현재 진행하고 있는 스프링 기반 프로젝트에서 인증 방식으로 Spring Security와 JWT토큰 인증 방식을 사용하고 있다.

해당 프로젝트에서 테스트코드를 작성하던 중, jwt 토큰과 관련해 인증 실패 테스트를 진행할 때, 내가 의도한 에러 처리가 되지 않고, 403 혹은 500에러로만 발생되는 것을 확인했다.

처음에는 전역처리를 위한 GlobalExceptionHandler에서 처리되야 하는 것이 아닌가… 라는 생각을 하며 나는 이미 전역처리를 통해 다 해놓았는데 왜 작동하지 않지? 라는 생각만 무한히 반복하고 있었다.

내가 Spring Security 필터에 대한 이해를 전혀 못하고 있었다.

다음의 그림을 보면 한방에 이해할 수 있다.

image

전역처리로 처리되지 않는 이유는, Security Filter는 아직 애플리케이션에 들어가지 못했기 때문이다.

Filter는 Spring 영역의 시작인 Dispatcher Servlet보다 앞에 존재하고, @RestControllerAdvice는 Handler Intercepter 쪽에 존재하며, 이는 Dispatcher Servlet보다 뒤에 존재한다.

그렇기 때문에, Filter에서 보낸 예외는 암만 에러를 던져도 GlobalExceptionHandler로 처리를 할 수 없던 것이다.


그렇다면 Security에 대한 에러는 어떻게 처리할까???

해답은 jwt 토큰 인증 필터를 처리했던 방법과 비슷하다.

나는, 인증 과정을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터인 UsernamePasswordAuthenticationFilter앞에 JwtTokenFilter를 두어 jwt 토큰에 대한 인증을 진행했다.

인증 요청 필터 전에 jwt 토큰에 대한 필터를 둔 이유는 jwt 토큰을 통해 인증 정보를 넘기기 때문이다.

같은 느낌으로, 토큰 예외 처리를 위한 필터 jwt 토큰 인증 필터 앞에 두어 에러를 처리하면 된다.

이 토큰 관련 에러 처리 클래스도 마찬가지로 OncePerRequestFilter를 상속받아 doFilterInternal메서드를 오버라이딩 하여 구현한다.


SecurityConfig.java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Value("${jwt.token.secret}")
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable() 
                .csrf().disable()      
                .cors()
                .and()
                    .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS) 
                .and()
                    .authorizeRequests()
                    .antMatchers("/api/v1/users/login","/api/v1/users/join", "/swagger-ui").permitAll() 
                    .antMatchers(HttpMethod.GET,"/api/v1/**").permitAll()   
                    .antMatchers(HttpMethod.POST,"/api/v1/**").authenticated()  
                    .antMatchers(HttpMethod.PUT, "/api/v1/**").authenticated()
                    .antMatchers(HttpMethod.DELETE, "/api/v1/**").authenticated()
                .and()
                    .addFilterBefore(new JwtTokenFilter(secretKey), UsernamePasswordAuthenticationFilter.class)
                    .addFilterBefore(new JwtTokenExceptionFilter(),JwtTokenFilter.class)
                .build();
    }
}

.addFilterBefore(new JwtTokenExceptionFilter(),JwtTokenFilter.class) 부분이 추가되었다.


이제 JwtTokenExceptionFilter를 작성한다.

@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenExceptionFilter extends OncePerRequestFilter {

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

        try {
//            log.info("토큰 검증 시작");
            filterChain.doFilter(request, response);
        } catch (ExpiredJwtException e) {       // 유효기간 만료 토큰
            log.error("만료된 JWT 토큰입니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);
        } catch (MalformedJwtException  e) {     // 구성이 잘못된 토큰(헤더,내용,서명이 없는 경우)
            log.error("올바르게 구성되지 못한 JWT 토큰입니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);
        } catch (SignatureException e) {        // 서명을 확인할 수 없는 토큰
            log.error("서명을 확인할 수 없는 토큰입니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);
        } catch (UnsupportedJwtException e) {   // 형식이 이상한 토큰
            log.error("지원하지 않는 형식의 JWT 토큰입니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);
        } catch (IllegalArgumentException | JwtException e) {  // 잘못된 토큰
            log.error("잘못된 JWT 토큰입니다.");
            setErrorResponse(response, ErrorCode.INVALID_TOKEN);

        }
    }

    private void setErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        response.setStatus(errorCode.getStatus().value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        ObjectMapper objectMapper = new ObjectMapper();

        ErrorResponse errorResponse = new ErrorResponse(errorCode);
        response.getWriter().write(objectMapper.writeValueAsString(Response.error("ERROR",errorResponse)));
    }
}

위와 같이, 토큰이 부적절한 경우를 나누고, 이를 setErrorResponse 메서드를 통해 내가 의도한 에러 코드를 낼 수 있게끔 설정한다.


그럼, Postman으로 토큰을 헤더에 넣고 요청해야 하는 게시글 등록 결과를 확인해보자.

토큰을 부적절하게 넣고, 돌려보면 다음과 같이 에러 처리가 된다.

image

인텔리제이 콘솔 창에서 내가 원하는 ‘서명을 확인 할 수 없는 토큰입니다`라는 로그가 정상적으로 찍히는 것을 볼 수 있다.

image



References

Leave a comment