Spring Security OAuth Client: 배포만하면 카카오 로그인이 실패하는 이유

WIZ·2024년 7월 4일

TroubleShooting

목록 보기
6/7

오랜만에 트러블 슈팅으로 돌아왔다.
이번 포스팅의 부제는 "내 컴퓨터에선 잘 됐는데.." 이다.

이번에 소개할 이슈는 spring-security-oauth2-client 를 이용해 소셜 로그인을 구현하는과정에서 발생한 것이고, 서버를 클라우드에 배포만하면 카카오 소셜 로그인이 실패하는 이슈였다.

개인적으로 디버깅이 굉장히 어려운 문제였다고 생각하고 그 이유는 모든 로직이 Spring Security 내부 코드에 의해서 동작했기 때문이라고 생각한다. Spring Security 는 굉장히 강력한 프레임워크임이 틀림없지만 내부 동작을 제대로 이해하고 있지않은 상태로 사용했을 때 이슈를 해결하는데 굉장히 많은 리소스가 필요하다는 단점이 있다. 원래도 Spring Security 를 즐겨쓰지 않았지만 이번 계기로 더더욱 그 생각이 굳건해졌다.

Spring Security 에 대한 나의 생각은 포스팅 을 통해서 자세히 확인해보면 될 것 같다.


요구사항 소개


내가 구현하고자하는 요구사항은 매우 간단하다.

KAKAO 소셜 로그인 구현

이번 포스팅은 OAuth 2.0 을 이용한 소셜로그인의 흐름은 이해하고 있다고 가정하고 작성되었다.

최근에는 예제코드도 매우 흔하게 찾아볼 수 있는 요구사항이라고 생각한다. 이를 구현하기 위해서 모든 과정을 직접 코드로 작성해도 되지만 spring-security-oauth2-client 라는 프레임워크의 도움을 받는 방법도 있다.

spring-security-oauth2-client 를 사용한다면 몇가지 설정을 추가하는 것만으로 소셜로그인을 구현할 수 있다.

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: {CLIENT_ID}
            redirect-uri: http://127.0.0.1:3000/oauth2/kakao/callback
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: kakao
            scope:
              - profile_nickname
              - profile_image
        provider:
          kakao:
            authorization_uri: https://kauth.kakao.com/oauth/authorize
            token_uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user_name_attribute: id

KAKAO 소셜 로그인에 필요한 설정값을들 위와같이 application.yml 파일에 작성하고 아래와같이 Spring Security 설정만 추가해주면 끝이다!

@Configuration
class SecurityConfig(
    private val oAuthUserService: OAuthUserService,
    private val oAuthLoginSuccessHandler: OAuthLoginSuccessHandler
) {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        return http.formLogin { it.disable() }
            .httpBasic { it.disable() }
            .csrf { it.disable() }
            .sessionManagement {
                it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            }
            .oauth2Login { oauthConfig ->
                oauthConfig.redirectionEndpoint {
                    it.baseUri("/oauth2/*/callback")
                }.userInfoEndpoint {
                    it.userService(oAuthUserService)
                }.successHandler(oAuthLoginSuccessHandler)
            }
            .build()
    }
}

물론 아래 두 가지 객체는 직접 구현을 해줘야한다. 데이터 모델링과 비즈니스 요구사항에 따라서 구현방법이 크게 달라질 수 있는 부분이라 간단하게 두 객체가 가지는 책임만 적고 넘어가려고 한다.

  • OAuthUserService : OAuth Provider 측의 인증을 통과한 사용자 정보를 토대로 우리 시스템의 사용자 관련 로직을 처리한다. (ex. 회원가입 처리)
  • OAuthLoginSuccessHandler : OAuth2 를 이용한 소셜로그인이 완전히 성공했을 때 어떻게 처리할지에 대한 로직을 처리한다. (ex. 토큰/세션 발급)

이렇게 나는 KAKAO 소셜 로그인 구현을 마쳤고, 로컬 환경에서 잘 동작하는 것까지 확인했다.


