스프링 시큐리티를 활용해 Oauth2 기능을 사용한 소셜 로그인 구현 시 미숙한 부분이 많아 디버깅을 통해 전반적인 동작과정을 이해하기 위한 과정을 기록하려고 한다.
진행중인 프로젝트의 HttpSecurity 를 사용한 설정은 아래와 같으며 해당 설정 기준으로 설명한다.
@Configuration
public class SecurityConfiguration {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
...
...
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth/login")
.and()
.successHandler(new Oauth2MemberSuccessHandler(jwtProvider, memberRepository, redisRepository, s3Service, customAuthorityUtils));
return http.build();
}
}
public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>>
extends AbstractSecurityBuilder<O> {
private final LinkedHashMap<Class<? extends SecurityConfigurer<O, B>>, List<SecurityConfigurer<O, B>>> configurers = new LinkedHashMap<>();
...
...
...
@Override
protected final O doBuild() throws Exception {
synchronized (this.configurers) {
this.buildState = BuildState.INITIALIZING;
beforeInit();
init();
this.buildState = BuildState.CONFIGURING;
beforeConfigure();
configure();
this.buildState = BuildState.BUILDING;
O result = performBuild();
this.buildState = BuildState.BUILT;
return result;
}
}
}
위의 SecurityConfiguration 클래스 에서 HttpSecurity 의 설정을 통해 생성된 모든 xxConfigurer 클래스는 HttpSecurity 의 부모 클래스인 AbstractConfiguredSecurityBuilder 클래스의 configurers 필드에 저장되며 httpSecurity.build(); 메소드가 실행되면 doBuild() 메소드를 호출한다. 메소드 내에서 init, cofigure 메소드가 실행되면 configurers 필드에 저장된 모든 XXConfgiurer 클래스의 init, cofigure 메소드를 호출하여 각각 Filter 를 생성하고 HttpSecurity 의 filters 필드에 대입된다. 다음 performBuild 메소드가 실행되면 filters 필드에 저장된 필터를 DefaultSecurityFilterChain 인스턴스를 생성하면서 생성자로 넘겨주고 Bean으로 등록하는 것이다. 등록된 DefaultSecurityFilterChain의 Bean 은 FilterChainProxy 클래스의 filterChains 필드에 대입되어 API 요청 마다 실행된다.
아래는 Oauth2LoginConfigurer 클래스의 비정적 멤버 클래스 이며 각 클래스의 필드에 무엇이 저장되며 어떠한 역할을 하는지 알아보자.
AuthorizationEndpointConfig
authorizationRequestBaseUri
OAuth2AuthorizationRequestRedirectFilter 의 리다이렉트 여부를 판별하는 URI를 설정 시 해당 필드에 대입된다. 미 설정시 "/oauth2/authorization" 값으로 설정된다.
저장된 URI는 DefaultOAuth2AuthorizationRequestResolver 클래스의 authorizationRequestMatcher 필드에 AntPathRequestMatcher 클래스로 래핑되어 저장되고 resolve 메소드를 통해 요청 URI가 설정한 URI 가 아닐 경우 null 을 반환한다. 호출한 OAuth2AuthorizationRequestRedirectFilter 에서는 다음 필터를 실행한다. 설정한 URI로 요청이 올 경우 이번 프로젝트 기준으로 GOOGLE 로그인 페이지로 리다이렉트 된다.
authorizationRequestResolver
OAuth2AuthorizationRequestResolver 를 커스텀한 클래스를 설정 시 해당 필드에 대입된다. 최종적으로 OAuth2AuthorizationRequestRedirectFilter 의 authorizationRequestResolver 필드에 저장된다. 기본 구동 방식은 위의 설명과 동일하다.
authorizationRequestRepository
AuthorizationRequestRepository 를 설정하는 역할을 하는데 구현체는 HttpSessionOAuth2AuthorizationRequestRepository 하나만 있다.
해당 클래스의 역할은 OAuth2AuthorizationRequestRedirectFilter 에서는 saveAuthorizationRequest 메소드를 호출해 StandardSession 클래스의 attributes 필드에 OAuth2AuthorizationRequest 를 저장하는 역할을 한다.
OAuth2LoginAuthenticationFilter 에서는 removeAuthorizationRequest() 호출하여 위에서 저장한 StandardSession 클래스의 attributes 필드에 저장된 OAuth2AuthorizationRequest 를 복사한 뒤 제거하는 역할을 한다.
TokenEndpointConfig
RedirectionEndpointConfig
UserInfoEndpointConfig
userService
OAuth2UserService 커스텀하여 설정시 해당필드에 저장되며 OAuth2LoginAuthenticationProvider 의 userService 필드에 대입되어 AccessToken 을 담아 ResourceServer API 에 ResourceOwener 의 정보를 요청 하는 역할을 한다.
oidcUserService
OidcUserService 커스텀하여 설정시 해당 필드에 저장되며 oidcUserService 의 역할은 우선 DefaultAuthorizationCodeTokenResponseClient 클래스의 getTokenResponse 메소드를 호출하여 JWT 형태의 AccessToken 과 id_token을 반환받은 뒤 id_token을 Decode 하여 Resource Owner 정보를 OidcIdToken 형태로 변환하고 이제 OidcIdToken, AccessToken 등의 정보를 OidcUserRequest 클래스의 생성자에 담아 인스턴스를 생성하여 oidcUserService 의 loadUser 메소드의 인자로 넘겨주면 OAuth2AccessToken 에 저장된 접근 권한 정보를 SimpleGrantedAuthority 로 변환하여 저장하는 등의 로직을 수행한 뒤 DefaultOidcUser 인스턴스에 담아 반환하는 역할을 한다.
customUserTypes
OAuth2User 를 커스텀하여 설정시 해당 필드에 저장되며 CustomUserTypesOAuth2UserService 클래스의 customUserTypes 필드에 저장되며 DelegatingOAuth2UserService 에 의해 호출되는데 AccessToken 을 가지고 이번 프로젝트 기준으로 구글 ResourceServer API를 요청 하면 ResourceOwener 의 정보를 설정한 클래스 타입으로 반환 받는다.
public final class AuthorizationEndpointConfig {
private String authorizationRequestBaseUri;
private OAuth2AuthorizationRequestResolver authorizationRequestResolver;
private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository;
...
...
}
public final class TokenEndpointConfig {
private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient;
...
...
}
public final class RedirectionEndpointConfig {
private String authorizationResponseBaseUri;
...
...
}
public final class UserInfoEndpointConfig {
private OAuth2UserService<OAuth2UserRequest, OAuth2User> userService;
private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService;
private Map<String, Class<? extends OAuth2User>> customUserTypes = new HashMap<>();
...
...
}
init 메소드
1. OAuth2LoginAuthenticationFilter 인스턴스를 생성하고 부모클래스인 AbstractAuthenticationFilterConfigurer 클래스의 필드에 생성한 Filter를 저장한다.
loginProcessingUrl 필드를 부모 클래스 필드에도 대입하는데 기본값으로 "/login/oauth2/code/**" 대입되어 있으며 1번에서 저장한 Filter 의 부모 클래스인 AbstractAuthenticationProcessingFilter 의 requiresAuthenticationRequestMatcher 필드에 loginProcessingUrl 을 생성자로 생성한 AntPathRequestMatcher 인스턴스를 저장하여 필터의 동작 여부를 판별한다.
현재 프로젝트에서는 loginpage 에 어떠한 값도 할당하지 않아 if 문은 패스된다.
다음 getLoginLinks 메소드를 호출하는데 해당 메소드에서 비정적 멤버 클래스인 AuthorizationEndpointConfig 의 authorizationRequestBaseUri 필드가 null 이 아닐 경우 authorizationRequestBaseUri + ClientRegistration 클래스의 registrationId 필드를 더한 값을 HashMap에 담아 반환하는 것이다. 이번 프로젝트의 경우 HttpSecurity 에서 설정한 /oauth/login + /google 로 설정된다.
updateAuthenticationDefaults() 메소드가 호출되는데 HttpSecurity 에서 failureHandler 에 대한 설정을 하지않아 null 값이 되어 기본값인 "/login?error" 가 failureUrl 필드에 대입된다.
LogoutConfigurer 클래스를 가져온 뒤 기본값인 "/login?logout" 이 LogoutConfigurer 클래스의 logoutSuccessUrl 필드에 대입시킨다.
registerAuthenticationEntryPoint 메소드를 호출하여 기본 엔트리 포인트 설정하는데
ExceptionHandlingConfigurer 를 가져온 뒤 해당 클래스의 defaultEntryPointMappings 필드에 구글 로그인페이지 URL "oauth/login/google" 을 가지고 있는 DelegatingAuthenticationEntryPoint가 대입된다.
HttpSecurity를 이용하여 authenticationEntryPoint 를 설정하지 않았다면 defaultEntryPointMappings 필드값을 기반으로 ExceptionTranslationFilter 를 생성한다.
비회원 및 권한이 없는 사용자가 API 요청시 구글 로그인 페이지로 리다이렉트 되는 것이다.
HttpSecurity 를 통해 OAuth2AccessTokenResponseClient 를 설정 했다면 해당 클래스를 아니라면 DefaultAuthorizationCodeTokenResponseClient 인스턴스를 생성한다.
다음 HttpSecurity 를 통해 OAuth2UserService 를 설정 했거나 OAuth2UserService<OAuth2UserRequest, OAuth2User> 타입의 Bean을 찾는다. 둘다 null 일 경우 HttpSecurity 를 통해 customUserTypes 를 설정했다면 CustomUserTypesOAuth2UserService 와 DefaultOAuth2UserService 인스턴스를 생성하여 List 에 담아 DelegatingOAuth2UserService 인스턴스 생성시 생성자로 넘겨주어 반환한다.
설정하지 않았다면 DefaultOAuth2UserService 인스턴스를 생성하여 반환한다.
7번에서 생성한 responseClient, userService를 생성자로 넘겨주어 OAuth2LoginAuthenticationProvider 인스턴스를 생성하는데 생성자 내부에서 OAuth2AuthorizationCodeAuthenticationProvider 인스턴스를 생성하여 파라미터로 받은 responseClient 를 생성자로 넘겨 authorizationCodeAuthenticationProvider 필드에 저장한다.
다음 생성한 OAuth2LoginAuthenticationProvider를 HttpSecurity 를 통해 저장하는데
AuthenticationManagerBuilder 클래스의 List<AuthenticationProvider> authenticationProviders 필드에 저장된다. 다음 같은 방식으로 OidcAuthorizationCodeAuthenticationProvider 생성하여 저장한다.
public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, OAuth2LoginConfigurer<B>, OAuth2LoginAuthenticationFilter> {
private final AuthorizationEndpointConfig authorizationEndpointConfig = new AuthorizationEndpointConfig();
private final TokenEndpointConfig tokenEndpointConfig = new TokenEndpointConfig();
private final RedirectionEndpointConfig redirectionEndpointConfig = new RedirectionEndpointConfig();
private final UserInfoEndpointConfig userInfoEndpointConfig = new UserInfoEndpointConfig();
private String loginPage;
private String loginProcessingUrl = OAuth2LoginAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI;
...
...
public void init(B http) throws Exception {
OAuth2LoginAuthenticationFilter authenticationFilter = new OAuth2LoginAuthenticationFilter(
OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()),
OAuth2ClientConfigurerUtils.getAuthorizedClientRepository(this.getBuilder()), this.loginProcessingUrl);
this.setAuthenticationFilter(authenticationFilter);
super.loginProcessingUrl(this.loginProcessingUrl);
if (this.loginPage != null) {
// Set custom login page
super.loginPage(this.loginPage);
super.init(http);
}
else {
Map<String, String> loginUrlToClientName = this.getLoginLinks();
if (loginUrlToClientName.size() == 1) {
// Setup auto-redirect to provider login page
// when only 1 client is configured
this.updateAuthenticationDefaults();
this.updateAccessDefaults(http);
String providerLoginPage = loginUrlToClientName.keySet().iterator().next();
this.registerAuthenticationEntryPoint(http, this.getLoginEntryPoint(http, providerLoginPage));
}
else {
super.init(http);
}
}
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient = this.tokenEndpointConfig.accessTokenResponseClient;
if (accessTokenResponseClient == null) {
accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
}
OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService = getOAuth2UserService();
OAuth2LoginAuthenticationProvider oauth2LoginAuthenticationProvider = new OAuth2LoginAuthenticationProvider(
accessTokenResponseClient, oauth2UserService);
GrantedAuthoritiesMapper userAuthoritiesMapper = this.getGrantedAuthoritiesMapper();
if (userAuthoritiesMapper != null) {
oauth2LoginAuthenticationProvider.setAuthoritiesMapper(userAuthoritiesMapper);
}
http.authenticationProvider(this.postProcess(oauth2LoginAuthenticationProvider));
boolean oidcAuthenticationProviderEnabled = ClassUtils
.isPresent("org.springframework.security.oauth2.jwt.JwtDecoder", this.getClass().getClassLoader());
if (oidcAuthenticationProviderEnabled) {
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService = getOidcUserService();
OidcAuthorizationCodeAuthenticationProvider oidcAuthorizationCodeAuthenticationProvider = new OidcAuthorizationCodeAuthenticationProvider(
accessTokenResponseClient, oidcUserService);
JwtDecoderFactory<ClientRegistration> jwtDecoderFactory = this.getJwtDecoderFactoryBean();
if (jwtDecoderFactory != null) {
oidcAuthorizationCodeAuthenticationProvider.setJwtDecoderFactory(jwtDecoderFactory);
}
if (userAuthoritiesMapper != null) {
oidcAuthorizationCodeAuthenticationProvider.setAuthoritiesMapper(userAuthoritiesMapper);
}
http.authenticationProvider(this.postProcess(oidcAuthorizationCodeAuthenticationProvider));
}
else {
http.authenticationProvider(new OidcAuthenticationRequestChecker());
}
this.initDefaultLoginFilter(http);
}
configure 메소드
1. HttpSecurity를 통해 OAuth2AuthorizationRequestResolver 를 설정했다면 OAuth2AuthorizationRequestRedirectFilter 생성자로 넘겨주어 인스턴스를 생성한다. 아닐 경우 패스한다.
HttpSecurity를 통해 비정적 멤버클래스인 AuthorizationEndpointConfig 의 authorizationRequestBaseUri 필드를 설정하지 않았다면 기본값인 "/oauth2/authorization" 를 OAuth2AuthorizationRequestRedirectFilter 생성자에 clientRegistrationRepository 함께 넘겨주어 인스턴스를 생성한다. 이번 프로젝트의 경우 "/oauth/login" 으로 설정하였음으로 해당 값을 인자값으로 넘겨준다. 생성자 로직을 보면
파라미터로 받은 값들을 DefaultOAuth2AuthorizationRequestResolver 인스턴스를 생성하면서 넘겨주고 authorizationRequestResolver 필드에 대입된다. 추후 해당 Filter 동작 시 authorizationRequestResolver 필드의 resolve 메소드를 호출하여 OAuth2AuthorizationRequest 를 생성해 구글 로그인 페이지로 Redirect 한다. OAuth2AuthorizationRequest null 일 경우에는 다음 필터를 실행시킨다.
HttpSecurity 를 통해 비정적 멤버 클래스 AuthorizationEndpointConfig 의 AuthorizationRequestRepository 를 설정했다면 OAuth2AuthorizationRequestRedirectFilter 의 authorizationRequestRepository 필드에 저장한다. 이번 프로젝트 에서는 설정하지 않았음으로 패스한다.
HttpSecurity 를 통해 RequestCache 클래스를 가져온 뒤 null 아니면 Filter 의 requestCache 필드에 저장한다.
HttpSecurity 의 filters 필드에 OAuth2AuthorizationRequestRedirectFilter 를 저장한다.
부모 클래스인 AbstractAuthenticationFilterConfigurer 클래스의 authFilter 필드에 저장된 OAuth2LoginAuthenticationFilter 를 가져온 뒤
비정적 멤버 클래스 RedirectionEndpointConfig 의 authorizationResponseBaseUri 필드가 null이 아닐 경우 Filter 의 동작 조건을 해당 필드 url로 설정한다. 이번 프로젝트는 설정하지 않았음으로 패스한다.
HttpSecurity 를 통해 비정적 멤버 클래스 AuthorizationEndpointConfig 의 AuthorizationRequestRepository 를 설정했다면 OAuth2LoginAuthenticationFilter 의 authorizationRequestRepository 필드에 저장한다. 이번 프로젝트 에서는 설정하지 않았음으로 패스한다.
부모 클래스인 AbstractAuthenticationFilterConfigurer 클래스의 configure 메소드를 호출하는데 OAuth2LoginAuthenticationFilter 에 ProviderManager, successHandler, failureHandler 를 설정하고 HttpSecurity 의 Filters 필드에 저장한다.
public void configure(B http) throws Exception {
OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter;
if (this.authorizationEndpointConfig.authorizationRequestResolver != null) {
authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
this.authorizationEndpointConfig.authorizationRequestResolver);
}
else {
String authorizationRequestBaseUri = this.authorizationEndpointConfig.authorizationRequestBaseUri;
if (authorizationRequestBaseUri == null) {
authorizationRequestBaseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI;
}
authorizationRequestFilter = new OAuth2AuthorizationRequestRedirectFilter(
OAuth2ClientConfigurerUtils.getClientRegistrationRepository(this.getBuilder()),
authorizationRequestBaseUri);
}
if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
authorizationRequestFilter
.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
}
RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache != null) {
authorizationRequestFilter.setRequestCache(requestCache);
}
http.addFilter(this.postProcess(authorizationRequestFilter));
OAuth2LoginAuthenticationFilter authenticationFilter = this.getAuthenticationFilter();
if (this.redirectionEndpointConfig.authorizationResponseBaseUri != null) {
authenticationFilter.setFilterProcessesUrl(this.redirectionEndpointConfig.authorizationResponseBaseUri);
}
if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
authenticationFilter
.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
}
super.configure(http);
}