Security Filter 예외처리하기 - JWT

정상희·2023년 1월 16일
2

SpringSecurity

목록 보기
1/1

Spring Security에서 토큰 기반 인증 중 예외가 발생한다면 어떤 일이 일어나는지, 어떻게 핸들링 해야하는지에 대해 알아보자.

이전에 알아야 할 지식

  • 토큰 인증 방식

    인증받은 사용자에게 토큰을 발급해주고,

    서버에 요청을 할 때 HTTP 헤더에 토큰을 함께 보내 인증받은 사용자(유효성 검사)인지 확인한다.

  • Spring boot 예외처리 방식

    • @ControllerAdvice@RestControllerAdvice를 이용해서 컴포넌트를 생성하고 예외처리 메서드를 작성해놓으면 모든 클래스에 전역적으로 적용이 가능하다.

    • @ExceptionHandler을 통해 특정 컨트롤러의 예외를 처리한다.



Q. Spring Security에서 토큰을 검증할 경우, 예외가 발생한다면 기존에 사용 중이던 Custom Exception으로 처리가 될까?

A. 가능하다면 좋겠지만 불가능하다!

spring security와 spring boot 예외 처리구간이 다르다고 생각해보면 간단하다.

FilterDispatcher Servlet 보다 앞단에 존재하며 Handler Intercepter는 뒷단에 존재하기 때문에 Filter 에서 보낸 예외는 Exception Handler로 처리를 못한다.

따라서, 토큰 예외처리를 위해선 새로운 Filter를 정의해서 Filter Chain에 추가해줘야 한다.


1. SecurityConfig 클래스 수정

public class SecurityConfig  {
    private final UserService userService;
    @Value("${jwt.token.secret}")
    private String secretKey;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers("/api/v1/users/join", "/api/v1/users/login").permitAll()
                .antMatchers( "/api/v1/users/list","/api/v1/users/{userId}/role/change").hasAnyRole("ADMIN")
                .antMatchers(HttpMethod.GET,"/api/v1/posts/my", "/api/v1/alarms").authenticated()
                .antMatchers(HttpMethod.POST, "/api/v1/**").authenticated()
                .antMatchers(HttpMethod.PUT, "/api/v1/**").authenticated()
                .antMatchers(HttpMethod.DELETE, "/api/v1/**").authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(new CustomAuthenticationEntryPointHandler())
                .accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class)
                .build();

    }


addFilterBefore(Filter, beforeFilter)

beforeFilter가 실행되기 이전에 Filter을 먼저 실행시키도록 설정하는 메소드이다.

.addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class)

그 외 추가한 메소드 설명

.exceptionHandling()
 // 인증 과정에서 예외가 발생할 경우 예외를 전달한다.
             .authenticationEntryPoint(new CustomAuthenticationEntryPointHandler())
 // 권한을 확인하는 과정에서 통과하지 못하는 예외가 발생하는 경우 예외를 전달한다.
             .accessDeniedHandler(new CustomAccessDeniedHandler())

메소드를 살펴보면 인가 과정의 예외 상황에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPointHandler 로 예외를 전달하고 있었다.

다음은 이러한 클래스를 작성하는 방법이다.


2. CustomAccessDeniedHandler클래스 생성

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
		
        // 4. 토큰 인증 후 권한 거부
        ErrorCode errorCode = ErrorCode.FORBIDDEN_REQUEST;
        JwtTokenFilter.setErrorResponse(response, errorCode);
    }
}

AccessDeniedHandler

액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외

handle() 메소드를 오버라이딩한다.


3. CustomAuthenticationEntryPointHandler 클래스 생성

@Slf4j
@Component
public class CustomAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        final String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
		
        // 1. 토큰 없음 2. 시그니처 불일치
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            log.error("토큰이 존재하지 않거나 Bearer로 시작하지 않는 경우");
            ErrorCode errorCode = ErrorCode.INVALID_TOKEN;
            JwtTokenFilter.setErrorResponse(response, errorCode);
        } else if (authorization.equals(ErrorCode.EXPIRED_TOKEN)) {
            log.error("토큰이 만료된 경우");
            
		// 3. 토큰 만료
            ErrorCode errorCode = ErrorCode.EXPIRED_TOKEN;
            JwtTokenFilter.setErrorResponse(response,errorCode);
        }
    }
}

AuthenticationEntryPoint

인증이 실패한 상황을 처리한다.

commence() 메서드를 오버라이딩해서 코드를 구현한다.


4. 예외처리

에러코드는 enum으로 관리한다

@AllArgsConstructor
@Getter
public enum ErrorCode {

    INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 토큰입니다."),
    EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."),
    INVALID_PERMISSION(HttpStatus.UNAUTHORIZED, "사용자가 권한이 없습니다."),
    FORBIDDEN_REQUEST(HttpStatus.FORBIDDEN, "ADMIN 회원만 접근할 수 있습니다.");

    private final HttpStatus httpStatus;
    private final String message;
}

JwtTokenFilter에 메소드를 추가로 작성해서 가독성을 높였다.

	  /**
     * Security Chain 에서 발생하는 에러 응답 구성
     */
    public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(errorCode.getHttpStatus().value());
        ObjectMapper objectMapper = new ObjectMapper();

        ErrorResponse errorResponse = new ErrorResponse
                (errorCode, errorCode.getMessage());

        Response<ErrorResponse> error = Response.error(errorResponse);
        String s = objectMapper.writeValueAsString(error);

        /**
         * 한글 출력을 위해 getWriter() 사용
         */
        response.getWriter().write(s);
    }

5. ExceptionHandlerFilter

@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
    /**
     * 토큰 관련 에러 핸들링
     * JwtTokenFilter 에서 발생하는 에러를 핸들링해준다.
     * <토큰의 유효성 검사>
     */

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

        try {
            // 다음 filter Chain에 대한 실행 (filter-chain의 마지막에는 Dispatcher Servlet이 실행된다.)
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {

            //토큰의 유효기간 만료
            log.error("만료된 토큰입니다");
            setErrorResponse(response, ErrorCode.EXPIRED_TOKEN);

        } catch (JwtException | IllegalArgumentException e) {

            //유효하지 않은 토큰
            log.error("유효하지 않은 토큰이 입력되었습니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);

        } catch (NoSuchElementException e) {

            //사용자 찾을 수 없음
            log.error("사용자를 찾을 수 없습니다.");
            setErrorResponse(response, ErrorCode.USERNAME_NOT_FOUND);

        } catch (ArrayIndexOutOfBoundsException e) {

            log.error("토큰을 추출할 수 없습니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);

        } catch (NullPointerException e) {

            filterChain.doFilter(request, response);
        }
    }

}

필터 클래스를 직접 만들어 사용할 때 만약 filterChain.doFilter() 메소드를 호출해주지 않으면 다음 필터로 넘어가지도 않을 뿐더러 서블릿으로 요청이 가지도 못한다는 것에 주의해야 한다.



Filter 추가 설명

Q. ExceptionHandlerFilter와 JwtTokenFilter의 차이점은 무엇일까?

공통점 : OncePerRequestFilter을 상속받아 필터를 구현했고, 매 요청마다 각각의 필터가 한번씩만 실행된다.

Spring Security의 필터 설명

필터는 스프링 컨텍스트 외부에서 request와 response의 해당하는 작업을 가로채어 공통 로직을 수행한다.


차이점 :

  • JwtTokenFilter는 JWT토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스.
    - HttpServletRequest에서 토큰 추출
    - 토큰에 대한 유효성을 검사 > 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가
  • ExcptionHandlerFilter은 filter에서 터지는 exception들을 효율적으로 관리하기 위해 만든 클래스
    JwtTokenFilter실행 전에 호출하여, Security 필터에서 오류가 발생시 처리하는 예외처리 로직을 작성한다.
.addFilterBefore(new ExceptionHandlerFilter(), JwtTokenFilter.class)

인증/인가 수준에서 나올 수 있는 거의 모든 exception들을 처리하는 handler들이 security속에 다 구현돼 있어서 custom만 알맞게 한다면 직접 exceptionHandlerFilter를 만들 필요는 없는거 같다 (스프링부트 핵심가이드에서도 따로 ExceptionHandlerFilter을 구현하지 않고 에러처리를 했다.)

필터 수행 순서 : ExceptionHandlerFilter > JwtTokenFilter > UsernamePasswordAuthenticationFilter => Dispatcher Servlet



마무리

시큐리티를 처음 설정할 땐 낯설게 느껴지지만 핵심적인 클래스와 메서드를 짚어보면 큰 그림이 그려진다.

어려운 내용을 만났을 때 잘 모르고 다음으로 넘어가는 것보다 이렇게 하나씩 정리해두면

두고두고 이용해먹을 수 있겠다.




[참고문헌]

Security Filter에서 발생하는 Exception 처리하기

[Spring Boot] JWT 토큰 만료에 대한 예외처리

스프링부트핵심가이드(397~403)
[Spring] 스프링 Filter, DoFilter
스프링 필터와 스프링 시큐리티(Spring Security)의 동작 구조

0개의 댓글