Spring Security OAuth2 authorization_request_not_found 에러

smc2315·2023년 8월 28일
4

spring-boot

목록 보기
3/7
post-thumbnail

1. 문제상황

로컬 환경에서는 잘 작동하던 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)]

  • 트러블 슈팅을 위해 stack trace를 로깅하여 에러 발생 지점을 추적해보니 Spring Security Oauth2LoginAuthenticationFilter에서 authorizationRequest를 찾지 못해서 발생하는 에러인듯 했다.
  • 로컬 환경과 클라우드 환경 간의 차이를 고려할 때, 로컬에서는 단일 서버가 작동하는 반면 클라우드에서는 로드밸런서를 통해 서버가 다중화되어 있어 발생하는 오류라는 생각이 들어 해당 관점으로 오류 분석을 시작하였다.

2. 원인 분석

문제의 원인을 파악하기 위해 Spring Security의 소셜 로그인 작동 과정을 분석하였다.

2-1. 소셜 로그인 작동 과정

원인 분석에 앞서 소셜 로그인의 작동 과정을 간단하게 살펴보자.

  1. 사용자가 소셜 회원가입 버튼을 클릭하여 https://api.example.com/oauth2/authorization/google 창을 연다.

  2. Spring Security가 위의 요청을 Intercept 한다.
    Spring Security는 /oauth2/authorization/* 패턴을 지닌 요청을 가로채는OAuth2AuthorizationRequestRedirectFilter를 가지고 있고 해당 필터는 아래와 같은 작업을 한다.
    i. OAuth2AuthorizationRequest 객체를 만든다.
    ii. 사전 구성된 AuthorizationRequestRepository에 해당 객체를 저장한다.
    iii. 브라우저를 provider의 authorization-uri 페이지로 리다이렉트 시킨다.

  3. 사용자가 Provider에 로그인 한다.
    Spring이 사용자를 authorization-uri 페이지로 리다이렉트 시킨 후에 provider가 제어를 가져오고 다음 과정을 수행한다.
    i. 사용자에게 로그인 요청
    ii. 사용자의 데이터에 대한 application의 접근 권한 승인 요청
    iii. callback URL로 브라우저를 리다이렉트

  4. 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로 리다이렉트 한다.

2-2. AuthorizationRequestRepository

앞서 작성한 소셜 로그인 인증과정에서 빨간색으로 표시한 OAuth2AuthorizationRequestAuthorizationRequestRepository에 저장할 때 문제가 발생하는 것으로 확인이 되었다.

OAuth2LoginAuthenticationFilter

Spring에서 사용하는 Default AuthorizationRequestRepositoryHttpSessionOAuth2AuthorizationRequestRepository로 session 기반 저장소를 사용한다.

단일 서버 환경에서는 해당 저장소를 사용해도 문제없이 돌아가는 듯 싶으나 다중 서버 환경에서는 서버 간의 session storage 공유 문제로 인해 2번에서 저장한 OAuth2AuthorizationRequest 객체를 4번에 다시 찾을 때 찾을 수 없는 문제가 발생한 것이다.

3. 해결을 위한 시도

(1) Sticky Session 적용

다중 서버 환경에서 session 공유 문제를 해결하기 위한 방법으로 가장 먼저 Sticky Session을 도입하는 방식이 떠올랐다.
Sticky Session을 도입하면 해당 문제점이 해결 되지만 몇 가지 문제점이 존재한다.

  • 특정 서버에 과부하가 발생할 수 있으며, 트래픽이 균등하게 배분될 수 없다.
  • 로드밸런싱으로 인해 트래픽이 분산되긴 하지만, Sticky Session을 사용했을 때 특정 서버에 몰린 사람들만 활발하게 활동하는 경우 해당 서버만 과부하가 걸릴 수 있다.
  • 프로젝트의 인증 과정을 stateless 하게 만들기 위해 JWT를 활용하였는데 Sticky Session을 활용하는 것은 근본적인 해결책이 아닌 것 같다고 보인다.

(2) session storage를 redis로 변경

Spring상의 설정으로 session storage를 Redis로 변경하여 문제를 해결할 수 있다. (application.propertiesspring.session.store-type=redis 추가)
하지만 이 또한 stateless하게 설계한 방식에 stateful로 인해 발생한 문제점을 해결하기 위한 근본적인 해결책이 아니라는 생각이 들었다.

구글링을 통하여 AuthorizationRequestRepository를 커스터마이징 하는 방식을 알게 되었고, 직접 Cookie 기반 저장소를 만들어 사용하는 것으로 결정을 내렸다.
Cookie 기반 저장소를 만들고 SecurityConfig에 빈으로 등록하면 AuthorizationRequestRepository를 바꿀 수 있다.

SecuriyConfig.java

.oauth2Login()
                .authorizationEndpoint().baseUri("/oauth2/authorization") // 소셜 로그인 Url
                .authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository()) // 인증 요청을 쿠키에 저장하고 검색

HttpCookieOAuth2AuthorizationRequestRepository.java

@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);
    }

}

CookieUtils.java

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

profile
개발일지

0개의 댓글