0. 현상황

  현재 Spring Cloud Gateway에 Spring Security를 도입을 하는 과정에서 jwt 토큰 인증중 예외가 발생하면 500에러가 그대로 노출이 되는 상황이 발생을 하였다. 현 상황을 그대로 두면 클라이언트에게 서버 상황을 그대로 노출을 하게되어 불편을 초래하고, 또 보안상 좋지않기 때문에 해당 예외를 400에러로 바꾸고 에러 메시지, 에러코드정도만 담은 간단한 json 형태로 바꾸어 출력을 해주고자 한다.

 

 

 

1. 웹플럭스 기반 Security에서의 필터체인

  Spring Cloud Gateway는 기본적으로 WebFlux 기반으로 동작한다. WebFlux기반에서 돌아가는 SpringSecurity에서는 SecurityFilterChain에서 에러 핸들링 방법을 설정해줘야 한다.

  @Bean
    public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
        return http
                .csrf(csrf -> csrf.disable()) // CSRF 비활성화
                .authorizeExchange(exchanges -> exchanges
                        .pathMatchers(whiteListPath.getWhiteList()).permitAll() // 화이트리스트 경로는 허용
                        .anyExchange().authenticated() // 그 외는 인증 필요
                )
                .securityContextRepository(securityContextRepository)
                .authenticationManager(authenticationManager)
                .exceptionHandling(exceptionHandlingSpec ->
                        exceptionHandlingSpec.authenticationEntryPoint(authenticationEntryPoint)
                )
                .build();
    }

여기서 하단에 위치한 코드중에서

.exceptionHandling(exceptionHandlingSpec ->
        exceptionHandlingSpec.authenticationEntryPoint(authenticationEntryPoint)
)

 

.exceptionHandling() 으로 예외를 어떤걸 이용하여 핸들링을 할 지를 결정을 해주면 된다.

 

authenticationEntryPoint는 ServerAuthenticationEntryPoint 인터페이스를 구현한 클래스를 작성해주면 된다.

@Component
public class CustomAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {

        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        String body = "{\"error\": \"" + ex.getMessage() + "\"}";
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = exchange.getResponse()
                .bufferFactory()
                .wrap(bytes);

        return exchange.getResponse().writeWith(Mono.just(buffer));
    }
}

 

이런식으로 에러를 잡으면 에러코드와 형식, 그리고 예외 메시지를 함께 포함하여 클라이언트단에 전달하고자 한다.

 

2. 원하는 지점에서 예외발생시키기.

 

1.에서 작성한 SecurityFilterChain동작 중  jwt 토큰 추출과 검증방법을 지정해주는 AuthenticationManager 에서 토큰 검증 중 검증에 실패하거나, 로그아웃을 하여 BlackList에 등록이 된 jwt는 예외를 발생하도록 정해주었다.

 

@RequiredArgsConstructor
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
    private final JwtTokenProvider tokenProvider;

    @Qualifier("blacklistRedisTemplate")
    private final RedisTemplate<String, Object> authRedisTemplate;


    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        String token = authentication.getCredentials().toString();

        if (!tokenProvider.validateToken(token) || authRedisTemplate.hasKey(token)) {
            return Mono.error(new CustomTokenException("토큰이 유효하지않습니다."));
        }

        String username = tokenProvider.getUsername(token);
        String role = tokenProvider.getRole(token);

        List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(role));
        return Mono.just(new UsernamePasswordAuthenticationToken(username, null, authorities));
    }
}

 

여기까지만 하면 이제 원하는대로 동작을 할 것 같다.

 

3. 의도치 않은 예외발생

  이제 에러가 잡혔다고 생각하고 다시 테스트를 해보면

 

아까 봤던 에러가 그대로 올라온다. 무언가 이상하다. AuthenticationEntryPoint가 동작을 안한다는 의미이다.

 

사실 AuthenticationEntryPoint는 문제가 없다. 우선 예외가 터지는 구조는 다음과 같다.

 

 SecurityFilterChain이 실행이 되고, JwtAuthenticationManager에서 Jwt 검증을 하다가 검증을 통과하지 못하면 Exception을 던지게 되는데 이 Exception을 ExceptionTranslationWebFilter 가 감지한다.

 

이때 Exception이 AuthenticationException이나 AccessDeniedException을 감지하게 되면 ServerAuthenticationEntryPoint를 실행을 하게 되는데, 만약에 다른 예외이거나 SeucurityFilterChain 밖에서 예외가 터져버리면 ServerAuthenticationEntryPoint에서 예외를 잡을수가 없다.

 

JwtAuthenticationManager에서 작성한 코드를 다시 보자.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationManager implements ReactiveAuthenticationManager {
    private final JwtTokenProvider tokenProvider;

    @Qualifier("blacklistRedisTemplate")
    private final RedisTemplate<String, Object> authRedisTemplate;


    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        String token = authentication.getCredentials().toString();

        if (!tokenProvider.validateToken(token) || authRedisTemplate.hasKey(token)) {
            return Mono.error(new CustomTokenException("토큰이 유효하지않습니다."));
        }

        String username = tokenProvider.getUsername(token);
        String role = tokenProvider.getRole(token);

        List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(role));
        return Mono.just(new UsernamePasswordAuthenticationToken(username, null, authorities));
    }
}

 

 

여기서 이 부분이 문제가 되는 부분이다.

return Mono.error(new CustomTokenException("토큰이 유효하지않습니다."));

 

 Mono.error()로 주면 SecurityContextRepository의 load에서 예외가 발생 -> 정상 흐름이 아니라서 securityFilterChain에서 벗어나버려서 예외를 핸들링할 방법이 없다.

 

Mono.empty()로 주면 정상적인 SecurityFilterChain의 흐름이고 CustomAuthenticationEntryPoint까지 정상적으로 흘러는 가지만 empty()엔 파라미터를 받지않아서 우리가 주고싶었던 CustomException의 메시지를 client단에 넘겨줄 수 없고 AuthenticationException의 기본 메시지만 출력이 가능하다. 

 

 

4. 의도한 메시지 보내주기 -> SecurityExceptionHandler 구현

return Mono.error()로 그대로 두고,   ErrorWebExceptionhandler를 구현을 하게되면 SecurityFilterChain에서 벗어난 에러처리 + 지정한 메시지 클라이언트에게 전달 이 두가지를 다 성공할 수 있다. 코드는 다음과 같다.

@Component
public class SecurityExceptionHandler implements ErrorWebExceptionHandler {
    @Override
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
        // 여기서 필터 단계에서 발생한 예외를 캐치
        HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
        String message = "예상치 못한 에러가 발생했습니다.";

        if (ex instanceof CustomTokenException) {
            status = HttpStatus.UNAUTHORIZED;
            message = ex.getMessage(); // "토큰이 유효하지 않습니다."
        }

        exchange.getResponse().setStatusCode(status);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
        String body = "{\"error\": \"" + message + "\"}";

        DataBuffer buffer = exchange.getResponse()
                .bufferFactory()
                .wrap(body.getBytes(StandardCharsets.UTF_8));
        return exchange.getResponse().writeWith(Mono.just(buffer));
    }
}

 

ErrorWebExceptionHandler는 Webflux 애플리케이션에서 최상위 예외처리자이다. 이 핸들러를 구현을 해주게 된다면 모든 예외처리가 가능하다. 

 

이렇게 SecurityExceptionHandler를 구현을 하고 다시 테스트를 진행해보면

 

CustomException에서 지정한 메시지를 넘겨받는데 성공하였다.

+ Recent posts