내 컴퓨터에선 잘 됐는데...


이번 포스팅의 부제이자 사건의 발단이다.

분명 내 컴퓨터, 즉 로컬 환경에서 실행했을때는 정상적으로 KAKAO 로그인이 잘 됐었다. 근데, 서버를 클라우드 환경에만 배포하면 로그인이 실패한다 ㄱ-

어디서 실패하는지 디버깅을 시작해보자.

디버깅 과정이 매우 복잡하고 험난하기 때문에 머리가 아프다면 밑으로 스킵하고 넘어가도 내용을 이해하는데 지장이 없다.

OAuth 2.0 을 이용한 소셜 로그인은 서버 관점에서 크게 2가지 흐름이 존재한다.

  1. OAuth Provider(KAKAO) 의 로그인 페이지로의 Redirect 해주는 흐름
  2. 로그인에 성공한 후 OAuth Provider 로 부터 받은 인가코드를 처리하는 흐름

각 흐름에 대한 자세한 코드를 살펴보기에 앞서 OAuth 2.0 에 존재하는 state 에 대한 개념을 먼저 알아야한다.

0. state Parameter 란 무엇일까?

spring-security-oauth2-client 는 위와같은 state 를 기본적으로 사용하고 있기 때문에 반드시 이해하고 넘어가야한다.

state 에 자세한 내용은 https://auth0.com/docs/secure/attack-protection/state-parameters 사이트를 통해 학습하면 좋을 것 같고, 이번 포스팅에서는 핵심적인 개념만 설명하고 넘어가려고 한다.

state 는 앞서 소개한 1번 흐름과 2번 흐름 사이의 연결을 위해서 사용된다. 1번 흐름을 보낸 서버와 2번 흐름을 받아서 처리하는 서버가 동일한지 확인해 CSRF 공격을 막아주는 것이다.

간략한 흐름을 통해 조금 더 자세히 알아보자.

  1. Client to Server : KAKAO 소셜 로그인 할게. 로그인 페이지로 리다이렉션 시켜줘.
  2. Server to Client : 오케이 로그인 페이지로 보내줄게. 근데 임의의 난수 state 를 내가 생성해서 줄테니까 로그인 성공하고나면 이거 도로 가져와야 이후 프로세스 진행해줄거야.
  3. Client to KAKAO : 로그인 할게.
  4. KAKAO to Client : 너 로그인 성공했어. 인가코드(authorization_code) 줄테니까 이거 가지고 이후 프로세스 진행하면 돼. (리다이렉션 방식에 따른 생략 가능)
  5. Client to Server : 인가코드랑 아까 너가 준 state 가지고 왔어. 이걸로 KAKAO 쪽 사용자 정보 조회해서 로그인처리 완료해줘.
  6. Server : 음.. state 가 내가 발급해준게 맞네. 통과!
  7. Server : 이후 프로세스 진행 (생략)

이정도면 state 가 왜 필요하고, OAuth 2.0 소셜로그인 흐름상에서 어떻게 움직이는지에 대해서 이해했으리라 생각한다.

1. OAuth Provider(KAKAO) 의 로그인 페이지로의 Redirect 해주는 흐름

첫 번째 흐름은 spring-security-oauth2-client 내부에 존재하는 OAuth2AuthorizationRequestRedirectFilter 에 의해서 처리된다.

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    try {
    	OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
    	if (authorizationRequest != null) {
    		this.sendRedirectForAuthorization(request, response, authorizationRequest);
            return;
        }
    }
    // 생략 ...
}

리다이렉트를 보내주는 this.sendRedirectForAuthorization() 메소드를 조금 더 자세히보자.

private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
    OAuth2AuthorizationRequest authorizationRequest) throws IOException {
    if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
    	this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
	}
	this.authorizationRedirectStrategy.sendRedirect(request, response, authorizationRequest.getAuthorizationRequestUri());
}

