스프링 시큐리티 필터 예외처리 적용하기

췌누의 개발·2024년 4월 16일
post-thumbnail

행운복권 프로젝트를 진행하면서 자체 JWT를 사용하여 사용자 인증을 구현하고 있었다. Security Filter에서 asseccToken 토큰의 유효성을 확인하고 예외가 발생했을 때 우리가 원하는 json 형태의 응답을 클라이언트로 전달하고자 한다.


@ControllerAdvice, @ExceptionHandler

처음에는 REST API 예외를 처리하기 위해 서 @ControllerAdvice, @ExceptionHandler를 이용하여 Global Exception을 처리하면 Security Filter에서 발생한 예외들을 처리할 수 있을 것이라 생각했다.....

GlobalExceptionHandler

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(LuckLotteryException.class)
    public ResponseEntity<ErrorResponse> luckLotteryExceptionHandler(
            LuckLotteryException e, HttpServletRequest request) {

        ErrorCode code = e.getErrorCode();
        ErrorResponse errorResponse =
                new ErrorResponse(
                        code.getStatus(),
                        code.getReason(),
                        request.getRequestURL().toString());
        return ResponseEntity.status(HttpStatus.valueOf(code.getStatus())).body(errorResponse);
    }
}    

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{

        http

                .csrf(AbstractHttpConfigurer::disable)
                .addFilterBefore(new JwtTokenFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests((registry) ->
                        registry.requestMatchers("/api/v1/credentials/login2/**").permitAll()
                                .requestMatchers("/api/v1/winning/pension/lottery/admin/save/db",
                                        "/api/v1/winning/lottery/admin/save/db").hasRole(AccountRole.ADMIN.getValue())
                                .anyRequest().authenticated()
                );


        return http.build();
    }


}

토큰이 유효하다면 Authentication을 생성하고 SecurityContextHolder에 넣는 기능을 JwtTokenFilter 필터로 구현했다.

jwtTokenProvider

  private Jws<Claims> getJws(String token) {
        try {
            return Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
        } catch (ExpiredJwtException e) {
            throw ExpiredTokenException.EXCEPTION;
        } catch (Exception e) {
            log.info("현재 토큰 : {}",token);
            throw InvalidTokenException.EXCEPTION;
        }
    }

예외를 처리하는 테스트 진행하기 위해 JwtTokenFilter에서 예외를 발생시켰다.

결과

그러나 우리가 원하는 http 상태 코드, 원하는 json 형태의 응답을 받을 수 없었다. 예외가서블릿 컨테이너에서 was까지 올라간 것을 확인할 수 있었다. 왜 예외를 잡을 수 없었을까?

스프링 예외 처리 흐름

컨트롤러에서 예외가 발생했을 때 스프링의 DispatcherServlet에서 핸들러를 호출한 후 ExceptionResolver에서 @ExceptionHandler를 찾아 예외를 해결하고 정상 흐름으로 was에 전달하게 된다.


그러나 우리는 Filter 단계에서 예외가 발생했기 때문에 DispatcherServlet에 도달하지도 못하고 Security Filter 단계에서 Exception을 throw 해버려 예외를 던져 서블릿 컨테이너에서 was까지 예외가 발생한 것이다.


디버깅으로 직접 확인해보자

Spring Secuirty는 SecurityFilterChain의 인증과 인가 과정에서 예외가 발생하면 ExceptionTranslationFilter가 예외를 처리한다.

ExceptionTranslationFilter

chain.doFilter는 애플리케이션 나머지 부분을 호출하고 예외가 발생하여 예외를 catch 하고 handleSpringSecurityException을 호출한다.

handleSpringSecurityException에서 인증과 인가에 대한 예외를 확인한다. 인가 단계에서 예외가 발생했고 handleaccessDeniedExection 메서드를 호출했다.

handleaccessDeniedExection 메서드를 간단히 보면 isAnonymous()에서 인증이 된 사용자인지 확인한다.

  • 유저가 단순히 인증을 하지 않아 접근 권한이 없는지 확인
  • 인증된 사용자가 접근 권한이 없는 것인지 확인

필자는 전자의 상황이 발생하여 sendStartAutjentication 함수가 호출 됐다.

sendStartAutjentication 인증을 시작하고 마지막으로 commence()을 호출한다.

commence() 내부에서 에러를 send 하는 것을 확인했고 예외가 was까지 올라갔기 때문에 @ControllerAdvice, @ExceptionHandler에서 우리의 예외를 잡을 수 없었던 것이다.

해결해 보자


ExceptionFilter를 구현하여 JwtTokenFilter 앞에 배치하여 필터에서 인증과 인가 시 발생하는 예외를 잡아 처리할 수 있도록 했다.

ExceptionFilter

@RequiredArgsConstructor
public class ExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (LuckLotteryException e) {
            writeErrorResponse(response, e.getErrorCode(), request.getRequestURL().toString());
        } catch (Exception e) {
            if (e.getCause() instanceof LuckLotteryException) {
                writeErrorResponse(
                        response,
                        ((LuckLotteryException) e.getCause()).getErrorCode(),
                        request.getRequestURL().toString());
            } else {
                e.printStackTrace();
                writeErrorResponse(
                        response,
                        ErrorCode.INTERNAL_SERVER_ERROR,
                        request.getRequestURL().toString());
            }
        }
    }

    // 응답을 생성하는 메서드 
    private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode, String path)
            throws IOException {
        ErrorResponse errorResponse =
                new ErrorResponse(
                        errorCode.getStatus(), errorCode.getReason(), path);

        response.setStatus(errorCode.getStatus());
        response.setCharacterEncoding("UTF-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }


}

ErrorResponse

@Getter
public class ErrorResponse {

    private final boolean success = false;
    private final int status;
    private final String reason;
    private final LocalDateTime timeStamp;
    private final String path;

    public ErrorResponse(int status, String reason, String path) {
        this.status = status;
        this.reason = reason;
        this.timeStamp = LocalDateTime.now();
        this.path = path;
    }
}

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final ObjectMapper objectMapper;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{

        http
                .csrf(AbstractHttpConfigurer::disable)
                .addFilterBefore(new JwtTokenFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new ExceptionFilter(objectMapper), JwtTokenFilter.class)
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests((registry) ->
                        registry.requestMatchers("/api/v1/credentials/login2/**").permitAll()
                                .requestMatchers("/api/v1/winning/pension/lottery/admin/save/db",
                                        "/api/v1/winning/lottery/admin/save/db").hasRole(AccountRole.ADMIN.getValue())
                                .anyRequest().authenticated()
                );


        return http.build();
    }


}

결과

우리가 원하는 http 상태 코드와, json 응답이 잘 나온 것을 확인할 수 있었다.


프로젝트 링크를 통해서 참고하시면 좋을 것 같습니다! 감사합니다.
도움이 되셨으면 좋겠습니다.👋🏼

행운 복권 깃허브 링크
https://github.com/Uttug-Seuja/luck-lottery-server

profile
아샷추를 좋아합니다

0개의 댓글