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

hellonayeon·2021년 12월 2일
19
post-thumbnail

구현 이유

현재 구현하고 있는 스프링 기반 웹 프로젝트에서 사용자 인증 방식으로 Spring Security + JWT 인증 방식을 사용하고 있다. 사용자 인증이 필요한 기능들과 사용자 인증이 없어도 사용 가능한 기능들을 구분해서 구현하고 있기 때문에 프론트 단에서 Local Storage토큰의 유무에 따라 기능을 열어주거나 강제로 로그인 페이지로 리다이렉트 시킨다.

여기서 문제는 로그아웃하면 Local Storage에 저장된 토큰을 삭제함으로써 이후 요청들에 대해 정상적인 처리가 가능하지만, 토큰의 기한이 만료된 경우 더이상 인증 절차를 밟을 수 없는 토큰인데도 불구하고 Local Storage에는 남아있다.

현재는 서버에서 JWT 인증 과정 절차를 밟던 중 예외가 발생해 사용자 인증을 하지 못하면 AuthenticationEntryPoint에 의해 401 상태를 리턴하도록 되있었다. 토큰 만료 토큰 정보 불일치 서명 불일치 에 대한 예외 모두 로그에만 내용을 남기지 프론트로 내려줄때 메시지는 "Unauthorization"으로 동일하게 처리했었다. 그래서 401 오류를 응답 코드로 받더라도 프론트 입장에서는 권한이 없음 정도만 알 수 있지 JWT 토큰이 잘못 된건지 사용자 인증을 위한 정보를 잘못 입력한건지에 대한 자세한 정보를 알 수 없었다. 토큰이 만료됐으면 로그인 페이지로 리다이렉트 시키고 싶은데 이런 처리를 할 수 없는 상황이다. 토큰이 만료 됐다 정도는 알아야 Local Storage에서 토큰을 삭제를 하던 재발급 요청을 하던 할텐데 말이다. 그래서 서버에서 JWT 토큰 인증을 처리하는 과정에서 예외가 발생하면 예외를 처리해서 해당되는 오류 메시지를 프론트에 응답으로 보낼 수 있도록 수정했다.

우리가 원하는 시나리오를 구현하기위해 이런 해결 방향으로 방법을 찾아보던 중.. 과연 프론트에 이렇게 오류 정보에 대한 메시지를 자세하게 내려주는게 맞나? 라는 생각이 들었다. 오류에 대한 정보가 친절하면 친절할 수록 공격자에게는 이득일테니 말이다. Refresh Token을 이용해서 만료된 토큰을 관리할 수 있도록 하는 방법으로 수정해야겠다.

기존 방식

기존에는 사용자 인증과 관련된 처리를 AuthenticationEntryPoint 구현체에서 하도록 했다. WebSecurityConfigurerAdapter에 인증에 관한 예외 처리를 AuthenticationEntryPoint에서 다루도록 설정해놓았고, 허용된 URL 요청 외에 요청들은 Filter를 거쳐서 JWT 토큰의 유효성을 검사하도록 설정했다.

WebSecurityConfig 클래스

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();

        http.authorizeRequests()
                .antMatchers("인증 없이도 허용할 API").permitAll()
                
                ...
                
                .anyRequest().authenticated().and()
                
                ...
                
                .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
 
 		... ;

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
    ...
}

JwtAuthenticationEntryPoint 클래스

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

JwtAuthenticationFilter 클래스

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;
    private final JwtTokenUtil jwtTokenUtil;

    String HEADER_STRING = "Authorization";
    String TOKEN_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
        String header = req.getHeader(HEADER_STRING);
        String username = null;
        String authToken = null;
        if (header != null && header.startsWith(TOKEN_PREFIX)) {
            authToken = header.replace(TOKEN_PREFIX,"");
            try {
                username = jwtTokenUtil.getUsernameFromToken(authToken);
            } catch (IllegalArgumentException e) {
                logger.error("an error occured during getting username from token", e);
            } catch (ExpiredJwtException e) {
                logger.warn("the token is expired and not valid anymore", e);
            } catch(SignatureException e){
                logger.error("Authentication Failed. Username or Password not valid.");
            }
        } else {
            logger.warn("couldn't find bearer string, will ignore the header");
        }
        
        ...

        chain.doFilter(req, res);
    }
}

