[Spring Security, JWT, Redis] 인증/인가 관련 예외 처리

3Beom's 개발 블로그·2023년 8월 13일
0

프로젝트에서 Spring Security + JWT + Redis 방식으로 인증/인가 로직을 구현하였고, 전체 과정을 기록으로 남겨두려 한다.


인증/인가 관련 예외처리 과정

Spring Security의 인증 과정 예외처리 Filter

  • Spring Security에서 발생하는 인증 관련 예외 처리는 AuthenticationEntryPoint 인터페이스를 구현하고 SecurityConfig에 등록하면 된다.
// JwtAuthenticationEntryPoint

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;
    private final String UTF_8 = "utf-8";

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException authException) throws IOException, ServletException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setCharacterEncoding(UTF_8);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);

        response.getWriter().write(
            objectMapper.writeValueAsString(
								ResponseDto.create(AUTHENTICATION_FAILED.getMessage())
            )
        );
    }
}
  • AuthenticationEntryPoint 인터페이스를 implements 하여 인증 관련 예외가 발생했을 경우에 대한 처리 과정을 구현할 수 있다. (Servlet/JSP에 대해 배울 때 이후로 response.getWriter()를 너무 오랜만에 봐서 반가웠다..!)
  • 위 코드에서는 따로 구현해놓은 ExceptionMessage Enum의 AUTHENTICATION_FAILED 내용을 응답에 담아 반환하도록 설정했다.
    • ExceptionMessage
      // ExceptionMessage
      
      public enum ExceptionMessage {
          AUTHENTICATION_FAILED("인증 실패"),
          AUTHORIZATION_FAILED("접근 권한 없음");
      
          private final String message;
      
          ExceptionMessage(String message) {
              this.message = message;
          }
      
          public String getMessage() {
              return this.message;
          }
      }
  • 다음 SecurityConfig에 구현한 Filter를 등록한다.
// SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final ObjectMapper objectMapper;

...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()

            .authorizeRequests()
            .antMatchers(HttpMethod.GET, "/~~~").permitAll()
            .antMatchers("/~~~").authenticated()
            .regexMatchers(HttpMethod.POST, "/~~~").authenticated()
            .anyRequest().authenticated()
            .and()

            .and()
            .exceptionHandling()
            .authenticationEntryPoint(new JwtAuthenticationEntryPoint(objectMapper))

            // addFilterBefore() 메서드를 통해 Filter를 등록할 수 있다.
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper),
                UsernamePasswordAuthenticationFilter.class);;

        return http.build();
    }

...
  • 위와 같이 구현해 놓은 후 로그인에 실패해보면,

  • 이렇게 잘 처리되는 것을 확인할 수 있다.
  • 하지만 만약 AuthenticationEntryPoint 만으로 예외처리를 진행할 경우, JWT가 유효하지 않은 다양한 경우에 대한 예외 처리를 적용하는데 어려움이 있다.
    • commence() 메서드의 인자로 AuthenticationException authException 을 받아오지만, AuthenticationFilter에서 직접 발생시킨 예외는 해당 인자로 담겨오지 않는다.
  • 따라서 AuthenticationFilter에서 직접 예외 처리를 적용해주었다.

JWT의 예외처리 구현

  • 앞서 구현했던 JwtAuthenticationFilter에서 JWT의 예외처리를 구현해 두었었는데, 해당 내용을 모두 주석처리한 상태로 다시 가져와보자.
// JwtAuthenticationFilter

...

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
//        try {
            // 1. Request Header 로부터 JWT 토큰 받아옴
            String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

            // 2. JwtTokenProvider.validateToken() 으로 유효성 검사 진행
            if (token != null && jwtTokenProvider.validateToken(token)) {
                // 토큰 유효할 경우, 토큰으로부터 Authentication 객체를 받아와 SecurityContext에 저장
                Authentication authentication = jwtTokenProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            filterChain.doFilter(request, response);
//        } catch (TokenException e) {
//            response.setStatus(HttpStatus.UNAUTHORIZED.value());
//            response.setCharacterEncoding(UTF_8);
//            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
//
//            response.getWriter().write(
//                objectMapper.writeValueAsString(
//                    ResponseDto.create(e.getMessage())
//                )
//            );
//        }
    }

...
  • 위 코드를 보면 jwtTokenProvider.validateToken() 메서드를 통해 JWT 유효성 검사를 수행하는 것을 확인할 수 있다.
  • 앞서 구현했던 JwtTokenProvider 의 validateToken() 메서드를 다시 가져와보자.