주의깊게 봐야할 부분은 this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response); 부분이다. 이 메소드가 로그인 페이지로 리다이렉트를 보내기 전에 state 를 생성하고 저장해두는 역할을 처리한다.

이제 흐름은 AuthorizationRequestRepository 으로 넘어가는데, 이는 인터페이스고 실질적인 로직을 처리하는 객체는 HttpSessionOAuth2AuthorizationRequestRepository 이다. HttpSessionOAuth2AuthorizationRequestRepositorysaveAuthorizationRequest() 메소드의 코드를 살펴보자.

@Override  
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,  
       HttpServletResponse response) {  
    // 생략 ...
    String state = authorizationRequest.getState();  
    Assert.hasText(state, "authorizationRequest.state cannot be empty");  
    request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest);  
}

state 를 생성하고 저장하는 흐름이 존재한다는 것을 확인할 수 있다.

문제는 어디에 저장하는가? 이다!! 생성한 stateHttpSession 을 이용해 Session 저장소에 저장하고 있다. 이미 어떤 문제가 있을지 눈치챈 사람들도 있겠지만 우선 그렇다는것만 기억하고 나머지 흐름을 살펴보자.

이렇게 첫 번째 흐름은 마무리된다.

2. 로그인에 성공한 후 OAuth Provider 로 부터 받은 인가코드를 처리하는 흐름

두 번째 흐름은 spring-security-oauth2-client 내부에 존재하는 OAuth2AuthorizationCodeGrantFilter 에 의해서 처리된다.

private void processAuthorizationResponse(HttpServletRequest request, HttpServletResponse response) throws IOException {  
   OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository.removeAuthorizationRequest(request, response);  
   // 생략 ...
   OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);
   OAuth2AuthorizationCodeAuthenticationToken authenticationRequest = new OAuth2AuthorizationCodeAuthenticationToken(
   	   clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
   // 생략 ...
   try {  
	  authenticationResult = (OAuth2AuthorizationCodeAuthenticationToken) this.authenticationManager  
		 .authenticate(authenticationRequest);  
   }  
   // 생략 ...
}

엄청나게 많은 과정을 거치지만 위에 남겨둔 핵심 흐름은 아래와 같다.

  1. HttpSessionOAuth2AuthorizationRequestRepository.removeAuthorrizationRequest() 를 호출해 1번 흐름에서 HttpSession 에 저장해뒀던 요청(OAuth2AuthorizationRequest)을 꺼낸다.
    여기엔 1번 흐름에서 임의로 생성했던 state 정보도 포함되어 있다.
  2. authorization_code, state 를 포함해 온 요청을 토대로 응답(OAuth2AuthorizationResponse)을 만든다.
  3. 위에서 만들어진 요청과 응답 객체를 토대로 OAuth2AuthorizationCodeAuthenticationToken 인증전 객체를 생성한다.
  4. OAuth2AuthorizationCodeAuthenticationToken 를 처리할 수 있는 AuthenticationProvider 를 선택해 인증을 요청한다. 이때 선택되는 AuthenticationProviderOAuth2AuthorizationCodeAuthenticationProvider 이다.

spring-security-oauth2-client 내부에 이미 만들어져있는 객체들이 많이 등장해 흐름을 이해하는 것만으로도 매우 복잡하다는 사실을 알 수 있다.

마지막으로 4번을 타고 들어가보면 아래와 같은 코드가 나온다.

if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
	OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE);
    throw new OAuth2AuthorizationException(oauth2Error);
}

이미 설명했듯이 요청과 응답에 포함되어 있는 state 가 동일한지 확인하는 과정이다.

험난한 디버깅 과정이 끝났다.
그럼 이제 왜 이런 이슈가 발생했는지 살펴보도록 하자.


뭐가 문제일까?


범인은 바로 spring-security-oauth2-clientstate 를 포함한 요청(OAuth2AuthorizationRequest) 정보를 HttpSession 이라는 세션 저장소에 저장했다는 것이다!!

