Oauth2LoginConfigurer 파헤치기

박진선·2023년 6월 15일
1

스프링 시큐리티를 활용해 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

  • accessTokenResponseClient
    OAuth2AccessTokenResponseClient 구현 클래스를 설정시 해당 필드에 저장되며 OAuth2AuthorizationCodeAuthenticationProvider 클래스의 accessTokenResponseClient 필드에 대입되어 Authorization Server 로 AccessToken 을 요청하여 OAuth2AccessTokenResponse 클래스 형태로 반환하는 역할을 한다. 설정하지 않으면 DefaultAuthorizationCodeTokenResponseClient 가 대입된다.

RedirectionEndpointConfig

  • authorizationResponseBaseUri
    OAuth2LoginAuthenticationFilter 클래스의 동작 조건 URI 설정시 해당 필드에 대입된다. OAuth2LoginConfigurer 클래스에 loginProcessingUrl 과 같은 기능을 하며 즉 설정한 URI 로 요청이 올 경우에만 동작한다.

    주의할 점은 설정한 URI가 ClientRegistration 클래스의 redirectUri 필드와 같아야만 한다. 그 이유는 OAuth2AuthorizationRequestRedirectFilter 가 동작 하여 이번 프로젝트의 경우 구글 로그인 페이지로 Resource Owenr 를 리다이렉트 한 뒤 인증에 성공하면 redirectUri 필드 에 설정한 URI로 code 파라미터 (Authorization Code Grant Type 일 경우 AccessToken 으로 교환하기 위한 Authentication Code 가 저장됨) 를 담아 리다이렉트 되는데 여기서 OAuth2LoginAuthenticationFilter 가 실행되어 code 파라미터를 가지고 AccessToken 으로 교환 후 ResourceOwener 정보를 반환받아야 하는데 동작 조건 URI 가 다를 경우 호출되지 않아 에러가 발생한다.

    ClientRegistration 클래스는 yml 파일에 설정한 security:oauth2:client:registration:.. 프로퍼티를 기반으로 생성되며 설정하지 않은 정보는 CommonOAuth2Provider 클래스의 기준으로 자동으로 설정된다. 단 registrationId 가 Google, GitHub .. 일 경우만 이다. 즉 yml 프로퍼티에 redirectUri 를 임의로 설정 했다면 authorizationResponseBaseUri 도 같은 URI 로 설정해 주어야 한다.

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를 저장한다.

  1. loginProcessingUrl 필드를 부모 클래스 필드에도 대입하는데 기본값으로 "/login/oauth2/code/**" 대입되어 있으며 1번에서 저장한 Filter 의 부모 클래스인 AbstractAuthenticationProcessingFilter 의 requiresAuthenticationRequestMatcher 필드에 loginProcessingUrl 을 생성자로 생성한 AntPathRequestMatcher 인스턴스를 저장하여 필터의 동작 여부를 판별한다.

  2. 현재 프로젝트에서는 loginpage 에 어떠한 값도 할당하지 않아 if 문은 패스된다.
    다음 getLoginLinks 메소드를 호출하는데 해당 메소드에서 비정적 멤버 클래스인 AuthorizationEndpointConfig 의 authorizationRequestBaseUri 필드가 null 이 아닐 경우 authorizationRequestBaseUri + ClientRegistration 클래스의 registrationId 필드를 더한 값을 HashMap에 담아 반환하는 것이다. 이번 프로젝트의 경우 HttpSecurity 에서 설정한 /oauth/login + /google 로 설정된다.

  3. updateAuthenticationDefaults() 메소드가 호출되는데 HttpSecurity 에서 failureHandler 에 대한 설정을 하지않아 null 값이 되어 기본값인 "/login?error" 가 failureUrl 필드에 대입된다.

  4. LogoutConfigurer 클래스를 가져온 뒤 기본값인 "/login?logout" 이 LogoutConfigurer 클래스의 logoutSuccessUrl 필드에 대입시킨다.

  5. registerAuthenticationEntryPoint 메소드를 호출하여 기본 엔트리 포인트 설정하는데
    ExceptionHandlingConfigurer 를 가져온 뒤 해당 클래스의 defaultEntryPointMappings 필드에 구글 로그인페이지 URL "oauth/login/google" 을 가지고 있는 DelegatingAuthenticationEntryPoint가 대입된다.
    HttpSecurity를 이용하여 authenticationEntryPoint 를 설정하지 않았다면 defaultEntryPointMappings 필드값을 기반으로 ExceptionTranslationFilter 를 생성한다.
    비회원 및 권한이 없는 사용자가 API 요청시 구글 로그인 페이지로 리다이렉트 되는 것이다.

  6. HttpSecurity 를 통해 OAuth2AccessTokenResponseClient 를 설정 했다면 해당 클래스를 아니라면 DefaultAuthorizationCodeTokenResponseClient 인스턴스를 생성한다.
    다음 HttpSecurity 를 통해 OAuth2UserService 를 설정 했거나 OAuth2UserService<OAuth2UserRequest, OAuth2User> 타입의 Bean을 찾는다. 둘다 null 일 경우 HttpSecurity 를 통해 customUserTypes 를 설정했다면 CustomUserTypesOAuth2UserService 와 DefaultOAuth2UserService 인스턴스를 생성하여 List 에 담아 DelegatingOAuth2UserService 인스턴스 생성시 생성자로 넘겨주어 반환한다.
    설정하지 않았다면 DefaultOAuth2UserService 인스턴스를 생성하여 반환한다.

  7. 7번에서 생성한 responseClient, userService를 생성자로 넘겨주어 OAuth2LoginAuthenticationProvider 인스턴스를 생성하는데 생성자 내부에서 OAuth2AuthorizationCodeAuthenticationProvider 인스턴스를 생성하여 파라미터로 받은 responseClient 를 생성자로 넘겨 authorizationCodeAuthenticationProvider 필드에 저장한다.

  8. 다음 생성한 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 생성자로 넘겨주어 인스턴스를 생성한다. 아닐 경우 패스한다.

  1. HttpSecurity를 통해 비정적 멤버클래스인 AuthorizationEndpointConfig 의 authorizationRequestBaseUri 필드를 설정하지 않았다면 기본값인 "/oauth2/authorization" 를 OAuth2AuthorizationRequestRedirectFilter 생성자에 clientRegistrationRepository 함께 넘겨주어 인스턴스를 생성한다. 이번 프로젝트의 경우 "/oauth/login" 으로 설정하였음으로 해당 값을 인자값으로 넘겨준다. 생성자 로직을 보면
    파라미터로 받은 값들을 DefaultOAuth2AuthorizationRequestResolver 인스턴스를 생성하면서 넘겨주고 authorizationRequestResolver 필드에 대입된다. 추후 해당 Filter 동작 시 authorizationRequestResolver 필드의 resolve 메소드를 호출하여 OAuth2AuthorizationRequest 를 생성해 구글 로그인 페이지로 Redirect 한다. OAuth2AuthorizationRequest null 일 경우에는 다음 필터를 실행시킨다.

  2. HttpSecurity 를 통해 비정적 멤버 클래스 AuthorizationEndpointConfig 의 AuthorizationRequestRepository 를 설정했다면 OAuth2AuthorizationRequestRedirectFilter 의 authorizationRequestRepository 필드에 저장한다. 이번 프로젝트 에서는 설정하지 않았음으로 패스한다.

  3. HttpSecurity 를 통해 RequestCache 클래스를 가져온 뒤 null 아니면 Filter 의 requestCache 필드에 저장한다.

  4. HttpSecurity 의 filters 필드에 OAuth2AuthorizationRequestRedirectFilter 를 저장한다.

  5. 부모 클래스인 AbstractAuthenticationFilterConfigurer 클래스의 authFilter 필드에 저장된 OAuth2LoginAuthenticationFilter 를 가져온 뒤
    비정적 멤버 클래스 RedirectionEndpointConfig 의 authorizationResponseBaseUri 필드가 null이 아닐 경우 Filter 의 동작 조건을 해당 필드 url로 설정한다. 이번 프로젝트는 설정하지 않았음으로 패스한다.

  6. HttpSecurity 를 통해 비정적 멤버 클래스 AuthorizationEndpointConfig 의 AuthorizationRequestRepository 를 설정했다면 OAuth2LoginAuthenticationFilter 의 authorizationRequestRepository 필드에 저장한다. 이번 프로젝트 에서는 설정하지 않았음으로 패스한다.

  7. 부모 클래스인 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);
	}
profile
주니어 개발자 입니다

0개의 댓글