오랜만에 트러블 슈팅으로 돌아왔다.
이번 포스팅의 부제는 "내 컴퓨터에선 잘 됐는데.." 이다.
이번에 소개할 이슈는 spring-security-oauth2-client 를 이용해 소셜 로그인을 구현하는과정에서 발생한 것이고, 서버를 클라우드에 배포만하면 카카오 소셜 로그인이 실패하는 이슈였다.
개인적으로 디버깅이 굉장히 어려운 문제였다고 생각하고 그 이유는 모든 로직이 Spring Security 내부 코드에 의해서 동작했기 때문이라고 생각한다. Spring Security 는 굉장히 강력한 프레임워크임이 틀림없지만 내부 동작을 제대로 이해하고 있지않은 상태로 사용했을 때 이슈를 해결하는데 굉장히 많은 리소스가 필요하다는 단점이 있다. 원래도 Spring Security 를 즐겨쓰지 않았지만 이번 계기로 더더욱 그 생각이 굳건해졌다.
Spring Security 에 대한 나의 생각은 포스팅 을 통해서 자세히 확인해보면 될 것 같다.
내가 구현하고자하는 요구사항은 매우 간단하다.
이번 포스팅은 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가지 흐름이 존재한다.
각 흐름에 대한 자세한 코드를 살펴보기에 앞서 OAuth 2.0 에 존재하는 state 에 대한 개념을 먼저 알아야한다.
spring-security-oauth2-client 는 위와같은 state 를 기본적으로 사용하고 있기 때문에 반드시 이해하고 넘어가야한다.
state 에 자세한 내용은 https://auth0.com/docs/secure/attack-protection/state-parameters 사이트를 통해 학습하면 좋을 것 같고, 이번 포스팅에서는 핵심적인 개념만 설명하고 넘어가려고 한다.
state 는 앞서 소개한 1번 흐름과 2번 흐름 사이의 연결을 위해서 사용된다. 1번 흐름을 보낸 서버와 2번 흐름을 받아서 처리하는 서버가 동일한지 확인해 CSRF 공격을 막아주는 것이다.
간략한 흐름을 통해 조금 더 자세히 알아보자.
- Client to Server : KAKAO 소셜 로그인 할게. 로그인 페이지로 리다이렉션 시켜줘.
- Server to Client : 오케이 로그인 페이지로 보내줄게. 근데 임의의 난수
state를 내가 생성해서 줄테니까 로그인 성공하고나면 이거 도로 가져와야 이후 프로세스 진행해줄거야.- Client to KAKAO : 로그인 할게.
- KAKAO to Client : 너 로그인 성공했어. 인가코드(
authorization_code) 줄테니까 이거 가지고 이후 프로세스 진행하면 돼. (리다이렉션 방식에 따른 생략 가능)- Client to Server : 인가코드랑 아까 너가 준
state가지고 왔어. 이걸로 KAKAO 쪽 사용자 정보 조회해서 로그인처리 완료해줘.- Server : 음..
state가 내가 발급해준게 맞네. 통과!- Server : 이후 프로세스 진행 (생략)
이정도면 state 가 왜 필요하고, OAuth 2.0 소셜로그인 흐름상에서 어떻게 움직이는지에 대해서 이해했으리라 생각한다.
첫 번째 흐름은 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 이다. HttpSessionOAuth2AuthorizationRequestRepository 의 saveAuthorizationRequest() 메소드의 코드를 살펴보자.
@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 를 생성하고 저장하는 흐름이 존재한다는 것을 확인할 수 있다.
문제는 어디에 저장하는가? 이다!! 생성한 state 를 HttpSession 을 이용해 Session 저장소에 저장하고 있다. 이미 어떤 문제가 있을지 눈치챈 사람들도 있겠지만 우선 그렇다는것만 기억하고 나머지 흐름을 살펴보자.
이렇게 첫 번째 흐름은 마무리된다.
두 번째 흐름은 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);
}
// 생략 ...
}
엄청나게 많은 과정을 거치지만 위에 남겨둔 핵심 흐름은 아래와 같다.
HttpSessionOAuth2AuthorizationRequestRepository.removeAuthorrizationRequest() 를 호출해 1번 흐름에서 HttpSession 에 저장해뒀던 요청(OAuth2AuthorizationRequest)을 꺼낸다.state 정보도 포함되어 있다.authorization_code, state 를 포함해 온 요청을 토대로 응답(OAuth2AuthorizationResponse)을 만든다.OAuth2AuthorizationCodeAuthenticationToken 인증전 객체를 생성한다.OAuth2AuthorizationCodeAuthenticationToken 를 처리할 수 있는 AuthenticationProvider 를 선택해 인증을 요청한다. 이때 선택되는 AuthenticationProvider 는 OAuth2AuthorizationCodeAuthenticationProvider 이다.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-client 가 state 를 포함한 요청(OAuth2AuthorizationRequest) 정보를 HttpSession 이라는 세션 저장소에 저장했다는 것이다!!
이 HttpSession 은 Application 의 로컬 메모리를 활용해 세션 저장소를 구현한다. 그리고 HttpSession 은 JSESSIONID 라는 Cookie 를 기반으로 동작한다.
즉, 두 가지 조건이 맞아야 spring-security-oauth2-client 가 구현해둔 위 로직이 문제없이 동작한다.
- Client 는 첫 번째 요청에서 받은
JSESSIONID를 잃어버리지 말고 두 번째 요청시 Request Header 의 Cookie 에 담아서 보내야한다.- 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 에 state 및 OAuth2AuthorizationRequest 객체를 저장해두는 방식이 있을 것 같다.
이렇게 작성된 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 라는 프레임워크에 대한 회의감을 다시한번 느꼈고, 내부 흐름을 완전히 이해해 정말 필요한 기능만 자유자제로 커스터마이징해서 사용할 수 있는게 아니라면 굳이 선택하지 않을 것 같다.
전체 흐름을 정리하면서 이번 포스팅을 마친다.
state 파라미터가 존재한다.spring-security-oauth2-client 는 이 state 를 HttpSession 에 저장해서 관리한다.HttpSession 에 의해 발급되는 JSESSIONID 를 잃어버리거나, Scale-out 된 환경에서 또 다른 서버로 두 번째 요청이 가게되면 HttpSession 에 저장해둔 state 를 찾을 수 없어 로그인에 실패한다.HttpSession 을 활용하는건 spring-security-oauth2-client 내부에 정의되어 기본적으로 사용되는 HttpSessionOAuth2AuthorizationRequestRepository 객체다.HttpSessionOAuth2AuthorizationRequestRepository 를 사용하지 않고 AuthorizationRequestRepository 구현체를 직접 만들어 덮어씌우면 문제가 해결된다.AuthorizationRequestRepository 은 state 와 요청(OAuth2AuthorizationRequest) 객체를 로컬 메모리에 저장하지 않고 외부 컴포넌트에 저장해두는 방식을 사용해야 Scale-out 환경에서도 안전하게 동작할 수 있다.