아래 코드는 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);
}
}