로컬 환경에서는 잘 작동하던 oauth2 로그인이 AWS ECS 배포를 하고 난 후에 authorization_request_not_found
에러가 발생하였다.
2023-08-11 10:48:21.288 INFO 1 — [nio-8080-exec-5] c.c.j.m.h.OAuth2LoginFailureHandler : 소셜 로그인에 실패했습니다. 에러 메시지 : [authorization_request_not_found]
[org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter.attemptAuthentication(OAuth2LoginAuthenticationFilter.java:173),
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:227),
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:217),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter.doFilterInternal(OAuth2AuthorizationRequestRedirectFilter.java:178),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:223),
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:217),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:103),
org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:89),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
com.cupid.jikting.jwt.filter.JwtAuthenticationProcessingFilter.doFilterInternal(JwtAuthenticationProcessingFilter.java:43),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
com.cupid.jikting.common.filter.ExceptionHandlerFilter.doFilterInternal(ExceptionHandlerFilter.java:30),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90),
org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:112),
org.springframework.security.web.context.SecurityContextPersistenceFilter.doFilter(SecurityContextPersistenceFilter.java:82),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:55),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346),
org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:221),
org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:186),
org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:354),
org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:267),
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178),
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153),
org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178),
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153),
org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178),
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153),
org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201),
org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117),
org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178),
org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153),
org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167),
org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90),
org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:481),
org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130),
org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93),
org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74),
org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343),
org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:390),
org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63),
org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:926),
org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1791),
org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52),
org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191),
org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659),
org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61),
java.base/java.lang.Thread.run(Thread.java:829)]
Oauth2LoginAuthenticationFilter
에서 authorizationRequest
를 찾지 못해서 발생하는 에러인듯 했다.문제의 원인을 파악하기 위해 Spring Security의 소셜 로그인 작동 과정을 분석하였다.
원인 분석에 앞서 소셜 로그인의 작동 과정을 간단하게 살펴보자.
사용자가 소셜 회원가입 버튼을 클릭하여 https://api.example.com/oauth2/authorization/google 창을 연다.
Spring Security가 위의 요청을 Intercept 한다.
Spring Security는 /oauth2/authorization/*
패턴을 지닌 요청을 가로채는OAuth2AuthorizationRequestRedirectFilter
를 가지고 있고 해당 필터는 아래와 같은 작업을 한다.
i. OAuth2AuthorizationRequest
객체를 만든다.
ii. 사전 구성된 AuthorizationRequestRepository
에 해당 객체를 저장한다.
iii. 브라우저를 provider의 authorization-uri 페이지로 리다이렉트 시킨다.
사용자가 Provider에 로그인 한다.
Spring이 사용자를 authorization-uri 페이지로 리다이렉트 시킨 후에 provider가 제어를 가져오고 다음 과정을 수행한다.
i. 사용자에게 로그인 요청
ii. 사용자의 데이터에 대한 application의 접근 권한 승인 요청
iii. callback URL로 브라우저를 리다이렉트
Spring Security가 위의 요청을 Intercept 한다.
Spring의 OAuth2LoginAuthenticationFilter
가 위의 요청을 가로채고 다음 과정을 수행한다.
i. 파라미터를 받는다. (e.g. accessToken & state)
ii. AuthorizationRequestRepository
를 사용하여 이전에 저장한 OAuth2AuthorizationRequest
를 검색하고, state를 비교한다.
iii. OAuth2UserService
의 구현체를 불러와 provider의 user-info-uri를 사용하여 사용자의 정보를 불러온다.
iv. 사용자를 인증한다.
v. OAuth2AuthorizationRequest
를 비운다.
vi. 사용자를 success-url로 리다이렉트 한다.
앞서 작성한 소셜 로그인 인증과정에서 빨간색으로 표시한 OAuth2AuthorizationRequest
를 AuthorizationRequestRepository
에 저장할 때 문제가 발생하는 것으로 확인이 되었다.
Spring에서 사용하는 Default AuthorizationRequestRepository
는 HttpSessionOAuth2AuthorizationRequestRepository
로 session 기반 저장소를 사용한다.
단일 서버 환경에서는 해당 저장소를 사용해도 문제없이 돌아가는 듯 싶으나 다중 서버 환경에서는 서버 간의 session storage 공유 문제로 인해 2번에서 저장한 OAuth2AuthorizationRequest
객체를 4번에 다시 찾을 때 찾을 수 없는 문제가 발생한 것이다.
다중 서버 환경에서 session 공유 문제를 해결하기 위한 방법으로 가장 먼저 Sticky Session을 도입하는 방식이 떠올랐다.
Sticky Session을 도입하면 해당 문제점이 해결 되지만 몇 가지 문제점이 존재한다.
Spring상의 설정으로 session storage를 Redis로 변경하여 문제를 해결할 수 있다. (application.properties
에 spring.session.store-type=redis
추가)
하지만 이 또한 stateless하게 설계한 방식에 stateful로 인해 발생한 문제점을 해결하기 위한 근본적인 해결책이 아니라는 생각이 들었다.
구글링을 통하여 AuthorizationRequestRepository
를 커스터마이징 하는 방식을 알게 되었고, 직접 Cookie 기반 저장소를 만들어 사용하는 것으로 결정을 내렸다.
Cookie 기반 저장소를 만들고 SecurityConfig에 빈으로 등록하면 AuthorizationRequestRepository
를 바꿀 수 있다.
.oauth2Login()
.authorizationEndpoint().baseUri("/oauth2/authorization") // 소셜 로그인 Url
.authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository()) // 인증 요청을 쿠키에 저장하고 검색
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
OAuth2AuthorizationRequest oAuth2AuthorizationRequest = CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
return oAuth2AuthorizationRequest;
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequest(request, response);
return;
}
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
public class CookieUtils {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object object) {
return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));
}
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
}
Spring Security 5 OAuth 2.0 Login and Sign Up in Stateless REST Web Services