[Spring Security] AuthenticationException과 AuthenticationEntryPoint

olsohee·2023년 9월 5일
0

Spring Security

목록 보기
4/6
post-thumbnail

1. webSecurityCustomizer()로 필터를 타지 않게 했을 때의 문제

프로젝트 진행 중 OAuth2를 사용한 카카오 인증 방식 구현에서 문제가 발생했다. 바로 리다이렉트 URL로 리다이렉트 되는 과정에서 다음과 같이 오류가 발생하고, oAuth2SuccessHandleroAuth2UserService가 호출되지 않는 문제이다.

이유는 바로 다음과 같은 webSecurityCustomizer()로 인해 리다이렉트 경로가 (/login/oauth2/kakao) 필터를 거치지 않기 때문이다.

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> {
            web.ignoring()
                    // 해당 경로는 security filter chain을 생략
                    // 즉 permitAll로 설정하여 로그인 없이 접근 가능한 URL을 아래에 추가하여
                    // 해당 URL 요청들은 JwtFilter, JwtExceptionFilter를 포함한 스프링 시큐리티의 필터 체인을 생략
                    .requestMatchers(
                            "/login/**",
                            ...
                            )
                    .requestMatchers(POST, "/sms/send/**")
                    .requestMatchers(GET, "/community/articles/**");
        };
    }

즉 리다이렉트 경로가 필터를 거치지 않기 때문에 oAuth2SuccessHandleroAuth2UserService가 호출되지 않았던 것이다. 그리고 이때 필터가 oAuth2SuccessHandleroAuth2UserService를 호출한다는 것을 유추할 수 있다.


2. JwtExceptionFilter를 통한 JwtException 예외 처리

그렇다면 webSecurityCustomizer()는 왜 필요했을까?

다음과 같이 JwtFilter를 스프링 시큐리티 필터로 등록하고, 토큰과 관련된 예외가 발생하면, JwtFilter의 앞 단에 등록한 JwtExceptionFilter로 예외가 던져지고, JwtExceptionFilter에서 예외 처리(예외 메시지 응답)를 해주었다.

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

        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authHttp ->
                        authHttp.requestMatchers(
                                        ...).permitAll()
                                .anyRequest().authenticated()
                )
                .oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/login")
                        .successHandler(oAuth2SuccessHandler)
                        .userInfoEndpoint(userInfo -> userInfo 
                                .userService(oAuth2UserService) 
                        )
                )
                .addFilterBefore(new JwtFilter(jwtTokenUtils), AuthorizationFilter.class)
                .addFilterBefore(new JwtExceptionFilter(), JwtFilter.class)
        ;

        return http.build();
    }

그런데 permitAll()로 설정한 경로들도 마찬가지로 JwtExceptionFilter, JwtFilter 등 스프링 시큐리티 필터를 모두 타고 있는 것이다. 따라서 permitAll()로 설정한 로그인이 필요 없는 경로의 요청까지도, JwtFilter에서 토큰 검증이 진행되고, 토큰이 없는 경우 JwtException이 발생하여 JwtExceptionFilter에서 예외 응답을 하고 있는 것이다.

따라서 로그인이 필요 없는 경로는 필터 자체를 타지 않도록 webSecurityCustomizer()로 설정해놓은 것이었다.

그런데 이러한 설정 때문에 카카오 로그인 방식 구현시, 리다이렉트 경로가 필터를 타지 않는 경로이고, 따라서 호출되어야 할 oAuth2SuccessHandleroAuth2UserService가 호출되지 않았던 것이다.


3. AuthenticationEntryPoint를 통한 예외 처리

따라서 모든 요청에 대해 필터를 거치게 하기 위해 webSecurityCustomizer()를 지워보자. 그러면 JwtExceptionFilter를 통해 예외 처리가 불가하다. JwtFilter에서 발생한 예외를 JwtExceptionFilter에서 예외 메시지를 응답하며 예외 처리를 하면, 로그인이 필요 없는 경로에도 예외 메시지가 응답되기 때문이다.

따라서 스프링 시큐리티의 인증 관련 예외인 AuthenticationException이 발생하면 호출되는 AuthenticationEntryPoint 통해 예외 처리를 해보자.

AuthenticationEntryPoint를 구현한 JwtAuthenticationEntryPoint를 다음과 같이 정의해주었다.