이 글을 쓰기에 앞서 스프링에서 예외 처리하는 방법에 대해 간단히 정리했었다. 처음에는 JwtAuthenticationFilter에서 예외를 던져주면 Exception Handler에서 처리하면 되지 않을까.. 라는 행복회로를 돌렸다. 정신나간 생각을 잠깐 하며 Filter는 아직 애플리케이션에 들어가지 못했다는 것을 깨달았다. FilterDispatcher Servlet 보다 앞단에 존재하며 Handler Intercepter는 뒷단에 존재하기 때문에 Filter 에서 보낸 예외는 Exception Handler로 처리를 못한다.

수정 방식

구글에서 삽질하던 중 spring boot filter exception handler 키워드로 검색해보니 현재 필터보다 앞단에 예외 처리를 위한 필터를 하나 더 두고 FilterChain.chain 으로 원래의 JWT 유효성 검사를 하던 필터로 요청을 넘겨주는 방법이 있었다. 필터 구성을 이런식으로 해두면 다음 차례 필터의 로직 수행 중 던져진 예외가 앞서 거쳤던 필터로 넘어가서 처리가 가능하게 되나보다😱

즉, 원래는 요청 ➡️ JwtAuthenticationFilter 의 형태였다면, 요청 ➡️ JwtExceptionFilter ➡️ JwtAuthenticationFilter로 필터를 구성해서 JwtAuthenticationFilter에서 던진 예외를 JwtExceptionFilter가 처리할 수 있도록 했다.

WebSecurityConfig 클래스 수정

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtExceptionFilter jwtExceptionFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ...
        
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        // JwtAuthenticationFilter 앞단에 JwtExceptionFilter를 위치시키겠다는 설정
        http.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class);
    }
    ...

JwtExceptionFilter 클래스 생성

@Component
public class JwtExceptionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
        try {
            chain.doFilter(req, res); // go to 'JwtAuthenticationFilter'
        } catch (JwtException ex) {
            setErrorResponse(HttpStatus.UNAUTHORIZED, res, ex);
        }
    }

    public void setErrorResponse(HttpStatus status, HttpServletResponse res, Throwable ex) throws IOException {
        res.setStatus(status.value());
        res.setContentType("application/json; charset=UTF-8");

        JwtExceptionResponse jwtExceptionResponse = new JwtExceptionResponse(ex.getMessage(), HttpStatus.UNAUTHORIZED);
        res.getWriter().write(jwtExceptionResponse.convertToJson());
    }
}

JwtAuthenticationFilter 클래스 수정

    ...
    
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
        String header = req.getHeader(HEADER_STRING);
        String username = null;
        String authToken = null;
        if (header != null && header.startsWith(TOKEN_PREFIX)) {
            authToken = header.replace(TOKEN_PREFIX,"");
            try {
                username = jwtTokenUtil.getUsernameFromToken(authToken);
            } catch (IllegalArgumentException e) {
                logger.error("an error occured during getting username from token", e);
                // JwtException (custom exception) 예외 발생시키기
                throw new JwtException("유효하지 않은 토큰");
            } catch (ExpiredJwtException e) {
                logger.warn("the token is expired and not valid anymore", e);
                throw new JwtException("토큰 기한 만료");
            } catch(SignatureException e){
                logger.error("Authentication Failed. Username or Password not valid.");
                throw new JwtException("사용자 인증 실패");
            }
        } else {
            logger.warn("couldn't find bearer string, will ignore the header");
        }
        ...

노트 필기

참고 문서

📌 linked2ev. "13. 스프링부트 MVC - Filter 설정", 연어 좋아하는 개발자, 15 Sep 2019.

📌 이상혁 (Sang Hyuk Lee). "인터셉터(Interceptor) & 필터(Filter)", Hyuk의 기술블로그
, 27 February 2020.

🖼 코동이. "Filter vs Interceptor 차이 (Spring)", 너도 나도 함께 성장하자, 13 Aug 2021

3개의 댓글

comment-user-thumbnail
2022년 4월 7일

감사합니다!

1개의 답글
comment-user-thumbnail
2023년 6월 14일

감사합니다!

답글 달기