Vegan-Life / VeganLife-Backend

채식주의자를 위한 식단 및 영양관리 앱 BE
2 stars 0 forks source link

[BUG] SseEmitter Timeout되면 AccessDeniedException 에러 #171

Closed O-Wensu closed 10 months ago

O-Wensu commented 10 months ago

Description

SseEmitter Timeout되면 AccessDeniedException 에러 발생

org.springframework.security.access.AccessDeniedException: Access Denied

ETC

O-Wensu commented 10 months ago

AccessDeniedException 에러 처리에 대해 의논을 하고자 합니다.

문제 상황

서버-전송 이벤트(SSE)를 사용하여 실시간 알림 시스템을 구축하였습니다.

그러나, SseEmitter의 timeout이 발생할 때마다 org.springframework.security.access.AccessDeniedException: Access Denied 오류가 발생하는 문제를 발견하였습니다.

로그 상에서는 AuthorizationFilter.java에서 예외가 발생합니다. AuthorizationFilterSecurityFilterChain 안에 있는 필터로 SecurityFilterChain에서 URL을 통해 사용자의 접근을 제한하는 권한을 부여하는 필터입니다.

AuthorizationFilter.java

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
            throws ServletException, IOException {

        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        if (this.observeOncePerRequest && isApplied(request)) {
            chain.doFilter(request, response);
            return;
        }

        if (skipDispatch(request)) {
            chain.doFilter(request, response);
            return;
        }

        String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
        request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
        try {
            AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
            this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
            if (decision != null && !decision.isGranted()) { // 이 부분에서 Exception 발생
                throw new AccessDeniedException("Access Denied"); 
            }
            chain.doFilter(request, response);
        }
        finally {
            request.removeAttribute(alreadyFilteredAttributeName);
        }
    }

PostMan

원인

우선 몇 가지 로그를 찍어보았습니다.

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("api/v1/sse")
public class SseController {
    private final NotificationService notificationService;

    @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> subscribe(@AuthenticationPrincipal UserDetailsImpl userDetails) {
        log.debug("subscribe() user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
        SseEmitter emitter = notificationService.subscribe(userDetails.id());
        return ResponseEntity.ok(emitter);
    }
}

2023-12-29T15:20:26.224+09:00 DEBUG 35140 --- [nio-8080-exec-3] c.k.v.sse.controller.SseController : subscribe() user: sojk401@naver.com

NotificationService.java

