개발하는 자몽

[Spring Security] 토큰 기반 로그인/로그아웃 구현하기 본문

Java/Spring

[Spring Security] 토큰 기반 로그인/로그아웃 구현하기

jaamong 2024. 6. 8. 10:25

Notice  Spring Boot 3.2.5, Spring Security 6.2.5 기반으로 작성한 글입니다.

Notice  24.07.06 로그인 구현 부분 `JwtAuthenticationFilter` 잘못된 내용 수정

 

 

Spring Security Authentication Architecture

https://memodayoungee.tistory.com/135

Spring Security를 기반으로 보안 관련 기능을 구현하기 위해서는 위의 그림을 이해해야 한다. 아래는 로그인을 예시로 한 Spring Security 인증 과정이다.

 

1. 사용자가 로그인 정보와 함께 인증을 요청한다.

 

2. `AuthenticationFilter`가 요청을 가로채어 인증에 사용될 UsernamePasswordAuthenticationToken 객체를 생성한다.

...
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final UserDetailsService userDetailsService;
    private final AuthenticationManager authenticationManager;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {

        String token = getJwtFromRequest(request);
        if (StringUtils.hasText(token)) {

            /*
	            JWT 유효성 검사 로직
            */
            
            String username = tokenProvider.extractUsername(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            // 인증용 토큰 객체 생성
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    userDetails.getPassword(),
                    userDetails.getAuthorities()
            );
	          ...
           }
       ...
       }

 

3.`AuthenticationManager`의 구현체인 `ProviderManager`에게 인증 토큰 객체를 전달한다.

 

Note AuthenticationManager는 Spring Security 필터가 어떻게 인증을 수행해야 하는지 정의한다.

// AuthenticationManager의 구현체인 ProviderManager를 빈으로 등록
// ProviderManager에게 커스텀 AuthenticationProvider를 전달

    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) {
        JwtAuthenticationProvider provider = new JwtAuthenticationProvider(userDetailsService, passwordEncoder());
        return new ProviderManager(provider);
    }

 

4. `ProviderManager`는 등록된 `AuthenticationProvider`를 조회하고, 인증을 시도한다.

ProviderManager

각 `AuthenticationProvider`는 특정 타입의 인증을 어떻게 수행하는지 알고 있으므로 어떤 `AuthenticationProvider`는 username/password 검증을 하거나 SAML assertion을 인증할 수도 있다.

public ProviderManager(AuthenticationProvider... providers) {	
	...
	public List<AuthenticationProvider> getProviders() {
        return this.providers;
  }
  ...
}

 

5, 실제 DB를 통해 사용자의 인증 정보를 가져오는 `UserDetailsService`에게 인증 토큰 객체에 담긴 사용자 정보를 전달한다.

...
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;

        String username = authToken.getName();
        if (username == null) {
            throw new UsernameNotFoundException("Invalid username or password");
        }

        // UserDetailsService에게 사용자 정보 전달
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
       
	      ...
    }

 

6. 받은 사용자 정보를 이용하여 DB에서 해당 사용자 정보를 찾는다. 찾은 사용자 정보를 바탕으로 `UserDetails` 객체를 생성한다.

...
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

       //DB에서 사용자 정보 조회
        UserEntity user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        //UserDetails 객체 생성
        return new User(
                user.getUsername(),
                user.getPassword(),
                mapAuthorities(user.getRole())
        );
    }

    //사용자가 가진 권한 반환
    private Collection<? extends GrantedAuthority> mapAuthorities(Role role) {
        return role.getAuthorities();
    }
}

 

7. `AuthenticationProvider`는 생성한 `UserDetails` 객체를 반환받아, 해당 객체와 인증 토큰 객체에 담긴 사용자 정보를 비교한다.

...
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        ...        
        // UserDetailsService에게 사용자 정보 전달
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        String password = userDetails.getPassword();
       
        // 사용자 정보 비교
        // verifyCredentials: 비밀번호를 검증하는 커스텀 메서드 - 보통 PasswordEncoder#matches 사용
        verifyCredentials(password, (String) authToken.getCredentials());
        ...
    }

 

8. 인증이 완료되면 권한을 포함하여 사용자 정보를 담은 `Authentication` 객체를 반환한다.

 

  • 생성할 `Authentication` 객체에는 보안을 위해 비밀번호(credentials)를 담지 않는다.
  • 위 `Authentication` 객체는 `UsernamePasswordAuthenticationToken`으로 구현할 수 있다.
