본문 바로가기
카테고리 없음

Gateway와 서비스 간 X-Forwarded-Prefix 처리 문제로 인한 OAuth2 Redirect URL 저장 실패 해결기

by Backchus 2025. 1. 15.

아래 코드는 OAuth2 인증 성공후 JWT를 발급 후 request session에 저장되어있는 redirection_url값을 가져와서 redirection response를 반환하는 SpringSecurity에서 제공하는 AuthenticationSuccessHandler인터페이스를 구현한 구현체입니다. 하지만 redirectToTargetUrl메서드에서 request session에 redirection_url이 저장되지 않아서 null을 반환하면서 redirection을 하지 못하는 문제가 발생했습니다.

public class CustomSsoAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

		// 코드 생략 ....

    @Override
    public void onAuthenticationSuccess(
        HttpServletRequest request,
        HttpServletResponse response,
        Authentication authentication
    ) throws IOException {
        try {
            AuthUserSaveOrUpdateRequest userSaveOrUpdateRequest = createDataHubUserRequestBy(authentication);

            log.info("[SsoSuccessHandler] User AccessToken: {}", userSaveOrUpdateRequest.authServerAccessToken());

			// 코드 생략 ...
					
            AuthUserDto authUserDto = userInternalService.authUserSaveOrUpdate(userSaveOrUpdateRequest);
            AuthTokenResponse authTokenResponse = createTokenResponseBy(authUserDto);

            // 코드 생략
						
			// 생성한 JWT를 request session에 저장되어있는 redirect_url로 redirection response 생성
            redirectToTargetUrl(request, response, authTokenResponse.accessToken(), authTokenResponse.refreshToken());
        } catch (Exception e) {
            log.error("[SsoSuccessHandler] message: {}, exception: {}", e.getMessage(), e.getClass(), e);
            writeErrorResponse(response);
        } finally {
            clearContextAndSession(request);
        }
    }
		
		// 코드 생략 ...

    private void redirectToTargetUrl(
        HttpServletRequest request,
        HttpServletResponse response,
        String accessToken,
        String refreshToken
    ) throws IOException {
		    // session에 redirection_url이 null을 반환
        String redirectBaseUrl = (String) request.getSession().getAttribute(REDIRECT_URL);

        String redirectUrl = getRedirectUrl(redirectBaseUrl, accessToken, refreshToken, request);
        log.info("[SsoSuccessHandler] Redirect url: {}", redirectUrl);
        
        // 오류 발생
        response.sendRedirect(redirectUrl);
    }

}

그래서 먼저 redirect_url파라미터값을 저장하는 로직이 호출이 되는지 확인해보기 위해 아래 코드에 path를 로그로 찍어보기로 했습니다. 아래 코드는 /identity/oauth2/authorization/azure?redirect_url={front서버 host주소} 요청시 redirect_url파라미터값을 request session에 저장하는 SpringFilterChain에 속한 직접 구현한 CustomFilter입니다.

