개인 프로젝트를 진행하는 도중 Spring Security OAuth2.0 로그인 구현을 하였다. Google, GitHub, Naver 로그인을 구현하였는데, 어플리케이션에서 서비스 프로바이더(Google, GitHub, Naver) 에 인증 요청을 보내고 그 결과를 받아 사용자정보(email)를 DB에서 조회해 데이터가 없을 경우 가입하는 프로세스였다.
구현을 완료한 후 GitHub과 Naver는 정상적으로 DB에 값이 저장 되는데, Google만 저장이 안되는 문제가 발생하였다.
위 프로세스를 진행하기 위해서는 Spring Security OAuth2UserService
인터페이스의 loadUser
를 구현한 커스텀 구현체를 구현해야 하는데 나는 OAuth2UserService
에서 구현한 DefaultOAuth2UserService
를 상속받아 커스텀 구현체를 구현했다.
현재 프로젝트의 커스텀 구현체인 CustomOAuth2UserService
에 브레이크 포인트를 걸고 디버깅을 진행하였으나 브레이크포인트에 걸리지 않았다. (즉, CustomOAuth2UserService
로 로직이 진행되지 않았다.)
Spring Security는 기본적으로 SecurityFilterChain
에 연결된 필터들을 통해 여러가지 처리를 한다. OAuth2.0 로긴의 경우 OAuth2LoginAuthenticationFilter
를 타게 되는데 OAuth2LoginAuthenticationFilter
의 attemptAuthentication
에서 다음 로직을 실행한다.
// OAuth2LoginAuthenticationFilter.java
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
.getAuthenticationManager().authenticate(authenticationRequest); // 이부분
OAuth2LoginAuthenticationToken
에는 인증을 위한 다양한 정보들이 담겨있다.
이 중에는 사용자가 프로퍼티에 설정한 registration
, cliend-id
, client-secret
등의 정보도 담겨있다
결론부터 말하자면, 위 디버깅 정보에서 scopes
의 openid
가 문제였는데 이에 대한 설명은 뒤에서 하기로 한다.
attemptAuthentication
메서드 내에 주석으로 표기한 authenticate
는AuthenticationManager
라는 인터페이스에 정의된 메서드이다. AuthenticationManager
인터페이스는 다음과 같이 여러가지 구현체가 있는데 그 중 ProviderManager
의 구현체를 사용한다.
ProviderManager
의 authenticate
는 아래의 과정을 수행하는데
// ProviderManager.java
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
Authentication result = null;
...
for (AuthenticationProvider provider : getProviders()) {
...
result = provider.authenticate(authentication); // 이부분
...
이는 AuthenticationProvider
인터페이스의 authenticate
를 호출한다.
AuthenticationProvider
도 다음과 같이 여러가지 구현체를 갖고 있는데 여기서는 OAuth2LoginAuthenticationProvider
를 사용한다.
OAuth2LoginAuthenticationProvider
의 authenticate
는 다음과 같은 처리를 하는데 여기서 위의 디버깅에서 보았던 scopes
에서 openid
가 나온다. 아래 코드 내의 주석에도 나와있듯이 scopes
에 openid
가 포함되어 있을 경우 OpenId Connect Authentication Request로 판단하여 null을 리턴하고OidcAuthorizationCodeAuthenticationProvider
가 대신 처리 한다.
// OAuth2LoginAuthenticationProvider.java
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
// Section 3.1.2.1 Authentication Request -
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest scope
// REQUIRED. OpenID Connect requests MUST contain the "openid" scope value.
if (loginAuthenticationToken.getAuthorizationExchange().getAuthorizationRequest().getScopes()
.contains("openid")) {
// This is an OpenID Connect Authentication Request so return null
// and let OidcAuthorizationCodeAuthenticationProvider handle it instead
return null;
}
...
OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters));
만약 openid
값이 없을경우 아래로 로직이 진행되고 this.userService.loadUser()
가 호출된다 여기서 userService가 사용자가 구현했던 CustomOAuth2UserService
인 것이다. (따로 커스텀한 구현체가 없을 경우 DefaultOAuth2UserService
)
참고로, OpenID Connect를 처리하는 OidcAuthorizationCodeAuthenticationProvider
의 authenticate
에서도 비슷한 처리를 하는데
// OidcAuthorizationCodeAuthenticationProvider.java
OidcUser oidcUser = this.userService.loadUser(new OidcUserRequest(clientRegistration,
accessTokenResponse.getAccessToken(), idToken, additionalParameters));
여기서의 기본 구현체는 OidcUserService
라는 구현체이다.
OAuth2LoginAuthenticationToken
디버깅정보 중 scopes
에 openid
가 포함되어 사용자가 정의한 CustomOAuth2UserService
로 정상적으로 진행되지 않았던 것이다.
Spring Security에서는 편의를 위해 Google, GitHub, Facebook 등의 대형 서비스에 대해 기본적으로 여러가지 설정을 해두었는데 해당 설정이 되어있는 곳이 CommonOAuth2Provider
는 enum
이다.
(참고로 Naver는 없기 때문에 모든 설정값을 직접 지정해주어야함)
public enum CommonOAuth2Provider {
GOOGLE {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}
},
GITHUB {
@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},
...
위 코드에서 보듯이 Google의 기본 scope
는 따로 설정하지 않을 경우 "openid", "profile", "email"
이다.
앞서 첨부하였던, oauth 설정 yml 스크린샷에서 보듯이 나는 각 서비스에 따로 scope를 지정해주지 않았다.
따라서 기본 설정인 openid
가 scope
에 포함되어 OpenID Connect 로직을 타게된 것이었다.
oauth 관련 yml 설정에 다음과 같이 scope를 지정해 주었더니
spring:
security:
oauth2:
client:
registration:
google:
client-id: XXXX
client-secret: XXXX
scope: profile, email
정상적으로 실행되어 데이터베이스에 정보가 적재되는것을 확인할 수 있었다.
CommonOAuth2Provider
에 지정해두었다.CommonOAuth2Provider
에서 기본 scope 정보를 가져온다.openid, email, profile
이 기본값으로 설정되어 있어 내부적으로 openid connect 로직을 타게 된다.
와 ㅠㅠ 이거때문에 하루종일 고통받았다가 scope에서 openid 빼니 돌아가서 더 멘붕 중이었는데ㅠㅠ 이거 보고 이해 잘 하고 갑니다!! ㅎㅎ