...
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

	      ...        
	      // UserDetailsService에게 사용자 정보 전달
	      ...
       
	      // 사용자 정보 비교
	      ...
				
	      // 인증 토큰 발급 및 반환
	      return new UsernamePasswordAuthenticationToken(
                 username,
                 null,
                 userDetails.getAuthorities()
	      );				
    }

 

9. 돌아가서 `AuthenticationFilter`에 `Authentication` 객체가 반환된다. [인증 완료]

...
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final UserDetailsService userDetailsService;
    private final AuthenticationManager authenticationManager;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {

        String token = getJwtFromRequest(request);
        if (StringUtils.hasText(token)) {
						
            ...
						            
            // 인증용 토큰 객체 생성: authenticationToken 
            ...
            
            // 인증 시도 -> 인증 완료: authentication 객체 받음
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            
            ...
           }
       ...
       }

 

10. `SecurityContextHolder`의 세션 영역에 있는 `SecurityContext`에 `Authentication` 객체를 저장한다.

 

Note 사용자 정보를 저장하는 것은 Spring Security가 전통적인 세션-쿠키 기반의 인증 방식을 사용함을 의미한다.

...
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final UserDetailsService userDetailsService;
    private final AuthenticationManager authenticationManager;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {

        String token = getJwtFromRequest(request);
        if (StringUtils.hasText(token)) {
            ...
						            
            // 인증용 토큰 객체 생성: authenticationToken 
            ...
            
            // 인증 시도 -> 인증 완료: authentication 객체 받음
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            
            // 인증 객체 설정
            SecurityContextHolder.getContext().setAuthentication(authentication);

            ...
           }
       ...
       }

 

 

⇒ 위 모든 과정을 거치고 나서 `Dispatcher Servlet`으로 요청을 넘긴다.

...
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final UserDetailsService userDetailsService;
    private final AuthenticationManager authenticationManager;

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {

        String token = getJwtFromRequest(request);
        if (StringUtils.hasText(token)) {
                // 4 ~ 10 과정
        }
        //요청 넘김 (애플리케이션의 나머지 동작 수행)
        filterChain.doFilter(request, response);
    }
모든 필터를 통과하고 애플리케이션에서 할 일을 마친 후 바로 클라이언트로 응답이 가는 것이 아닌, 다시 필터를 통해서 돌아가는 것을 기억합시다.

 

 

 

토큰 기반 로그인(인증) 구현

Spring Security는 기본적으로 `formLogin`을 제공하는데 이는 HTML 폼 기반으로 REST API가 필요할 때는 별도로 구현해야 한다. 또한 JWT를 사용하므로 security configuration 클래스에 관련 설정을 해야 한다.

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    ...
   
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .csrf(AbstractHttpConfigurer::disable)
                .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))  // H2 웹콘솔 iframe 정상 작동을 위해 같은 Origin에 대해 허용
                .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) //here
                ...
                ;

        return http.build();
    }
}

 

현재 토큰 기반의 인증을 구현하므로 사용자의 로그인 요청이 성공하면 Access Token과 Refresh Token을 발급하도록 구현한다. 로그인 요청 시에는 토큰 인증이 아닌 로그인 요청 정보(Response Body)만 검증하도록 한다.

 

따라서 로그인 요청인 경우에는 Header에 토큰이 없으므로 바로 다음 필터로 넘어가게 된다.

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    ...
    
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws ServletException, IOException {

				
                // JWT 검사 및 인증 객체 설정
                String token = getJwtFromRequest(request);
                if (StringUtils.hasText(token)) {
                	//Header에 JWT가 있는 요청들은 이 부분 실행
                	...
                }
                //Header에 JWT가 없는 요청들은 바로 doFilter() 실행. Ex) 회원가입 등...
                filterChain.doFilter(request, response);		
        }
}

토큰 인증 등의 과정을 진행하지 않고 바로 `FilterChain.doFilter(request, response)`를 호출한다.

 

Note 참고로 spring security configuration에서 해당 URI를 `permitAll()` 처리해도 이는 `filter`를 거칩니다. 아예 해당 URI를 무시하는 방법도 있으나 이는 보안 측면에서 권장되지 않는 방법입니다.

 

이제 로그인 요청을 처리하는 컨트롤러 코드를 구현한다.

...