shouldNotFilter메서드를 살펴보면 /saml2/authenticate/**, /oauth2/authorization/**, /credential/users/login/** 이 3가지 형식의 uri인 경우만 doFilterInternal을 호출하여 request session에 redirection_url파라미터 값을 저장하도록 되어 있습니다.

@Slf4j
@RequiredArgsConstructor
public class RedirectUrlSaveFilter extends OncePerRequestFilter {

    private final GptAuthServerProperties gptAuthServerProperties;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        final String path = request.getRequestURI();
        
        // path 확인하기위해 로그 추가
        log.info("Info - path: {}", path);   // /identity/oauth2/authroization/azure

        AntPathMatcher pathMatcher = new AntPathMatcher();

        List<String> listOfPatterns = List.of(
            "/saml2/authenticate/**",
            "/oauth2/authorization/**",
            "/credential/users/login/**"
        );

        return listOfPatterns.stream()
            .noneMatch(pattern -> pathMatcher.match(pattern, path));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        setRedirectUrl(request);
        filterChain.doFilter(request, response);
    }

    private void setRedirectUrl(HttpServletRequest request) {
        String redirectBaseUrl = request.getParameter(REDIRECT_URL);
        request.getSession().setAttribute(REDIRECT_URL, redirectBaseUrl);
        log.info("Success to save redirect_url to Session. redirect_url: {}", redirectBaseUrl);
    }
}

현재 Client(Web, Mobile, etc…)에서 소셜로그인 인증을 요청하게되면 /identity/oauth2/authorization/azure?redirect_url={front서버 host주소} 요청을 통해서 gateway로 요청이 가고 gateway에서 rewritePathFilter를 거쳐 /identity를 제거합니다. 그 후 datahub에서 /identity가 제거된 /oauth2/authorization/azure?redirect_url={front서버 host주소}로 요청을 받도록 되어 있습니다.

RedirectUrlSaveFilter에서 추가한 로그를 통해서 로그를 찍어보니 일단 RedirectUrlSaveFilter의 shouldNotFilter메서드 호출을 하는 것을 보니 /identity까지 찍혀 있는 것을 확인할 수 있습니다.

{
	// 생략
   "message":"[RedirectUrlSaveFilter] path: /identity/oauth2/authorization/azure",
}

그래서 혼란이 왔습니다 위에 그림의 아키텍처대로라면 gateway에서 /identity/oauth2/authorization/azure요청시 datahub가 그대로 받으면 애초에 RedirectUrlSaveFilter를 탈 수 없습니다. 그 이유는 아래 Oauth2 관련 SpringSecurityFilterChain설정때문입니다.

public class OAuth2SecurityConfig {

    private final CustomSsoAuthenticationSuccessHandler customSsoAuthenticationSuccessHandler;

    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    private final AppleSecretFileHandler appleSecretFileHandler;

    @Order(1)
    @Bean
    SecurityFilterChain oauth2AuthenticationConfig(
        HttpSecurity http,
        GptAuthServerProperties gptAuthServerProperties
    ) throws Exception {
        log.info("Init configure oauth2 authentication.");

        configureCommon(http);

        return http
            .securityMatcher(
                "/login/oauth2/code/**",
                "/oauth2/authorization/**"
            )
            .oauth2Login(oauth2Login ->
                oauth2Login
                    .tokenEndpoint(tokenEndpoint ->
                        tokenEndpoint.accessTokenResponseClient(accessTokenResponseClient()))
                    .successHandler(customSsoAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)
            )
            // SecurityFilterChain에 RedirectionUrlSaveFilter 추가
            .addFilterBefore(new RedirectUrlSaveFilter(gptAuthServerProperties), OAuth2AuthorizationRequestRedirectFilter.class)
            .build();
    }
		// 생략
}

oauth2AuthenticationConfig메서드의 return부분을 보시면 prefix가 /oauth2/authroization인 요청만 해당 SecurityFilterChain을 태우게 되는데 RedirectUrlSaveFilter가 찍히고 있다는건 OAuth2SecurityConfig에서 설정한 SecurityFilterChain을 거쳐가고 있다는 뜻입니다.

        return http
            .securityMatcher(
                "/login/oauth2/code/**",
                "/oauth2/authorization/**"
            )

그렇다면 datahub에서는 gateway에서 /identity/oauth2/authorization/azure?redirect_url={} 요청을 /identity가 제거된 /oauth2/authorization/azure?redirect_url={}요청으로 잘 받았다는 말인데 왜 log에찍힌 path값이 /identity/oauth2/authorization/azure?redirect_url={} 이렇게 /identity가 제거되지 않을 수 있을까요?

@Slf4j
@RequiredArgsConstructor
public class RedirectUrlSaveFilter extends OncePerRequestFilter {

    private final GptAuthServerProperties gptAuthServerProperties;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        final String path = request.getRequestURI();
        
        // path 확인하기위해 로그 추가
        log.info("Info - path: {}", path);   // /identity/oauth2/authroization/azure

        AntPathMatcher pathMatcher = new AntPathMatcher();

        List<String> listOfPatterns = List.of(
            "/saml2/authenticate/**",
            "/oauth2/authorization/**",
            "/credential/users/login/**"
        );

        return listOfPatterns.stream()
            .noneMatch(pattern -> pathMatcher.match(pattern, path));
    }

비밀은 request.getRequestURI() 메서드에 있었습니다. HttpServletRequest의 getRequestURI메서드에 간단히 살펴보면 아래와 같습니다.

  • 요청받은 URL에서 경로 추출
    • getRequestURI()는 요청된 URL에서 호스트와 포트를 제외한 경로 부분을 반환합니다.
    • 예: 요청 URL이 https://example.com/app/login이라면 getRequestURI()는 /app/login을 반환합니다.
  • 헤더 기반 경로 복원 (프록시 서버와 함께 사용할 때)
    • 만약 프록시 서버(예: NGINX, Spring Cloud Gateway)가 요청을 처리하면서 X-Forwarded-* 헤더를 추가한 경우, request.getRequestURI()는 Spring의 ForwardedHeaderTransformer 설정에 따라 경로를 복원할 수 있습니다.
    • 기본적으로 X-Forwarded-Prefix와 같은 헤더를 고려하여 원래 경로를 반환합니다.

헤더 기반 경로 복원으로 인해 request.getRequestURI()호출을 통해 path를 받아서 shouldNotFilter를 체크할 경우 문제가 생기고 있었습니다. Spring Cloud Gateway가 요청을 처리하면서 X-forwarded-prefix: /identity, X-forwarded-path: /identity/oauth2/authorization/azure를 헤더값으로 추가해서 datahub에 요청하고 있었습니다. datahub에서는 RedirectUrlSaveFilter을 거치면서 request.getRequestURI()호출을 하게되면 http메시지의 X-Forwarded-*로 되어있는 헤더값들을 참고해 gateway를 태우기전에 경로를 복원해서 /identity를 제거하지 않고 그대로 path변수에 할당하게 됩니다.

그래서 이런 문제를 해결하기위해 여러가지 방법이 있는데

  • gateway에서 datahub로 요청시 X-Forwarded-*헤더 제거
  • datahub에서 X-Forwarded-* 헤더를 무시하도록 설정
  • RedirectUrlSaveFilter에서 명시적으로 경로를 사용
    // AS-IS
    String path = request.getRequestURI();  // /identity/authorization/azure
    // TO-BE
    String path = request.getServletPath(); // /oauth2/authorization/azure
    
  • getServletPath()는 게이트웨이가 재작성한 경로(/oauth2/authorization/azure)를 반환합니다.

위의 3가지중 제일 마지막 RedirectUrlSaveFilter를 사용하기로 했습니다. 그 이유는 나머지 2가지 방식은 환경변수를 따로 추가하면서 관리포인트가 늘어나기 때문에 고려하지 않았습니다.

다시 RedirectUrlSaveFilter로 돌아와서 listOfPatterns의 형식에 맞으면서 doFilterInternal를 호출하여 request session에 redirect_url을 저장하고 OAuth2인증 성공 후 SuccessHandler에서 생성한 JWT토큰을 정상적으로 redirection할 수 있었습니다.

@Slf4j
@RequiredArgsConstructor
public class RedirectUrlSaveFilter extends OncePerRequestFilter {

    private final GptAuthServerProperties gptAuthServerProperties;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        final String path = request.getServletPath(); // /oauth2/authorization/azure
        AntPathMatcher pathMatcher = new AntPathMatcher(); 

        List<String> listOfPatterns = List.of(
            "/saml2/authenticate/**",
            "/oauth2/authorization/**",
            "/credential/users/login/**"
        );

        return listOfPatterns.stream()
            .noneMatch(pattern -> pathMatcher.match(pattern, path));
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        setRedirectUrl(request);
        setBackUrl(request);
        filterChain.doFilter(request, response);
    }

    private void setRedirectUrl(HttpServletRequest request) {
        String redirectBaseUrl = request.getParameter(REDIRECT_URL);

        if (!isValidRedirectUrl(redirectBaseUrl)) {
            throw new GlobalException(ErrorCode.INVALID_REDIRECT_URL, "Cannot allow redirect. url: " + redirectBaseUrl);
        }

        request.getSession().setAttribute(REDIRECT_URL, redirectBaseUrl);
        log.info("Success to save redirect_url to Session. redirect_url: {}", redirectBaseUrl);
    }
}