// JwtTokenProvider

    public boolean validateToken(String token) throws TokenException {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
            throw new InvalidTokenException(INVALID_TOKEN.getMessage());
        } catch (ExpiredJwtException e) {
            throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
        }
    }
  • token 유효성 검사 과정에서 발생하는 다양한 예외들에 대해 처리하고 있다.
    • 토큰이 유효하지 않을 경우, 형식이 유효하지 않은 건지, 만료된 건지 등 다양한 상황에 대한 예외 처리가 이루어 지고 있는 것이다.
  • 하지만 위와 같은 예외 처리들은 AuthenticationEntryPoint만으로는 처리할 수 없었다.
    • 처음에는 인증 Filter들을 거치면서 AuthenticationException을 상속받는 예외가 발생할 경우, AuthenticationEntryPoint.commence() 메서드의 AuthenticationException authException 인자로 전달될 줄 알았는데 아니었다..!
  • 만약 AuthenticationEntryPoint만 구현해 둘 경우, 다음과 같이 수행된다.
    • AuthenticationFilter에서 JwtTokenProvider.validateToken() 메서드가 호출되어 유효성 검사를 수행한다.
    • 만약 유효하지 않을 경우, validateToken() 메서드에서 어떻게 유효하지 않은지에 대한 정보를 담은 예외를 발생시킨다.
    • 발생된 예외는 어느 곳에서도 catch 하여 처리하지 않게 되고, 결국 인증 실패로 이어져 AuthenticationEntryPoint에서 처리하는데, 내가 발생시킨 예외가 전달되진 않는다.
    • 이렇게 에러 로그들만 찍힌다..!
  • 따라서 JWT 유효성 검사 중 예외가 발생할 경우 AuthenticationFilter에서 바로 catch하여 응답하도록 구현하였다.
    • 인증이 필요하지 않은 요청의 경우 token 변수에 null이 담겨 jwtTokenProvider.validateToken() 메서드를 수행하지 않고 filterChain.doFilter() 로 다음 Filter로 넘어간다.
    • JWT 유효성 검사를 수행하는 요청은 모두 인증이 필요한 요청들이므로 만약 유효하지 않아 예외가 발생할 경우, 이후 Filter들은 진행할 필요가 없으므로 바로 응답하도록 filterChain.doFilter() 도 try 문 안에 넣어두었다.
  • 위 JwtAuthenticationFilter 에서 주석들을 다시 해제하고 유효하지 않은 토큰을 전달해 보면,

  • 이렇게 예외 메세지가 응답으로 잘 전달되는 것을 확인할 수 있다.

Spring Security의 인가 과정 예외처리 Filter

  • Spring Security에서 발생하는 인가 관련 예외 처리는 AccessDeniedHandler 인터페이스를 구현하고 SecurityConfig에 등록하면 된다.
// JwtAccessDeniedHandler

@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;
    private final String UTF_8 = "utf-8";

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding(UTF_8);

        response.getWriter().write(
            objectMapper.writeValueAsString(
                ResponseDto.create(ExceptionMessage.AUTHORIZATION_FAILED.getMessage())
            )
        );
    }
}
  • JwtAuthenticationEntryPoint와 같이 ExceptionMessage의 AUTHORIZATION_FAILED 메세지를 응답하도록 설정하였다.
  • AccessDeniedHandler로 똑같이 SecurityConfig에 등록해야 한다.
// SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final ObjectMapper objectMapper;

...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()

            .authorizeRequests()
            .antMatchers(HttpMethod.GET, "/~~~").permitAll()
            .antMatchers("/~~~").authenticated()
            .regexMatchers(HttpMethod.POST, "/~~~").authenticated()
            .anyRequest().authenticated()
            .and()

            .and()
            .exceptionHandling()
            .accessDeniedHandler(new JwtAccessDeniedHandler(objectMapper))
            .authenticationEntryPoint(new JwtAuthenticationEntryPoint(objectMapper))

            // addFilterBefore() 메서드를 통해 Filter를 등록할 수 있다.
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper),
                UsernamePasswordAuthenticationFilter.class);;

        return http.build();
    }

...
  • 이후 ROLE_ADMIN으로 설정된 url에 ROLE_USER 권한만을 가진 Access Token으로 접근해보면,

  • 이렇게 잘 동작하는 것을 확인할 수 있다.
profile
경험과 기록으로 성장하기

0개의 댓글