@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final HandlerExceptionResolver resolver;

    public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
        this.resolver = resolver;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {

        log.info("JwtAuthenticationEntryPoint 실행");

        // HandlerExceptionResolver에게 예외 처리를 위임
        resolver.resolveException(request, response, null, e);
    }
}

그리고 JwtAuthenticationEntryPoint는 전달받은 예외를 HandlerExceptionResolver에게 예외 처리를 위임한다. 따라서 ExceptionController에서 예외 응답이 나간다.

@RestControllerAdvice
@Slf4j
public class ExceptionController {

    // AuthenticationException 예외 처리
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e, HttpServletRequest request) {

        log.error("AuthenticationException 발생");
        log.info(request.getRequestURL().toString());
        log.info(e.getMessage());
        return ErrorResponse.createErrorResponse(e);
    }
    
    ...
    
}

그리고 다음과 같이 정의해준 JwtAuthenticationEntryPoint를 등록한다.

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

        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authHttp ->
                        authHttp.requestMatchers(
                                        ...
                                        ).permitAll()
                                .anyRequest().authenticated()
                )
                .oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/login")
                        .successHandler(oAuth2SuccessHandler)
                        .userInfoEndpoint(userInfo -> userInfo 
                                .userService(oAuth2UserService) 
                        )
                )

                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)
                .and()
                
                .addFilterBefore(new JwtFilter(jwtTokenUtils), AuthorizationFilter.class)
                //.addFilterBefore(new JwtExceptionFilter(), JwtFilter.class)
        ;

        return http.build();
    }

그리고 JwtFilter에서 발생한 예외를 잡지 않으면, 스프링 시큐리티는 /error 경로로 리다이렉트한다. 그리고 이때 AuthenticationException이 발생한다. 따라서 다음과 같이 JwtFilter에서 예외가 발생하면 log를 남기며 예외를 잡아주자.

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenUtils jwtTokenUtils;

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


        log.info("JwtFilter 실행");

        // 요청헤더에 토큰이 있는지 검증
        try {
            String token = jwtTokenUtils.getTokenFromHeader(request);

            // 있다면, 토큰이 유효한지 검증
            if(jwtTokenUtils.validateAccessToken(token)) {

                // 토큰이 유효하면 authentication 반환 후 SecurityContextHolder에 저장
                Authentication authentication = jwtTokenUtils.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("SecurityContextHolder에 authentication 저장 완료");
            }
        } catch (JwtException e) {
            log.info("JwtException 발생");
        }

        filterChain.doFilter(request, response);

        log.info("JwtFilter 끝");
    }
}

결과

permitAll 설정 유무에 따른 차이

이때 permitAll 설정 유무에 따른 차이를 알아야 한다! permitAll로 설정한 경로든, 아닌 경로든 필터를 탄다는 것은 같다.

그러나 permitAll로 설정한 경로는, 모든 필터를 처리한 후에 SecurityContext에 인증 정보가 없어도 예외를 발생시키지 않고, permitAll로 설정하지 않은 경로는 모든 필터 처리 후에 SecurityContext에 인증 정보가 없으면 예외를 발생시킨다.

1. permitAll로 설정한 경로

JwtFilter에서 토큰 검증을 진행하고, 토큰이 없으므로 JwtException이 발생한다.

JwtFilter에서 JwtException을 잡고 로그를 남긴다.

예외가 잡혔으므로, 이어서 다음 필터들이 진행되고 최종적으로 컨트롤러까지 정상 동작한다.

2. permitAll로 설정하지 않은 경로: 토큰 전달 X

JwtFilter에서 토큰 검증을 진행하고, 토큰이 없으므로 JwtException이 발생한다.

JwtFilter에서 JwtException을 잡고 로그를 남긴다.

예외가 잡혔으므로, 이어서 다음 필터들이 진행되고 모든 필터가 진행된 후에 SecurityContext에 인증 정보가 없으므로 AuthenticationException가 발생한다.

AuthenticationException가 발생하고 JwtAuthenticationEntryPoint가 호출되면서 예외 응답이 나간다.

3. permitAll로 설정하지 않은 경로: 토큰 전달 O

JwtFilter에서 토큰 검증을 진행하고, 토큰이 있고 유효하므로 JwtException이 발생하지 않는다. 그리고 이때 SecurityContext에 인증 정보를 저장한다.

