Spring Security는 강력한 인증 및 권한 부여 프레임워크로, 다양한 인증 방식을 지원합니다. 그 중 OAuth2는 널리 사용되는 인증 프로토콜이며, 특히 Google, Facebook 등의 소셜 로그인에 많이 활용됩니다. 하지만 때로는 예상치 못한 동작으로 인해 개발자를 당황스럽게 만들기도 합니다. 오늘은 Google OAuth2 로그인 구현 중 마주친 흥미로운 문제와 그 해결 과정을 공유하고자 합니다.
문제 상황
Spring Security를 사용하여 Google OAuth2 로그인을 구현하던 중, 다음과 같은 설정을 통해 커스텀 OAuth2UserService를 등록했습니다:
.oauth2Login { oauth2 ->
oauth2
.userInfoEndpoint { it.userService(customOAuth2UserService) }
.successHandler(oAuth2AuthenticationSuccessHandler())
}
그러나 디버깅 과정에서 CustomOAuth2UserService의 loadUser 메서드가 호출되지 않고, 바로 성공 핸들러로 넘어가는 현상이 발생했습니다.
원인 분석
이 현상의 원인은 Google이 OpenID Connect (OIDC) 프로토콜을 사용한다는 점에 있었습니다. OIDC는 OAuth2의 확장 프로토콜로, 인증에 대한 추가적인 보안 계층을 제공합니다.
Spring Security는 OIDC를 지원하기 위해 OidcUserService를 사용합니다.
이는 표준 OAuth2 인증 흐름과는 약간 다른 처리 과정을 거치게 됩니다.
OidcAuthorizationCodeAuthenticationProvider
OAuth2 vs OIDC
OAuth2는 주로 권한 부여(Authorization)에 중점을 둡니다.
OIDC는 OAuth2를 기반으로 하지만, 인증(Authentication)에 대한 표준화된 방법을 추가로 제공합니다.
Spring Security의 동작
OAuth2 프로바이더가 OIDC를 지원하는 경우, Spring Security는 기본적으로 OidcUserService를 사용합니다.
이는 OAuth2UserService 대신에 OidcUserService가 호출되는 결과를 가져옵니다.
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken authorizationCodeAuthentication = (OAuth2LoginAuthenticationToken)authentication;
if (!authorizationCodeAuthentication.getAuthorizationExchange().getAuthorizationRequest().getScopes().contains("openid")) {
return null;
} else {
OAuth2AuthorizationRequest authorizationRequest = authorizationCodeAuthentication.getAuthorizationExchange().getAuthorizationRequest();
OAuth2AuthorizationResponse authorizationResponse = authorizationCodeAuthentication.getAuthorizationExchange().getAuthorizationResponse();
if (authorizationResponse.statusError()) {
throw new OAuth2AuthenticationException(authorizationResponse.getError(), authorizationResponse.getError().toString());
} else if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
OAuth2Error oauth2Error = new OAuth2Error("invalid_state_parameter");
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
} else {
OAuth2AccessTokenResponse accessTokenResponse = this.getResponse(authorizationCodeAuthentication);
ClientRegistration clientRegistration = authorizationCodeAuthentication.getClientRegistration();
Map<String, Object> additionalParameters = accessTokenResponse.getAdditionalParameters();
if (!additionalParameters.containsKey("id_token")) {
OAuth2Error invalidIdTokenError = new OAuth2Error("invalid_id_token", "Missing (required) ID Token in Token Response for Client Registration: " + clientRegistration.getRegistrationId(), (String)null);
throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
} else {
OidcIdToken idToken = this.createOidcToken(clientRegistration, accessTokenResponse);
this.validateNonce(authorizationRequest, idToken);
OidcUser oidcUser = (OidcUser)this.userService.loadUser(new OidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(), idToken, additionalParameters));
Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper.mapAuthorities(oidcUser.getAuthorities());
OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(authorizationCodeAuthentication.getClientRegistration(), authorizationCodeAuthentication.getAuthorizationExchange(), oidcUser, mappedAuthorities, accessTokenResponse.getAccessToken(), accessTokenResponse.getRefreshToken());
authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
return authenticationResult;
}
}
}
}
OidcAuthorizationCodeAuthenticationProvider 클래스의 authenticate 메서드는 OAuth2 인증 과정에서 중요한 역할을 합니다. 이 메서드의 주요 분기와 처리 과정을 순차적으로 설명하겠습니다:
OpenID Connect (OIDC) 스코프 확인
if (!authorizationCodeAuthentication.getAuthorizationExchange().getAuthorizationRequest().getScopes().contains("openid")) {
return null;
}
- 이 부분에서 "openid" 스코프가 요청에 포함되어 있는지 확인합니다.
- "openid" 스코프가 없으면 이 provider는 인증을 처리하지 않고 null을 반환합니다.
- 이는 OIDC 프로토콜을 사용하지 않는 일반 OAuth2 인증의 경우를 걸러내는 역할을 합니다.
인증 응답 유효성 검사
인증 응답이 에러 상태인지 확인합니다.
state 파라미터가 일치하는지 확인합니다 (CSRF 공격 방지).
액세스 토큰 응답 처리
OAuth2AccessTokenResponse를 얻어옵니다.
ID 토큰 확인
if (!additionalParameters.containsKey("id_token")) {
// ID 토큰이 없으면 예외를 던집니다.
}
OIDC 프로토콜에서는 ID 토큰이 필수적입니다.
OIDC 토큰 생성 및 검증
ID 토큰을 생성하고 nonce를 검증합니다.
사용자 정보 로드
OidcUser oidcUser = (OidcUser)this.userService.loadUser(new OidcUserRequest(clientRegistration, accessTokenResponse.getAccessToken(), idToken, additionalParameters));
여기서 userService.loadUser 메서드를 호출하여 OidcUser 객체를 생성합니다.
이 부분이 제가 구현한 CustomOAuth2UserService의 loadUser 메서드 대신 호출되는 부분입니다.
인증 결과 생성
최종적으로 OAuth2LoginAuthenticationToken을 생성하여 반환합니다.
문제의 원인
Google OAuth2 설정에 "openid" 스코프가 포함되어 있어서, 이 OIDC 전용 provider가 인증을 처리하게 됩니다.
이 provider는 OidcUserService를 사용하여 사용자 정보를 로드합니다.
따라서 제가 구현한 CustomOAuth2UserService.loadUser 메서드 대신, Spring Security의 기본 OidcUserService가 사용됩니다.
해결 방법
"openid" 스코프를 제거하여 일반 OAuth2 흐름을 사용하거나,
OidcUserService를 구현하여 OIDC 흐름에 맞는 사용자 정보 로딩 로직을 제공하거나,
설정에서 both OAuth2UserService와 OidcUserService를 지정하여 두 경우 모두 처리할 수 있도록 합니다.
이렇게 함으로써, Google의 OIDC 기반 인증 흐름에서도 커스텀 로직을 적용할 수 있게 됩니다.
결론
이번 경험을 통해 OAuth2와 OIDC의 차이점, 그리고 Spring Security가 이를 어떻게 처리하는지에 대해 깊이 있게 이해할 수 있었습니다. 소셜 로그인 구현 시 사용하는 프로바이더의 프로토콜을 정확히 파악하고, 그에 맞는 적절한 서비스를 구현하는 것이 중요합니다.
(젓가락질을 이상하게 해도 어떻게든 밥을 먹을 수만 있으면 그만인 것이 아니듯 ㅎㅎ)
이러한 세부적인 차이점을 이해하고 대응함으로써, 개발자가 더 안정적이고 보안성 높은 인증 시스템을 구축할 수 있습니다. Spring Security의 유연성과 강력함을 제대로 활용하기 위해서는 이런 세세한 부분들에 대한 이해가 필수적입니다.