HttpSession 은 Application 의 로컬 메모리를 활용해 세션 저장소를 구현한다. 그리고 HttpSessionJSESSIONID 라는 Cookie 를 기반으로 동작한다.

즉, 두 가지 조건이 맞아야 spring-security-oauth2-client 가 구현해둔 위 로직이 문제없이 동작한다.

  1. Client 는 첫 번째 요청에서 받은 JSESSIONID 를 잃어버리지 말고 두 번째 요청시 Request Header 의 Cookie 에 담아서 보내야한다.
  2. Client 의 첫 번재 요청과 두 번째 요청을 처리하는 Server 가 동일해야한다.

만약 중간에 JSESSIONID 를 잃어버렸거나 Server 가 달라졌다면 다시 돌아와도 저장해둔 state 를 포함한 요청(OAuth2AuthorizationRequest) 정보를 찾을 수 없다.

if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
	OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE);
    throw new OAuth2AuthorizationException(oauth2Error);
}

그렇게되면 당연히 위와같은 검증절차를 통과할 수 없기 때문에 로그인이 실패하는 것이다.


그래서 왜 배포만 하면..


여러가지 테스트를 거치면서 발견한 것은 "서버를 클라우드에 배포하면 JSESSIONID Cookie 가 사라진다는 것" 이었다. 이제와서 고백하자면... 끝끝내 왜 서버를 클라우드에 배포하기만하면 JSESSIONID Cookie 가 사라지는지 원인을 찾지 못했다.

혹시.. 원인을 아는분이 있다면 댓글 부탁드립니다..!! 🥺🙏🏼

원인도 못찾았으면서 무슨 포스팅을써? 라고 생각할 수도 있겠지만 해결 방법을 찾았고, 사실 이 문제는 배포와 상관없이 다양한 문제를 야기할 수 있기 때문에 반드시 알아야하는 포인트라고 생각패 포스팅을 작성하게 되었다.


어떤 문제가 생길 수 있을까?


배포했을 때 JSESSIONID 가 사라져서 생기는 문제도 있지만.. 사실 심각한 문제가 발생할 수 있는 상황은 동일한 Application 이 여러대의 서버로 확장되는 Scale-out 환경에서이다.

HttpSession 은 기본적으로 Application 자체의 로컬 메모리에 저장하기 때문에 인가코드(authorization_code) 를 가진 두 번째 요청이 또 다른 서버로 가게된다면 state 가 포함된 요청(OAuth2AuthorizationRequest) 을 세션 저장소에서 찾을 수 없어 동일한 문제가 발생하게된다.

즉, 나는 모든 코드를 STATELESS 로 가정하고 Application 을 개발했는데, spring-security-oauth2-client 내부에서 멋대로 STATEFUL 한 로직을 사용하고 있어서 문제가 발생 할 수 있다는 것이다.

심지어 Spring Security 설정에 it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) 까지 줬는데 억울하기 그지없다.

만약, Scale-out 환경에서 이 문제를 마주했다면 지금보다도 더 원인을 파악하는데 오랜 시간이 필요했을것 같다.


어떻게 해결할 수 있었을까?


state 를 포함한 요청(OAuth2AuthorizationRequest) 객체가 로컬 메모리를 기반으로 하는 HttpSession 에 저장하는게 근본적인 원인이었다.

따라서 HttpSession 을 사용하는 HttpSessionOAuth2AuthorizationRequestRepository 를 제거하고 직접 AuthorizationRequestRepository 를 커스터마이징 하는 것으로 이 문제를 해결할 수 있다.

직접 작성한 코드를 살펴보자.

