Security Filter 예외처리하기 - JWT

정상희·2023년 1월 16일
2

SpringSecurity

목록 보기
1/1
post-custom-banner

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(jwtProvider, redisTemplate), UsernamePasswordAuthenticationFilter.class)
                .build();

    }


addFilterBefore(Filter, beforeFilter)

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

.addFilterBefore(new JwtTokenFilter(jwtProvider, redisTemplate), UsernamePasswordAuthenticationFilter.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);
    }

JwtTokenFilter의 전체 코드를 추가겠습니다


@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    /**
     * request 에서 전달받은 Jwt 토큰을 확인
     */

    private final String BEARER = "Bearer ";
    private final JwtProvider jwtProvider;
    private final RedisTemplate redisTemplate;



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

        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        log.info("First Authorization = {}", token);
        request.setAttribute("existsToken", true); // 토큰 존재 여부 초기화
        if (isEmptyToken(token)) request.setAttribute("existsToken", false); // 토큰이 없는 경우 false로 변경

        //쿠키 값 셋팅
        if (token == null) {
            Cookie[] cookies = request.getCookies();
            if (cookies != null) for (Cookie cookie : cookies) if(cookie.getName().equals("jwt")) token = cookie.getValue().replace("+", " ");
            else {
                HttpSession session = request.getSession(false);
                if (session == null || session.getAttribute("Authorization") == null) {
                } else {
                    token = session.getAttribute("Authorization").toString();
                }
            }
        }
        // 쿠키 조회했는데도 null이거나 'Bearer ' 로 시작하지 않으면 에러
        if (token == null || !token.startsWith(BEARER)) {
            log.info("Error At nullCheck Authorization = {}", token);
            filterChain.doFilter(request, response);
            return;
        }

        token = parseBearer(token);
        log.info("After remove Bearer Authorization = {}", token);

        if (jwtProvider.validateToken(token)) {
            // Redis 에 해당 accessToken logout 여부 확인
            String isLogout = (String)redisTemplate.opsForValue().get(token);
            log.info("isLogout?:{}",isLogout);

            if (ObjectUtils.isEmpty(isLogout)) {
                // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
                Authentication authentication = jwtProvider.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);

            } else if(isLogout.equals("logout")) {
                MakeError(response,ErrorCode.INVALID_PERMISSION);
                filterChain.doFilter(request, response);
                return;
            }
        }
        log.info("finish add Authorization to Security ContextHolder= {}", token);
        filterChain.doFilter(request, response);
    }

    private boolean isEmptyToken(String token) {
        return token == null || "".equals(token);
    }

    private String parseBearer(String token) {
        return token.substring(BEARER.length());
    }

    /**
     * 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> resultResponse = Response.error(errorResponse);

        // 한글 출력을 위해 getWriter()
        response.getWriter().write(objectMapper.writeValueAsString(resultResponse));
    }
}

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

  • JwtTokenFilter는 JWT토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스.
    - HttpServletRequest에서 토큰 추출
    - 토큰에 대한 유효성을 검사 > 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가
    인증된 사용자 점보를 관리

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



마무리

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

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

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




[참고문헌]

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

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

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

post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 6월 6일

좋은 글 잘 읽었습니다. 좋은 정보 감사합니다.
혹시 setErrorResponse() 메소드는 JwtTokenFilter 클래스에 있는 걸까요? ExceptionHandlerFilter에 있는 걸까요? 글에서는 JwtTokenFilter에서 선언했다고 되어있는데 그렇다면 ExceptionHandlerFilter에서 호출한 setErrorResponse()는 어떻게 호출된건지 알 수 있을까요?
디렉토리 구조를 몰라서 헤매고 있습니다.

1개의 답글