@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    private final AuthenticationService authService;

    @PostMapping("/sign-in")
    public ResponseEntity<AuthenticationRespDto> authenticate(@RequestBody AuthenticationReqDto dto) {

        log.info("[authenticate] login request: {}", dto);
        authService.isValidCredentials(dto); //here
				
				...
    }
}

 

로그인 정보와 함께 `/api/auth/sign-in`으로 요청이 들어오면 `AuthService#isValidCredentials` 메서드에서 로그인 정보를 검증한다. 

@Service
@Transactional
@RequiredArgsConstructor
public class AuthenticationService {

    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    ...
    
    public AuthenticationRespDto authenticate(AuthenticationReqDto dto) {

        //jwt, refresh token 발급
        UserDetails userDetails = userDetailsService.loadUserByUsername(dto.username());
        String jwt = tokenProvider.generateToken(userDetails);
        String refreshToken = tokenProvider.generateRefreshToken(userDetails);

        //이전 토큰 무효화 및 새 토큰 저장
        UserEntity user = userRepository.findByUsername(dto.username())
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        revokeUserAllTokens(user);
        saveUserToken(user, jwt);

        return new AuthenticationRespDto(jwt, refreshToken);
    }
			
    public void isValidCredentials(AuthenticationReqDto dto) {
        UserEntity user = userRepository.findByUsername(dto.username())
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));

        if (user == null || !passwordEncoder.matches(dto.password(), user.getPassword())) {
            throw new BadCredentialsException("Invalid username or password");
        }
    }
    
    ...
}
  • `username`이 DB에 있는지 확인한다. 없으면 `UsernameNotFoundException`을 던지고, 있으면 해당 정보를 가진 객체를 꺼낸다.
  • DB에서 꺼낸 객체의 `password`와 사용자가 입력한 `password`를 비교한다. 일치하지 않으면 `BadCredentialsException`을 던진다.

 

검증이 성공적으로 완료되면 다시 컨트롤러로 돌아간다.

...

@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    private final AuthenticationService authService;

    @PostMapping("/sign-in")
    public ResponseEntity<AuthenticationRespDto> authenticate(@RequestBody AuthenticationReqDto dto) {

        log.info("[authenticate] login request: {}", dto);
        authService.isValidCredentials(dto);
        
        AuthenticationRespDto response = authService.authenticate(dto); //here
        log.info("[authenticate] login response: {}", response);
				
				...
    }
}

 

`AuthService#authenticate`를 호출하여 JWT 토큰을 생성한다.

@Service
@Transactional
@RequiredArgsConstructor
public class AuthenticationService {

    private final UserDetailsService userDetailsService;
    private final TokenProvider tokenProvider;
    private final UserRepository userRepository;
    private final TokenRepository tokenRepository;
    ...
    
    public AuthenticationRespDto authenticate(AuthenticationReqDto dto) {

        //jwt, refresh token 발급
        UserDetails userDetails = userDetailsService.loadUserByUsername(dto.username());
        String jwt = tokenProvider.generateToken(userDetails);
        String refreshToken = tokenProvider.generateRefreshToken(userDetails);

        //이전 토큰 무효화 및 새 토큰 저장
        UserEntity user = userRepository.findByUsername(dto.username())
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
        revokeUserAllTokens(user);
        saveUserToken(user, jwt);

        return new AuthenticationRespDto(jwt, refreshToken);
    }	
    
    ...
}
  • `UserDetailsService#loadUserByUsername`를 이용하여 사용자 정보를 담는 객체인 `UserDetails`를 생성한다.
  • 해당 객체를 `TokenProvider#generateToken`으로 넘겨 사용자 정보를 바탕으로 Access Token을 생성한다.
  • 그다음에 Refresh Token을 생성하는 `TokenProvider#generateRefreshToken`을 호출한다.
  • 토큰 생성 과정에서 예외 발생 없이 잘 넘어가면 사용자가 이전에 발급받은 모든 토큰을 무효화(invalide) 처리한다.
        private void revokeUserAllTokens(UserEntity user) {
            List<Token> userTokens = tokenRepository.findAllValidTokenByUser(user.getId());
    
            if (userTokens.isEmpty()) return;
    
            // 사용자 모든 토큰 만료시키기
            userTokens.forEach(token -> {
                token.configureRevoked(true);
                token.configureExpired(true);
            });
    
            tokenRepository.saveAll(userTokens);
        }
  • 이제 새롭게 발급한 토큰을 저장한다.
        private void saveUserToken(UserEntity user, String jwt) {
            Token token = Token.builder()
                    .user(user)
                    .token(jwt)
                    .expired(false)
                    .revoked(false)
                    .build();
            tokenRepository.save(token);
        }
  • 모든 과정이 성공적으로 완료되면 생성된 Access Token, Refresh Token을 DTO 객체에 담아 반환한다.