이어서 다음 필터들이 진행되고 모든 필터가 진행된 후에 SecurityContext에 인증 정보가 있으므로 AuthenticationException가 발생하지 않고, 정상적으로 컨트롤러가 호출된다.

여전히 남은 문제

결과적으로 permitAll로 설정한 경로는 JwtException이 발생하더라도 컨트롤러가 잘 호출되고, 그 외의 경로는 JwtException이 발생하면 예외 메시지가, 토큰이 유효하면 컨트롤러가 호출되게 되었다.

그러나 이는 근본적인 해결책은 아니라고 생각한다. 왜냐하면 토큰이 잘못되어 JwtException이 발생한 경우, 예외 처리를 하며 예외 메시지가 나간 그 예외는 JwtException이 아니라 AuthenticationException이기 때문이다. 즉 JwtException이 발생했을 때의 세부적인 예외 메시지(토큰 없음, 만료됨 등등)를 예외 메시지로 응답할 수 없다. JwtException은 로그를 남기며 잡히게 되고, SecurityContext에 인증 정보가 없어서 발생한 AuthenticationException에 대한 예외 처리가 예외메시지로 나간 것이다..

그렇다고 JwtException을 처리할 JwtExceptionFilter를 JwtFilter 앞단에 위치시키자니, 로그인이 필요없는 경로까지 모두 JwtExceptionFilter와 JwtFilter를 타게 되고 예외가 발생하고 예외 응답이 나가는 과정을 거치게 된다..

JwtException을 세부적으로 예외 응답할 수 있는 방법은 다음 이어지는 내용을 확인해보자.


4. JwtException 그대로 처리하기

@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtTokenUtils jwtTokenUtils;

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


        log.info("JwtFilter 실행");

        // 요청헤더에 토큰이 있는지 검증
        try {
            String token = jwtTokenUtils.getTokenFromHeader(request);

            // 있다면, 토큰이 유효한지 검증
            if(jwtTokenUtils.validateAccessToken(token)) {

                // 토큰이 유효하면 authentication 반환 후 SecurityContextHolder에 저장
                Authentication authentication = jwtTokenUtils.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
                log.info("SecurityContextHolder에 authentication 저장 완료");
            }
        } catch (JwtException e) {
            log.info("JwtException 발생");
            request.setAttribute("exception", e); // 발생한 JwtException을 request에 담기
        }

        filterChain.doFilter(request, response);

        log.info("JwtFilter 끝");
    }
}

위와 같이 JwtException이 발생하면, 예외를 잡으면서 request에 예외를 담아준다.

그리고 AuthenticationException이 발생하여 JwtAuthenticationEntryPoint이 호출되면, 다음과 같이 예외 처리 핸들러에게 예외 처리를 위임할 때 AuthenticationException을 넘겨주는 것이 아니라, request에 담겨있는 JwtException을 넘겨준다.

@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final HandlerExceptionResolver resolver;

    public JwtAuthenticationEntryPoint(@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
        this.resolver = resolver;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {

        log.info("JwtAuthenticationEntryPoint 실행");

        // HandlerExceptionResolver에게 예외 처리를 위임
        resolver.resolveException(request, response, null, (JwtException) request.getAttribute("exception"));
    }
}

따라서 예외 처리 핸들러에서는 다음 메소드가 실행되면서 JwtException에 대한 예외 메시지를 응답하게 된다.

@RestControllerAdvice
@Slf4j
public class ExceptionController {

    // JwtException 예외 처리
    @ExceptionHandler(JwtException.class)
    public ResponseEntity<ErrorResponse> handleJwtException(JwtException e) {

        log.error("JwtException 발생");
        return ErrorResponse.createErrorResponse(e);
    }
    
    ...
    
}

Reference

https://colabear754.tistory.com/172#%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%97%90%EC%84%9C_%EB%B0%9C%EC%83%9D%ED%95%9C_%EC%98%88%EC%99%B8_%EA%B7%B8%EB%8C%80%EB%A1%9C_%EC%B2%98%EB%A6%AC%ED%95%98%EA%B3%A0_%EC%8B%B6%EC%96%B4!

profile
공부한 것들을 기록합니다.

0개의 댓글