    private SseEmitter createEmitter(Long memberId) {
        SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
        emitterRepository.save(memberId, emitter);
        log.debug("createEmitter() user: {}", SecurityContextHolder.getContext().getAuthentication().getName());

        emitter.onCompletion(() -> emitterRepository.deleteById(memberId));
        emitter.onTimeout(() -> {
            log.debug("onTimeout() user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
            emitterRepository.deleteById(memberId);
        });
        return emitter;
    }

2023-12-29T15:20:26.238+09:00 DEBUG 35140 --- [nio-8080-exec-3] c.k.v.sse.service.NotificationService : createEmitter() user: sojk401@naver.com

onTimeout 시에는 인증 객체를 찾을 수 없었습니다. (로그 없음)

이러한 과정에서 제가 생각한 원인은 다음과 같습니다.

SSE 프로토콜에 따르면, SSE 연결이 끊어지고 자동으로 재 연결을 시도하는 것은 SSE 프로토콜 특성이라고 합니다.

때문에 SseEmitter의 timeout이 발생하면 SSE와의 연결이 끊어집니다, Spring Security는 다시 연결을 시도하게 되고, 새로 연결을 시도하는 사용자를 새로운 사용자로 간주하여 인증 절차를 요구하게 됩니다. 그러나 이 과정에서는 JWT 토큰이 없기 때문에 인증 절차가 통과되지 않습니다.

따라서, 사용자는 접근이 거부되는 Access Denied 오류를 만나게 됩니다.

시도

같은 문제를 겪는 개발 블로그를 찾을 수 없어서 여러 시도를 해봤습니다..

시도1: Securty Filter URL 경로 허용

SecurityConfig.java new AntPathRequestMatcher("/api/v1/sse/**")

sse와 관련된 api 경로를 허용하면 오류가 발생하지 않습니다. 대신, 이 방법은 @AuthenticationPrincipal을 사용할 수 없기 때문에 다음과 같이 PathVariable로 memberId를 받는 방법을 사용해야 합니다.

    @GetMapping(value = "/subscribe/{memberId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> subscribe(@PathVariable Long memberId) {
        SseEmitter emitter = notificationService.subscribe(memberId);
        return ResponseEntity.ok(emitter);
    }

또는 RequestParm으로 토큰이나 사용자 계정을 받는 것도 가능합니다. (현재 클라이언트 측에 memberId를 전달하는 Response가 없어 사용자 계정도 고려하고 있습니다.)

시도2: 인증 객체 설정

이 방법은 애초에 Security Filter Chain을 통과하지 못하기 때문에 해결 방법이 되지 못했습니다.

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        emitter.onTimeout(() -> {
            SecurityContextHolder.getContext().setAuthentication(authentication);
            emitterRepository.deleteById(memberId);
        });

최종적으로

저는 1번 방법이 현재로서는 최선이라고 생각하였습니다. SSE가 재연결을 시도할 때 Request를 조작하는 방법을 찾지 못했기 때문입니다.

soun997 commented 10 months ago

오… 굉장히 어려운 문제 같아요ㅜㅜ

일단 사용자 id 정보가 노출되는 것은 token 사용하는 의미가 없어지는 것 같아서 조금 꺼려지네요. (방법이 없다면 어쩔 수 없지만ㅜㅜ)

궁금한 점이 몇 가지 있는데

  1. timeout을 테스트하기 위해서 따로 설정을 해 준 것인지
  2. emitter.onTimeout()은 콜백 메서드이기 때문에 재전송되기 전에 이 메서드를 타니까 로그는 찍혀야 하는게 맞지 않을까요..? 로그 조차 찍히지 않았다는게 이해하기가 어렵네요😢

디버깅 브레이크 포인트를 설정해서 디버깅 해보아도 좋을 것 같습니다!

O-Wensu commented 10 months ago
  1. 테스트하기 위해 따로 설정해준 것은 없습니다!
  2. 일반적인 로그는 찍힙니다. ex) log.debug("onTimeout")

현재 타임 아웃으로 인해 연결이 끊기자마자 SSE 재 연결을 하게 되는데 이때 AccessDeniedException이 발생합니다.

그 이후, onTimeout 콜백은 정상적으로 동작하고 있습니다! 😥

위에서 로그가 찍히지 않은 문제는 이미 연결이 끊겼기 때문에 SecurityContextHolder를 사용할 수 없기 때문인 것 같습니다.

@Authentication을 쓰신 분들은 간혹 보이는데 따로 설정한 것이 있는지는 잘 찾아볼 수가 없네요..

O-Wensu commented 10 months ago

방법3

SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); SseEmitter 생성 시 timeout을 Long.MAX_VALUE로 설정하면 연결이 끊기지 않습니다.

약 292,471,208년에 해당하기 때문에, 실질적으로 연결이 끊기지 않는 것과 같습니다.

하지만, 이 방법은 클라이언트 측에서 연결을 끊지 않는 한, 서버 자원이 계속 사용되어 부담을 줄 수 있습니다.

O-Wensu commented 10 months ago

방법4

security filter chain을 거치기 전에 JwtAuthenticationFilter를 거치게 됩니다.

원래는 허용된 url에는 요청 시 헤더에 토큰을 넣지 않습니다. 그러면 바로 doFilter로 넘어갑니다. security filter chain에서도 허용된 url이므로 통과하겠죠.

    @Override
    protected void doFilterInternal(
            HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String extractToken = request.getHeader(JwtUtils.AUTH_TOKEN_HEADER);
        jwtUtils.extractBearerToken(extractToken)
                .ifPresent(
                        jwt -> {
                            try {
                                jwtUtils.validateToken(jwt);
                                setAuthentication(request, jwt);
                            } catch (InvalidJwtException e) {
                                request.setAttribute(
                                        JwtUtils.EXCEPTION_ATTRIBUTE, e.getErrorCode());
                            }
                        });
        filterChain.doFilter(request, response); // 헤더에 토큰이 없으므로 바로 통과
    }

위 로직을 보면 헤더에 토큰이 있다면, jwt를 검증하고, 인증 객체를 설정하는 로직을 거치게 됩니다.