@Component
class CustomAuthorizationRequestRepository : AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    private val requestStorage = mutableMapOf<String, OAuth2AuthorizationRequest>()

    override fun loadAuthorizationRequest(request: HttpServletRequest?): OAuth2AuthorizationRequest {
        TODO("Not yet implemented")
    }

    override fun removeAuthorizationRequest(
        request: HttpServletRequest,
        response: HttpServletResponse
    ): OAuth2AuthorizationRequest? {
        return requestStorage.remove(request.getParameter("state"))
    }

    override fun saveAuthorizationRequest(
        authorizationRequest: OAuth2AuthorizationRequest,
        request: HttpServletRequest,
        response: HttpServletResponse
    ) {
        requestStorage[authorizationRequest.state] = authorizationRequest
    }
}

위 코드에서는 간단하게 Map<String, OAuth2AuthorizationRequest> 를 사용했는데 이를 통해 클라우드에 배포했을 때 발생하는 문제는 해결이 가능하지만, 근본적인 문제였던 로컬 메모리에 저장한다는 점은 동일하다.

위 예제코드는 어떤식으로 커스터마이징하는지 가이드를 위해 작성된 참고용 코드이다.

근본적인 문제를 해결하려면 외부 컴포넌트를 활용해야한다. 예를 들면 Redis 에 stateOAuth2AuthorizationRequest 객체를 저장해두는 방식이 있을 것 같다.

이렇게 작성된 AuthorizationRequestRepository 구현체를 Security Config 에 등록해주기만 하면 된다.

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    return http.formLogin { it.disable() }
        .httpBasic { it.disable() }
        .csrf { it.disable() }
        .cors { it.configurationSource(corsConfigurationSource()) }
        .sessionManagement {
            it.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        }
        .oauth2Login { oauthConfig ->
            oauthConfig.redirectionEndpoint {
                it.baseUri("/oauth2/*/callback")
            }.userInfoEndpoint {
                it.userService(oAuthUserService)
            }.authorizationEndpoint {
                it.authorizationRequestRepository(oAuthAuthorizationRequestRepository)
            }.successHandler(oAuthLoginSuccessHandler)
        }
        .build()
}

등록해주는 부분은 it.authorizationRequestRepository(oAuthAuthorizationRequestRepository) 부분이다!!


결론


험난했다.

당연히 STATELESS 를 가정하고 Application 을 구성했는데, spring-security-oauth2-client 가 내부 로직에서 마음대로 HttpSession 을 이용하고 있어 완전히 뒤통수를 맞은 기분이었다.

심지어 그 과정을 찾는 것도 디버깅 난이도가 높았다. 이번 이슈를 계기로 Spring Security 라는 프레임워크에 대한 회의감을 다시한번 느꼈고, 내부 흐름을 완전히 이해해 정말 필요한 기능만 자유자제로 커스터마이징해서 사용할 수 있는게 아니라면 굳이 선택하지 않을 것 같다.

전체 흐름을 정리하면서 이번 포스팅을 마친다.

  1. OAuth 2.0 에는 보안적인 이슈를 해결하기 위한 state 파라미터가 존재한다.
  2. spring-security-oauth2-client 는 이 stateHttpSession 에 저장해서 관리한다.
  3. HttpSession 에 의해 발급되는 JSESSIONID 를 잃어버리거나, Scale-out 된 환경에서 또 다른 서버로 두 번째 요청이 가게되면 HttpSession 에 저장해둔 state 를 찾을 수 없어 로그인에 실패한다.
  4. HttpSession 을 활용하는건 spring-security-oauth2-client 내부에 정의되어 기본적으로 사용되는 HttpSessionOAuth2AuthorizationRequestRepository 객체다.
  5. HttpSessionOAuth2AuthorizationRequestRepository 를 사용하지 않고 AuthorizationRequestRepository 구현체를 직접 만들어 덮어씌우면 문제가 해결된다.
  6. 커스텀 하는 AuthorizationRequestRepositorystate 와 요청(OAuth2AuthorizationRequest) 객체를 로컬 메모리에 저장하지 않고 외부 컴포넌트에 저장해두는 방식을 사용해야 Scale-out 환경에서도 안전하게 동작할 수 있다.

소스코드 링크 : https://github.com/slolee/oauth-sample-v.2023.12

0개의 댓글