다시 컨트롤러로 돌아온다.

...

@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {

    private final AuthenticationService authService;

    @PostMapping("/sign-in")
    public ResponseEntity<AuthenticationRespDto> authenticate(@RequestBody AuthenticationReqDto dto) {

        log.info("[authenticate] login request: {}", dto);
        authService.isValidCredentials(dto);
        
        AuthenticationRespDto response = authService.authenticate(dto); //here
        log.info("[authenticate] login response: {}", response);
				
        return ResponseEntity.ok(response); //here
    }
}

반환받은 DTO를 `ResponseEntity`에 담아서 사용자에게 반환한다.

 

 

토큰 기반 로그아웃 구현

Spring Security에 JWT 기반 로그아웃을 위한 내장된 구현은 별도로 없다. JWT의 stateless 특성으로 인하여 서버는 사용자의 세션을 저장하거나 추적하지 않기 때문이다.

따라서 클라이언트가 토큰 관리와 로그아웃 과정 초기화를 담당하게 된다. 프론트엔트의 경우 사용자는 토큰을 로컬 스토리지에서 제거하거나 토큰이 만료되도록 만료 날짜를 변경 또는 설정하면 된다. 또는 백엔드에서 이러한 토큰에 관한 보안이나 무효화하는 메커니즘을 구현할 수도 있다.

여기에서는 로그아웃을 구현할 때 Spring Security에게 로그아웃을 위한 엔드포인트를 제공하고, Spring은 개발자에게 로그아웃 핸들러를 제공한다.

 

우선 Security Configuration에서 로그아웃 설정을 한다.

...

@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {

		...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                ...
                .logout(logout -> logout.logoutUrl("/api/auth/sign-out")
                        .addLogoutHandler(logoutHandler)
                        .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext())
                );

        return http.build();
    }
}
  • 로그아웃 요청을 받을 URL을 지정한다.
  • 로그아웃을 처리할 `LogoutHandler`를 등록한다. 이 핸들러는 `LogoutHandler` 인터페이스를 구현한 구현체이다.
    ...
    
    @Service
    @RequiredArgsConstructor
    public class LogoutHandlerImpl implements LogoutHandler {
    
        private final TokenRepository tokenRepository;
    
        @Override
        public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
            String token = getJwtFromRequest(request);
    
            Token storedToken = tokenRepository.findByToken(token)
                    .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 토큰(JWT)입니다."));
    
            //토큰 무효화하기
            storedToken.configureExpired(true);
            storedToken.configureRevoked(true);
    
            tokenRepository.save(storedToken);
        }
    
        private String getJwtFromRequest(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");
    
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
                return bearerToken.substring(7);
            }
            return null;
        }
    }
    • 요청에서 토큰을 꺼낸다.
    • 해당 토큰이 DB에 있는지 확인한다.
      • 토큰이 없는 경우 `404 NOT FOUND` 에러가 발생한다.
    • DB에 토큰이 있으면 토큰을 무효화 설정한다.
    • 설정 후에 해당 변경을 저장한다.
  • 로그아웃이 성공하면 호출되는 `logoutSuccessHandler`를 설정한다.

여기의 로그아웃 구현은 로그아웃 핸들러 외에 특별히 설정할 것이 없다. 관련하여 자세한 내용은 아래 링크를 참고!

 

Handling Logouts :: Spring Security

If you are using Java configuration, you can add clean up actions of your own by calling the addLogoutHandler method in the logout DSL, like so: Custom Logout Handler CookieClearingLogoutHandler cookies = new CookieClearingLogoutHandler("our-custom-cookie"

docs.spring.io

 

 

번외. JWT Authentication

기간 내에 구현하려고 하다 보니 공식 문서를 꼼꼼히는 뒤늦게 보게 됐는데, 이런 부분이 있더라... 나중에 참고하기 위해 남겨두기..

 

OAuth 2.0 Resource Server JWT :: Spring Security

Most Resource Server support is collected into spring-security-oauth2-resource-server. However, the support for decoding and verifying JWTs is in spring-security-oauth2-jose, meaning that both are necessary in order to have a working resource server that s

docs.spring.io

 

 

 

 

 

🔖참고

Comments