그래서 security filter chain을 통과할 수 있게 url을 허용해주고, 헤더에 토큰을 보내도록 하면 어떨까라고 생각했습니다.

SecurityConfig.java new AntPathRequestMatcher("/api/v1/sse/**") 경로를 허용해줍니다.

그리고 sse 연결 요청을 보낼 때, 요청 헤더에 bearer 토큰을 함께 보내줍니다. 그렇게 되면 SecurityContextHolder에 인증 객체가 설정됩니다.

    @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> subscribe(@AuthenticationPrincipal UserDetailsImpl userDetails)

그러면 @AuthenticationPrincipal을 통해 사용자 정보를 가져올 수 있습니다.

단, 토큰이 없을 경우를 위해 try-catch문이 필요합니다.

    @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public ResponseEntity<SseEmitter> subscribe(
            @AuthenticationPrincipal UserDetailsImpl userDetails) {
        try {
            SseEmitter emitter = notificationService.subscribe(userDetails.id());
            return ResponseEntity.ok(emitter);
        } catch (NullPointerException e) {
            throw new InvalidJwtException(ErrorCode.INVALID_TOKEN);
        }
    }

이후 SSE Timeout이 되고, 연결이 끊겼을 때 security filter chain에서는 url이 허용되어 있기 때문에 AccessDeniedException을 발생시키지 않습니다. (이 경우는 정상적인 클라이언트 요청이 아니므로 JwtAuthenticationFilter를 통과하지 않습니다.)

물론, AccessDeniedException만 발생하지 않을 뿐, 이미 서버와의 연결이 끊어지면서 SecurityContextHolder에는 사용자의 정보가 없는 상태입니다. 따라서, 클라이언트 측에서 SSE 재연결을 해야합니다.

의문인 것은 재 연결인데 왜 PostMan과의 SSE 연결은 끊겨 있는가 입니다... (다음에 계속)

O-Wensu commented 10 months ago

조금 더 조사해본 결과, SSE가 자동 재 연결을 지원하는 것은 맞습니다. 그러나 이것은 서버가 아닌 클라이언트 측에서 서버 측과의 SSE 연결이 끊겼을 때 자동 재 연결을 지원하는 것입니다. 혼동을 드려 죄송합니다. 😅

어쩌면 저는 PostMan으로 테스트하기 때문에 AccessDeniedException이 발생한 것일 수도 있겠다는 생각이 들었습니다.

PostMan이 클라이언트라고 생각하면 PostMan으로는 재 연결 테스트가 어렵기 때문입니다. 헤더 토큰 등... (현재 코드가 정상적일지도 모른다는 소리..! 두근두근.. 🤩)

클라이언트에서는 재 연결시 토큰을 보낼 수 있으므로 추후 확인해보면 좋을 것 같습니다.

soun997 commented 10 months ago

와웅 조사하시느라 고생하셨습니다❤️

저도 집착광공처럼 계속 찾아보고 있었는데 재연결 헤더에 대한 내용은 볼 수가 없어서 혹시나..? 하는 생각이 들긴 했습니다^^,,,

다만, 클라이언트에서 재연결 시 토큰을 보낼 수 있다는 것은 확정적인 건가요? 웹 기준으로 EventSource에 onError 콜백을 받을 수 있긴 하던데 이 부분에 대한 확인이 필요하다고 말씀하신 건지 궁금합니다!

O-Wensu commented 10 months ago

일반적으로 EventSource에는 헤더를 추가할 수는 없지만, event-source-polyfill 라이브러리를 사용하여 헤더에 토큰을 보낼 수 있습니다!

그리고 현재 저희 클라이언트가 웹이 아닌 AOS이다 보니, EventSource가 아닌 SSE를 사용할 수도 있을 것 같습니다!

soun997 commented 10 months ago

앗 제가 여쭤본 것은 재연결 시에도 동일한 헤더로 요청이 전달되는지에 대한 것이였습니당!!

O-Wensu commented 10 months ago

기본적으로 EventSource가 재연결 시도시에 처음 연결을 맺은 것과 동일한 요청을 한다고 했던 것 같은데, 만약 토큰이 포함되지 않는다면 onerror 콜백을 통해 재연결 로직을 작성할 때 토큰을 포함시키면 될 것 같습니다!