땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 24. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (3) 소셜서비스 사용자 정보 획득 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 24. 스프링 시큐리티 OAuth2 Client 를 사용한 소셜 로그인 (3) 소셜서비스 사용자 정보 획득

ttasjwi 2024. 12. 2. 11:27

지난 글에서는 사용자의 OAuth2 권한부여 요청이 들어왔을 때, 사용자를 실제 권한부여 엔드포인트(소셜 로그인 페이지)로 리다이렉트 시켜서, code를 발급받는 부분까지 진행했습니다.

 

이번 글부터는 사용자를 실제로 소셜로그인, 회원가입 시키는 작업을 진행해볼건데요.

소셜 서비스의 사용자 정보를 획득하는 작업까지 해보도록 하겠습니다.


1. 리다이렉트 받은 페이지: 다시 백엔드의 소셜 로그인 API를 호출

 

 

 

리다이렉트 받은 페이지입니다.

제가 따로 프론트엔드 서버를 띄워두지 않았지만, 프론트엔드 측에서는 사용자가 이 페이지로 리다이렉트 되었을 때 다음 작업을 수행해야합니다.

 

 

1. 구글/네이버/카카오 측이 리다이렉트 시점에 보낸 파라미터 수집

리다이렉트 된 페이지의 쿼리 파라미터에는 state, code, scope가 포함되어 있습니다.

 

2. 백엔드의 소셜 로그인 API 호출

백엔드의 실질적인 소셜로그인 API 를 호출하고 state, code, scope 값을 포함해서 보내야합니다.

제가 구현한 스프링 서버에서는 일단

 

/api/v1/auth/login/oauth2/code/google

/api/v1/auth/login/oauth2/code/naver

/api/v1/auth/login/oauth2/code/post

이런 이름으로 잡아놨습니다. 

(다시 생각해보면 그냥 /api/v1/auth/social-login/google  이렇게 잡아둘걸 생각도 드네요)

 

3. 파라미터 전송 HTTP 메서드 선택

백엔드 측에 파라미터를 보내줘야하는데 이 때 HTTP 메서드 선택을 해야합니다.

스프링이 제공해주는 OAuth2LoginAuthenticationFilter의 기본 방식은 모든 URL 을 지원하면서 모든 HTTP 메서드를 허용합니다. 보통 GET, POST 방식으로 보내면 되는데요.

 

이 때 주의할 점은, 소셜 서비스에서 전송된 쿼리파라미터들이 URL 인코딩된 상태라서

GET방식으로 보낼때는 그냥 구글/네이버/카카오에서 보내준 값 그대로 보내주면 되고

POST 방식으로 보낼 때는 x-www-form-urlencoded 방식으로 보내면서, 각 파라미터들을 다시 url 디코딩한 후 보내줘야합니다. 저는 편의상 GET방식 기준으로 구현하겠습니다.

 

4. 소셜 로그인 결과를 바탕으로 후처리

저는 소셜로그인 API의 응답으로 액세스토큰, 리프레시 토큰을 JSON 에 담아 보내줄 것인데 브라우저에서는 이에 해당하는 후처리를 해주면 될 것 같아요. 토큰을 로컬스토리지와 같은 곳에 캐시해두거나 어디 저장한 뒤 사용자를 홈페이지로 리다이렉트 시킨다거나 하는 화면처리 등이 있을 것 같내요.


2. OpenID Connect 란?

이전 글들에서 Open ID connect 용어를 섞어서 글을 작성했는데

개념에 대해서 이야기를 말씀드린 적이 없고

 

이후 사용할 인증/인가 시 Open Id Connect 가 사용되기 때문에 글 서두에서

이번 글에서 간단하게 용어를 정리하고 진행하도록 하겠습니다.

 

 

보통의 OAuth2 흐름은 code를 기반으로 액세스토큰을 획득하고, 이 액세스토큰을 인가서버의 사용자 정보 엔드포인트에 요청하여 사용자 정보를 획득해오는 흐름으로 구성되어 있습니다.

 

 

그런데 만약 OAuth2 인가서버가 Open ID Connect 를 지원할 경우

토큰 발급 요청시 scope에 openid 를 포함해 보냈을 시 액세스토큰, 리프레시토큰과 함게 id token 이 함께 전달됩니다.

 

id token 은 그 자체로 인증을 위한 토큰이 아니라 사용자 정보를 담는 것을 목적으로 만들어진 토큰입니다.

보통 이 id token 은 JWT 로 만들어져있습니다.

 

 

실제 OAuth2 인가서버측에 토큰 요청을 보낼 때 id token 이 JWT 형태로 함께 오게되고

이 토큰을 파싱해보면 payload 쪽에 저의 신원에 관한 정보가 포함되어 있습니다.

 

sub 은 제 google의 고유 id 가 되고 email 은 저의 이메일 주소가 됩니다. 이런 정보가 jwt 내부에 포함되어 있습니다.

또 최초 요청 시 nonce 를 보냈었는데 이 nonce 도 그대로 담겨서 옵니다.


OPEN ID Connect 는 이처럼, OAuth2 인가 프로토콜에 기반하여, 사용자의 프로필 획득에 초점을 두고 인증, 사용자 정보획득 2단계의 작업을 1단계로 간소화시킨 기술입니다.

 

ID Token 이 올바르다고 판별만 할 수 있다면, 굳이 추가적으로 사용자 정보 획득 엔드포인트와 추가적으로 통신하지 않고 액세스토큰 발급시점에 함께 전송된 ID 토큰만으로 사용자 정보를 획득할 수 있습니다.

 

세계적으로 알려진 서비스들은 대부분 Open ID Connect 를 지원합니다. Google 역시 OpenId Connect를 지원합니다.

국내에서는 Kakao 가 Open Id Connect 를 지원합니다. 다만 Naver는 Open Id Connect를 지원하지 않습니다.

 

 

참고로 이 ID TOKEN의 JWT가 올바른지 검증하기 위해서는 JWK 가 필요합니다.

 

그런데 스프링 기준, 앞에서 OAuth2 Client  설정을 하는 과정에서 ClientRegistration의 jwkSetUri 가 따로 설정되어졌습니다. 이 정보를 사용해서 스프링은 인가서버의 jwkSetUri 위치한 JWK 를 함께 얻어와서, 토큰이 올바른지 검증할 수 있습니다.

 

혹시 JWT, JWK 용어가 낯설다면 이전에 제가 JWT 를 사용한 글을 참고하시면 될 것 같습니다.

 

이후 글에서는 Open Id Connect 를 짧게 부를 때 oidc 라고도 부르겠습니다.


3. OAuth2LoginAuthenticationFilter

실제 OAuth2 AuthorizationServer의 사용자를 식별하는 작업은 OAuth2LoginAuthenticationFilter를 통해 할 것인데요.

내부적으로 어떤 흐름으로 동작하는 지 먼저 기본 구현을 보겠습니다.

 

3.1 의존성, 클래스 상속 관계

public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";
	private static final String AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE = "authorization_request_not_found";
	private static final String CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE = "client_registration_not_found";
	private ClientRegistrationRepository clientRegistrationRepository;
	private OAuth2AuthorizedClientRepository authorizedClientRepository;
	private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository();
	private Converter<OAuth2LoginAuthenticationToken, OAuth2AuthenticationToken> authenticationResultConverter = this::createAuthenticationResult;
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
		implements ApplicationEventPublisherAware, MessageSourceAware {
	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
		.getContextHolderStrategy();
	protected ApplicationEventPublisher eventPublisher;
	protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
	private AuthenticationManager authenticationManager;
	protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
	private RememberMeServices rememberMeServices = new NullRememberMeServices();
	private RequestMatcher requiresAuthenticationRequestMatcher;
	private boolean continueChainBeforeSuccessfulAuthentication = false;
	private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
	private boolean allowSessionCreation = true;
	private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
	private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
	private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();

 

OAuth2LoginAuthenticationFilter 는 기본적으로 스프링의 추상골격 클래스인 AbstractAuthenticationProcessingFilter 를 상속하고 있습니다.

 

이 의존성들을 모두 확인하는 것도 좋긴한데, 지금은 소셜로그인 기능을 빠르게 만드는 것이 우선도가 높다보니 전체 필터 흐름을 확인해보다가 필요한 의존성만 그 때 그때 확인해서 보겠습니다.

 

(스프링이 기본으로 제공해주는 시큐리티 인증 필터 상당수는 AbstractAuthenticationProcessingFilter  를 상속하고 있다보니 이 필터는 한번 구조를 확인해두시면 도움이 많이 될 것입니다.)

 

3.2  AbstractAuthenticationProcessingFilter 흐름

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if (!requiresAuthentication(request, response)) {
			chain.doFilter(request, response);
			return;
		}
		try {
			Authentication authenticationResult = attemptAuthentication(request, response);
			if (authenticationResult == null) {
				// return immediately as subclass has indicated that it hasn't completed
				return;
			}
			this.sessionStrategy.onAuthentication(authenticationResult, request, response);
			// Authentication success
			if (this.continueChainBeforeSuccessfulAuthentication) {
				chain.doFilter(request, response);
			}
			successfulAuthentication(request, response, chain, authenticationResult);
		}
		catch (InternalAuthenticationServiceException failed) {
			this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
			unsuccessfulAuthentication(request, response, failed);
		}
		catch (AuthenticationException ex) {
			// Authentication failed
			unsuccessfulAuthentication(request, response, ex);
		}
	}

 

필터의 전체 골격은 부모 클래스인 AbstractAuthenticationProcessingFilter 에서 거의 다 작성되어 있습니다.

 

requireAuthentication?

내부적으로 의존하고 있는 RequestMatcher(요청 매칭기)를 통해 현재 요청이 필터에서 처리해야할 지 확인합니다.

처리할 URL 이 아니면  바로 필터를 통과시키고, 필터에서 처리할 대상이면 처리를 시작합니다.

 

attemptAuthentication

이 부분은 추상메서드인데, 하위의 필터에서 구현해야합니다.

요청을 기반으로 인증처리를 수행하고 그 인증 결과를 Authentication 사양으로 얻어와야합니다.

이 부분은 하위 클래스인 OAuth2LoginAuthenticationFilter 에서 구현되어 있으며, 아래에서 따로 다룰겁니다.

 

sessionStrategy.onAuthentication

인증이 발생했을 때, 세션 관련된 전략(세션 관련 처리를 담당)을 실행하는데요. 저는 세션설정을 사용하지 않도록 필터체인에서 설정해뒀기 때문에 이 부분은 관심사를 두지 않을 것입니다.

 

successfulAuthentication

인증된 성공 후속처리를 담당합니다. 이 부분은 아래에서 따로 다루겠습니다.

 

unsuccessfulAuthentication

인증 실패되어 인증 예외가 발생했을 때의 후속처리를 진행합니다. 이 부분도 아래에서 따로 다루겠습니다.

 

3.3 OAuth2LoginAuthenticationFilter.attemptAuthentication

이 메서드는 구현 코드가 약간 길어서 좀 나눠서 설명해보겠습니다.

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
		if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
			OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
			.removeAuthorizationRequest(request, response);
		if (authorizationRequest == null) {
			OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
		ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
		if (clientRegistration == null) {
			OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
					"Client Registration not found with Id: " + registrationId, null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		// @formatter:off
		String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
				.replaceQuery(null)
				.build()
				.toUriString();
		// @formatter:on
		OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
				redirectUri);
		Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
		OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
				new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
		authenticationRequest.setDetails(authenticationDetails);

 

이 메서드의 앞부분에서는 OAuth2AuthenticationRequest  작성이 일어나고 있습니다.

 

1. 사용자 요청의 파라미터 분석

먼저 사용자의 요청 파라미터들을 추출해서 MultiValueMap을 구성합니다.

이 과정에서 code, state 를 획득하지 못 했다면 예외가 발생합니다.

 

2. OAuth2AuthorizationRequest 조회 및 삭제

이전에 인가 서버의 인가엔드포인트 요청 과정에서 저희 서버에 사용자의 인가요청(OAuth2AuthorizationRequest) 정보를 저장해뒀었죠. 이것을 OAuth2AuthorizationRequestRepository 에서 조회해서 가져오고 삭제합니다. 

 

여기서 조회된 OAuth2AuthorizationRequest 가 없다면 예외가 발생합니다.

 

3. ClientRegistration 조회

위에서 얻어진 OAuth2AuthorizationRequest 를 정보를 기반으로

ClientRegistrationRepository 를 통해 ClientRegistration 을 조회해옵니다.

 

4. redirect_uri 구성

사용자가 이번 요청시 어떤 URL로 요청했는지 정보를 구성합니다.

이 값은 redirect_uri 로 변수명이 잡혀있습니다. 사실 이건 스프링 시큐리티 설계가 기본적으로 인가서버 응답이 프론트엔드를 거치지 않고 바로 백엔드로 오는 것을 염두해두고 설계되어 있기 때문인 것 같습니다.

그냥 현재 요청 시 어떤 URL로 접근했는지 정보를 구성한다고 생각하시면 될 듯 합니다.

 

5. OAuth2AuthorizationResponse 구성

위에서 만든 redirect_uri, 사용자 요청파라미터(state, code)를 종합하여 OAuth2AuthorizationResponse 를 구성합니다.

OAuth2 Authorizaton 서버 입장에서 생각해보면 인가 요청과 인가 응답이기 때문에 이런 이름을 붙인 것 같습니다.

 

이 OAuth2AuthorizationResponse 안에 그 외에 부가적인 정보들도 추가합니다.

 

6. OAuth2LoginAuthenticationToken 구성 (authenticationRequest)

위에서 얻어온 OAuth2AuthorizationRequest, OAuth2AuthorizationResponse 를 이용해 인증을 수행해야하는데

이를 위해 이 두개를 하나로 묶어서 OAuth2AuthorizationExchange 을 구성한 뒤

ClientRegistration 정보도 합쳐서 OAuth2LoginAuthenticationToken 을 구성합니다. 변수명은 authenticationRequest 로 되어 있습니다.

 

		OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
			.getAuthenticationManager()
			.authenticate(authenticationRequest);
		OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
			.convert(authenticationResult);
		Assert.notNull(oauth2Authentication, "authentication result cannot be null");
		oauth2Authentication.setDetails(authenticationDetails);
		OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(
				authenticationResult.getClientRegistration(), oauth2Authentication.getName(),
				authenticationResult.getAccessToken(), authenticationResult.getRefreshToken());

		this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response);
		return oauth2Authentication;
	}

 

7. authenticationManager.authenticate

내부적으로 의존하고 있는 AuthenticationManager를 통해 인증 처리를 위임합니다. 그리고 그결과를 OAuth2LoginAuthenticationToken 형태로 받아옵니다. 변수명은 authenticationResult 로 잡혀있네요.

 

8. 결과 변환 : authenticationResultConverter.convert

위에서 얻어온 결과를 OAuth2AuthenticationToken 으로 변환합니다. 변수명은 oauth2Authentication 으로 잡혀있습니다.

 

9. OAuth2AuthorizedClient 구성 및 저장

소셜 서비스 인증을 거치면서 해당 서비스의 액세스토큰, 리프레시 토큰을 얻어왔는데 이 정보를 따로 저장해서 관리하기 위해 OAuth2AuthorizedCleint 를 구성하고 있습니다.

 

그리고 이 값을 OAuth2AuthorizedClientRepository 에 저장하여 이후 꺼내서 쓸 수 있게 하고있습니다.

기본 구현체는 HttpSessionOAuth2AuthorizedClientRepository 인데요.

그런데 저는 이 값을 저장해서 관리할 필요가 없어서 따로 저장하지 않게 구현을 바꿀 예정입니다.

 

10. 최종 반환

oauth2Authentication (위에서 만들어진 OAuth2AuthenticationToken) 을 반환합니다.

 

3.4 AbstractAuthenticationProcessingFilter.successfulAuthentication

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
       Authentication authResult) throws IOException, ServletException {
    SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authResult);
    this.securityContextHolderStrategy.setContext(context);
    this.securityContextRepository.saveContext(context, request, response);
    if (this.logger.isDebugEnabled()) {
       this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
    }
    this.rememberMeServices.loginSuccess(request, response, authResult);
    if (this.eventPublisher != null) {
       this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
    }
    this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

 

인증 성공 후속처리는 AbstractAuthenticationProcessingFilter 쪽에서 진행됩니다.

인증 컨텍스트를 저장하는 SecurityContextHolder, SecurityContextRepository 저장이 여기서 이루어집니다.

 

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws ServletException, IOException {
		SavedRequest savedRequest = this.requestCache.getRequest(request, response);
		if (savedRequest == null) {
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		String targetUrlParameter = getTargetUrlParameter();
		if (isAlwaysUseDefaultTargetUrl()
				|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
			this.requestCache.removeRequest(request, response);
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		clearAuthenticationAttributes(request);
		// Use the DefaultSavedRequest URL
		String targetUrl = savedRequest.getRedirectUrl();
		getRedirectStrategy().sendRedirect(request, response, targetUrl);
	}

 

그 후 AuthenticationSuccessHandler를 호출해서 onAuthenticationSuccess 를 호출하는데요. 여기서 응답 메시지 작성이기본 구현체인 SavedRequestAwareAuthenticationSuccessHandler 를 통해 이루어집니다.

 

기존에 인증 실패했을 때 캐싱이 있다면 그 요청으로 리다이렉트 시키는 식으로 작동하는데요. 제가 만드는 스프링 서버는 웹 페이지를 제공하는 서버가 아니라, API를 제공하는 서버이므로 이 방식은 제가 원하는 방식이 아니

 

이 부분에서 저는 AuthenticationSuccessHandler.onAuthenticationSuccess 부분을 커스터마이징 할 예정이에요.

 

3.5 AbstractAuthenticationProcessingFilter.unsuccessfulAuthentication

protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
       AuthenticationException failed) throws IOException, ServletException {
    this.securityContextHolderStrategy.clearContext();
    this.logger.trace("Failed to process authentication request", failed);
    this.logger.trace("Cleared SecurityContextHolder");
    this.logger.trace("Handling authentication failure");
    this.rememberMeServices.loginFail(request, response);
    this.failureHandler.onAuthenticationFailure(request, response, failed);
}

 

인증 실패 후속처리는 AbstractAuthenticationProcessingFilter 쪽에서 진행됩니다.

AuthenticationFailureHandler 를 호출하여 인증실패 후속처리를 위임하고 있습니다.

 

그런데 이전 글에서 커스텀하게 구현해둔 AuthenticationFailureHandler가 있으므로 이것을 재사용하면 될 것 같아요.

 

 

전반적인 OAuth2LoginAuthenticationFilter의 흐름입니다.

내부적으로 AuthenticationManager 를 통해 소셜서비스의 사용자 정보, 신원을 획득하는 것까지 하고

후속 성공처리, 실패처리를 합니다.

 

그런데 이 후속 성공처리/실패처리를 놓고보면

성공처리는 우리 서비스에의 회원가입/로그인 처리가 되지 않고 있고

후속처리도 예외 응답 API가 나가지 않는 한계가 있습니다.


4. AuthenticationManager

실질적 인증 처리는 결국 AuthenticationManager를 통해 이루어지는 것을 볼 수 있는데, 이 부분 코드를 봐보겠습니다.

 

4.1 AuthenticationManager(ProviderManager), AuthenticationProvider

public interface AuthenticationManager {
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

 

AuthenticationManager 는 어떤 인증되지 않은 Authentication 을 받아서, 인증하고 그 결과를 인증된 Authentication 으로 반환하는 역할을 담당합니다. 이 과정에서 실패하면 AuthenticationException 을 반환할 책임을 가집니다.

 

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

	private AuthenticationEventPublisher eventPublisher = new NullEventPublisher();
	private List<AuthenticationProvider> providers = Collections.emptyList();
	protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
	private AuthenticationManager parent;
	private boolean eraseCredentialsAfterAuthentication = true;

 

그런데 AuthenticationManager 는 기본 구현체가 ProviderManager 라는 클래스입니다. 사실 스프링 시큐리티 대부분에서 사용되는 AuthenticationManager 의 기본 구현체는 ProviderManager 라서, AuthenticationManager 는 ProviderManager라고 기억해도 될 것 같아요.

 

ProviderManager 는

내부적으로 AuthenticationProvider 라는 여러가지 인증 제공자를 목록으로 가지고 있고

또다른 AuthenticationManager 를 parent 변수에 가지고 있습니다.

 

public interface AuthenticationProvider {
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
	boolean supports(Class<?> authentication);
}

 

AuthenticationProvider는 인증을 실질적으로 처리하는 처리자인데

 

어떤 Authentication 클래스 정보가 전달됐을 때 자신이 처리할 수 있는지를 확인할 수 있어야(supprots)하고

그 Authentication이 전달됐을 때 인증 처리를 수행해야합니다.

 

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		if (result == null && this.parent != null) {
			// Allow the parent to try.
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
                
                // 생략

 

 

ProviderManager는

 

자신이 가진 AuthenticationProvider 목록을 순회하면서 가장 먼저 매칭(supports)되는 AuthenticationProvider 를 찾고,

그쪽에 인증 처리(authenticate)를 위임합니다.

 

이때 AuthenticationProvider 가 반환한 결과가 null 이 아니면 그 결과를 그대로 반환하고

AuthenticationProvider 가 반환한 결과가 null 이면 다음 AuthenticationProvider를 호출하여 위의 과정을 반복합니다.

 

이렇게 해도 모든 AuthenticationProvider 가 인증을 하지 못 했다면 parent로 지정해둔 AuthenticationManager 를 호출하여 인증처리를 위임합니다. 이렇게 다 했음해도 인증되지 못 했다면 예외가 발생합니다.

 

 

실제 인증 흐름쪽에 디버거를 걸어서 실행해보면 AuthenticationManager 역할에

ProviderManager 가 위치해있고

ProviderManager 는 여러 개의 AuthenticationProvider 목록을 가지고 있으며

 

이 안에는

OAuth2LoginAuthenticationAuthenticationProvider, OidcAuthorizationCodeAuthenticationProvider가 순서를 가지고 위치해 있습니다. 이 둘이 이런 순서로 되어 있는건 의도적인 설계인데요. 뒷 부분을 보겠습니다.

 

4.2 OAuth2LoginAuthenticationAuthenticationProvider

public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
	private final OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider;
	private final OAuth2UserService<OAuth2UserRequest, OAuth2User> userService;
	private GrantedAuthoritiesMapper authoritiesMapper = ((authorities) -> authorities);

 

OAuth2LoginAuthenticationProvider 는 OAuth2 사용자 인증이 들어왔을 때, 사용자를 인증하고 사용자 정보를 획득해 온 뒤 응답하는 AuthenticationProvider 입니다.

 

	@Override
	public boolean supports(Class<?> authentication) {
		return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication);
	}

 

실제 작동 조건이 Authentication 이 OAuth2LoginAuthenticationToken 일 때입니다.

 

	@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;
		}

 

 

실제 authenticate 구현 메서드를 보면 첫 부분에 특이한 부분이 있는데요.

OAuth2LoginAuthenticationToken 안에 위치한 OAuth2AuthorizationRequest 의 scope 목록에 "openid"가 있으면 이 AuthenticationProvider 가 처리하지 않고 null 을 반환하게 합니다.

 

ProviderManager 흐름속에서 AuthenticationProvider 가 null 을 반환하면 그 AuthenticationProvider 가 처리하지 않도록 되어 있죠.

 

다음에 위치한 AuthenticationProvider로 인증 처리를 넘깁니다.

근데 이 다음에 위치한 AuthenticationProvider 가 AuthenticationProvider 가 OidcAuthorizationCodeAuthenticationProvider 이고 뒤에서 보시면 아시겠지만 OidcAuthorizationCodeAuthenticationProvider 역시 Authentication 이 OAuth2LoginAuthenticationToken  일때 작동하도록 되어 있습니다.

 

Oidc 가 아닌 인증처리(제 서비스 기준으로는 naver)는 OAuth2LoginAuthenticationAuthenticationProvider 에서 진행되고

Oidc 인증처리(제 서비스 기준으로는 google, kakao) 는 OidcAuthorizationCodeAuthenticationProvider 에서 진행된다고 보면 됩니다.

 

		OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
		try {
			authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
				.authenticate(
						new OAuth2AuthorizationCodeAuthenticationToken(loginAuthenticationToken.getClientRegistration(),
								loginAuthenticationToken.getAuthorizationExchange()));
		}
		catch (OAuth2AuthorizationException ex) {
			OAuth2Error oauth2Error = ex.getError();
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
		}
		OAuth2AccessToken accessToken = authorizationCodeAuthenticationToken.getAccessToken();
		Map<String, Object> additionalParameters = authorizationCodeAuthenticationToken.getAdditionalParameters();
		OAuth2User oauth2User = this.userService.loadUser(new OAuth2UserRequest(
				loginAuthenticationToken.getClientRegistration(), accessToken, additionalParameters));
		Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
			.mapAuthorities(oauth2User.getAuthorities());
		OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
				loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
				oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
		authenticationResult.setDetails(loginAuthenticationToken.getDetails());
		return authenticationResult;
	}

 

먼저 oidc(open id connect 방식) 처리는 null 반환을 통해 다음 위치한 OidcAuthorizationCodeAuthenticationProvider 가 처리하도록 넘겨버리고

여기서부터는 정말 OAuth2 인증이 진행됩니다.

 

 

 

1. 소셜 서비스 인증(HTTP 통신)

OAuth2LoginAuthenticationProvider 는 내부적으로 가지고 있는 OAuth2AuthorizationCodeAuthenticationProvider 를 통해 OAuth2 Authorization Server(소셜서비스의 인가서버) 측 인증 처리를 위임합니다. (여기서 client_id, client_secret, code, code_verifier 등을 함께 전달하도록 작동합니다.)

 

OAuth2AuthorizationCodeAuthenticationProvider 는 내부적으로 AuthorizationCodeTokenResponseClient 를 통해 실제 인가서버와 HTTP 통신을 통해 액세스토큰 발급 등의 처리를 수행하고 사용자를 인증합니다.

 

이렇게 하면 Access Token, Refresh Token 이 담긴 OAuth2AuthorizationCodeAuthenticationToken 을 얻어올 수 있습니다.

 

2. 사용자 정보 획득(OAuth2UserService)

그리고 이렇게 받은 OAuth2AuthorizationCodeAuthenticationToken 을 토대로 OAuth2UserService 를 통해 OAuth2 서비스측의 사용자 정보가 담긴 OAuth2User 를 얻어옵니다. 이 과정에서는 방금 받아온 액세스토큰을 가져다가 OAuth2 서비스 측의 사용자 정보 엔드포인트와 HTTP 통신을 해서 사용자 정보를 가져오는 작업이 수행돼요.

 

3. 결과 생성 및 반환

위에서 얻어온 사용자 정보, 액세스토큰 등을 묶어서 OAuth2LoginAuthenticationToken 으로 만들어서 반환합니다.

 

4.3 OidcAuthorizationCodeAuthenticationProvider

	@Override
	public boolean supports(Class<?> authentication) {
		return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication);
	}

 

OidcAuthorizationCodeAuthenticationProvider 의 작동조건은 Authentication 이 OAuth2LoginAuthentication 일 때입니다.

 

앞에 위치해있던 OAuth2LoginAuthenticationAuthenticationProvider 에서, null 이 반환된 경우 이곳에 오게 되는데 매칭되므로 oidc 방식의 OAuth2 인증은 여기서 이루어집니다.

 

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		OAuth2LoginAuthenticationToken authorizationCodeAuthentication = (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 (!authorizationCodeAuthentication.getAuthorizationExchange()
			.getAuthorizationRequest()
			.getScopes()
			.contains(OidcScopes.OPENID)) {
			// This is NOT an OpenID Connect Authentication Request so return null
			// and let OAuth2LoginAuthenticationProvider handle it instead
			return null;
		}

 

실제 이 AuthenticationProvider 는 scope에 openid 가 없으면 작동되지 않도록 되어 있습니다.

scope에 openid가 포함되어 있을 때 작동합니다.

 

		OAuth2AuthorizationRequest authorizationRequest = authorizationCodeAuthentication.getAuthorizationExchange()
			.getAuthorizationRequest();
		OAuth2AuthorizationResponse authorizationResponse = authorizationCodeAuthentication.getAuthorizationExchange()
			.getAuthorizationResponse();
		if (authorizationResponse.statusError()) {
			throw new OAuth2AuthenticationException(authorizationResponse.getError(),
					authorizationResponse.getError().toString());
		}
		if (!authorizationResponse.getState().equals(authorizationRequest.getState())) {
			OAuth2Error oauth2Error = new OAuth2Error(INVALID_STATE_PARAMETER_ERROR_CODE);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		OAuth2AccessTokenResponse accessTokenResponse = getResponse(authorizationCodeAuthentication);
		ClientRegistration clientRegistration = authorizationCodeAuthentication.getClientRegistration();
		Map<String, Object> additionalParameters = accessTokenResponse.getAdditionalParameters();
		if (!additionalParameters.containsKey(OidcParameterNames.ID_TOKEN)) {
			OAuth2Error invalidIdTokenError = new OAuth2Error(INVALID_ID_TOKEN_ERROR_CODE,
					"Missing (required) ID Token in Token Response for Client Registration: "
							+ clientRegistration.getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(invalidIdTokenError, invalidIdTokenError.toString());
		}
		OidcIdToken idToken = createOidcToken(clientRegistration, accessTokenResponse);
		validateNonce(authorizationRequest, idToken);
		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;
	}

 

 

 

1. 소셜 서비스 인증(HTTP 통신) => 액세스토큰, Id 토큰 획득

OidcAuthorizationCodeAuthenticationProvider 는 먼저 OAuth2 Authorization Server(소셜서비스의 인가서버) 측의 토큰 엔드포인트와 통신 처리를 AuthorizationCodeTokenResponseClient 에 위임합니다. (여기서 client_id, client_secret, code, code_verifier 등을 함께 전달하도록 작동합니다.)

 

이 과정을 거치면서 이렇게 하면 Access Token, Refresh Token 등의 정보가 담긴 OAuth2AccessTokenResponse 를 얻어올 수 있습니다. 그런데 이 뿐만 아니라 scope에 openid가 포함되어있어서, Id Token 도 함께 얻어와집니다.

 

 

2. ID 토큰  구성 및 검증

id 토큰은 위에서 언급했듯, JWT로 구성했습니다.

id 토큰값을 분석, 검증하여 OidcToken 형태로 재구성합니다. 이 과정에서 id 토큰이 올바른 토큰인지에 대한 검증이 추가적으로 이루어집니다. 최초에 ClientRegistrationRepository 구성 작업 과정에서 인가서버의 jwkSet Uri 도 얻어와졌는데, 이 과정에서 실제 Authorization Server 의 jwk 를 참고하여 id 토큰이 올바른지에 대한 검증도 함께 이루어집니다.

 

또, id 토큰을 분석하여 우리가 최초 요청시 보낸 nonce 값이 그대로 잘 왔는 지도 검증합니다.

 

3. 사용자 정보(OidcUser) 획득

OidcUserService 를 통해 OAuth2 서비스 측의 사용자 정보를 획득해옵니다. 이것은 OidcUser 사양입니다.

이 OidcUserService의 기본 구현체는 DefaultOidcUserService 이고 여기서 얻어지는 OidcUser 의 기본 구현체는 DefaultOidcUser 입니다.

 

 

4. 결과 구성 및 반환

인증 결과를 담아서 OAuth2LoginAuthenticationToken 으로 만들어 반환합니다.

 


5. OAuth2User, OidcUser

public interface OAuth2User extends OAuth2AuthenticatedPrincipal {

}

 

위에서 OAuth2UserService에서

OAuth2 인증서버와 통신을 통해 얻어진 사용자의 정보는 OAuth2User 사양으로 얻어집니다.

 

public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {

	@Nullable
	@SuppressWarnings("unchecked")
	default <A> A getAttribute(String name) {
		return (A) getAttributes().get(name);
	}
	Map<String, Object> getAttributes();
	Collection<? extends GrantedAuthority> getAuthorities();

}

 

OAuth2User의 부모인 Oauth2AuthenticatedPrincipal 에서는

 

이 우리 서비스가 사용자에 대신해서 얻은 oauth2 서비스 측 권한에 해당하는 authorities

OAuth2 사용자의 여러가지 속성들이 담긴 attributes 를 얻을 수 있는 getter 메서드가 있으며

이를 통해 사용자 정보를 획득할 수 있습니다.

 

public interface OidcUser extends OAuth2User, IdTokenClaimAccessor {

	@Override
	Map<String, Object> getClaims();

	OidcUserInfo getUserInfo();

	OidcIdToken getIdToken();

}

 

Oidc방식 인증을 거칠때 OidcUserService 를 통해 OidcUser를 얻었었는데요.

사실 OidcUser 는 OAuth2User 의 하위 인터페이스입니다.

 

Oidc 를 통해 추가적으로 얻은 정보를 획득할 수 있는 역할이 추가적으로 정의되어 있습니다.

 

public class DefaultOAuth2User implements OAuth2User, Serializable {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private final Set<GrantedAuthority> authorities;

	private final Map<String, Object> attributes;

	private final String nameAttributeKey;

	public DefaultOAuth2User(Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes,
			String nameAttributeKey) {
		Assert.notEmpty(attributes, "attributes cannot be empty");
		Assert.hasText(nameAttributeKey, "nameAttributeKey cannot be empty");
		if (!attributes.containsKey(nameAttributeKey)) {
			throw new IllegalArgumentException("Missing attribute '" + nameAttributeKey + "' in attributes");
		}
		this.authorities = (authorities != null)
				? Collections.unmodifiableSet(new LinkedHashSet<>(this.sortAuthorities(authorities)))
				: Collections.unmodifiableSet(new LinkedHashSet<>(AuthorityUtils.NO_AUTHORITIES));
		this.attributes = Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
		this.nameAttributeKey = nameAttributeKey;
	}

	@Override
	public String getName() {
		return this.getAttribute(this.nameAttributeKey).toString();
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return this.authorities;
	}

	@Override
	public Map<String, Object> getAttributes() {
		return this.attributes;
	}

 

OAuth2User 의 기본 구현체는 DefaultOAuth2User 인데요.

이것은 OAuth2UserService의 기본 구현체인 DefaultOAuth2UserService 를 통해 얻어집니다.

내부적으로 attributes, authorities 등이 있고 이를 통해 OAuth2 서비스 측의 사용자 정보를 획득할 수 있어요.

 

public class DefaultOidcUser extends DefaultOAuth2User implements OidcUser {

	private final OidcIdToken idToken;

	private final OidcUserInfo userInfo;

	/**
	 * Constructs a {@code DefaultOidcUser} using the provided parameters.
	 * @param authorities the authorities granted to the user
	 * @param idToken the {@link OidcIdToken ID Token} containing claims about the user
	 */
	public DefaultOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken) {
		this(authorities, idToken, IdTokenClaimNames.SUB);
	}

 

OidcUser 의 기본 구현체는 DefaultOidcUser 입니다.

이것은 OidcUserService의 기본 구현체인 DefaultOidcUserService를 통해 얻어집니다.

이 클래스는 DefaultOAuth2User 를 상속하면서 OidcUser를 구현하고 있습니다.

 

 

 

결국 여기까지의 흐름을 정리하면 이렇습니다.

 

ProviderManager 는 내부적으로 여러개의 AuthenticationProvider 를 목록으로 가지고 있고

 

기본 OAuth2 인증 처리는 OAuth2LoginAuthenticationProvider 가 담당하며

Oidc 인증 처리는 OidcAuthorizationCodeAuthenticationProvider 가 담당합니다.

 

OAuth2LoginAuthenticationProvider는 내부적으로 OAuth2AuthorizationCodeAuthenticationProvider를 갖고 있습니다.

여기서 내부적으로 AuthorizationCodeTokenResponseClient 를 통해 인가서버의 토큰 엔드포인트와 통신하여 액세스토큰을 발급받아옵니다.

그리고 여기서 얻어온 액세스토큰 정보를 기반으로 DefaultOAuth2UserService 를 통해 인가서버의 사용자 정보 엔드포인트와 통신하여 사용자 정보를 획득하고 DefaultOAuth2User 형태로 구성합니다.

 

OidcAuthorizationCodeAuthenticationProvider 는 내부적으로 AuthorizationCodeTokenResponseClient 를 갖고 있고 이를 통해 인가서버의 토큰 엔드포인트와 통신하여 액세스토큰을 받아옵니다. 그런데 이 때 ID 토큰도 함께 받아와집니다.

여기서 받아진 액세스토큰, ID 토큰 정보를 기반으로 DefaultOidcUserService를 통해 사용자 정보를 획득하고 DefaultOidcUser 를 구성합니다.

 

public class OAuth2LoginAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private OAuth2User principal;

	private ClientRegistration clientRegistration;

	private OAuth2AuthorizationExchange authorizationExchange;

	private OAuth2AccessToken accessToken;

	private OAuth2RefreshToken refreshToken;

 

이렇게 얻어진 사용자 정보를 기반으로 인증 결과인 OAuth2LoginAuthenticationToken 을 만들어 호출한 측으로 반환합니다.

 

public class OAuth2AuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final OAuth2User principal;

    private final String authorizedClientRegistrationId;

 

그리고 이 OAuth2LoginAuthenticationToken은 다시 Converter를 거쳐서 OAuth2AuthenticationToken 으로 변환되는데 이 내부에는 OAuth2User 사양으로 principal 필드가 있습니다.

 

이를 통해 나중에 Authentication 에서 principal 을 통해 사용자 정보를 획득할 수 있습니다.

 


7. 스프링 시큐리티 OAuth2LoginAuthenticationFilter 기본구현의 한계

 

7.1 성공처리의 한계 : 우리 서비스에의 실질적 회원가입/로그인이 되지 않는다.

 

이렇게 위의 과정만 놓고보면 스프링 시큐리티는 소셜 서비스의 인가서버측의 사용자 정보를 획득하는 것까지는 잘 수행해줬습니다.

 

하지만 이 사용자 정보를 기반으로 우리서비스 측 로그인 처리 등은 제대로 수행할 수 없습니다.

 

소셜서비스의 사용자 정보를 기반으로 이 사용자가 우리 서비스의 누구인지 알아내서 로그인처리하거나

우리 서비스에 신규가입한 다음 로그인처리하도록 하고 싶습니다.

 

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws ServletException, IOException {
		SavedRequest savedRequest = this.requestCache.getRequest(request, response);
		if (savedRequest == null) {
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		String targetUrlParameter = getTargetUrlParameter();
		if (isAlwaysUseDefaultTargetUrl()
				|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
			this.requestCache.removeRequest(request, response);
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		clearAuthenticationAttributes(request);
		// Use the DefaultSavedRequest URL
		String targetUrl = savedRequest.getRedirectUrl();
		getRedirectStrategy().sendRedirect(request, response, targetUrl);
	}

 

하지만 기본 구현 방식으로는 이런 작업이 수행되지 않습니다.

성공처리를 담당하는 AuthenticationSuccessHandler 의 처리를 수정할 필요가 있습니다.

 

7.2 커스텀 AuthenticationSuccessHandler 작성시도

class CustomOAuth2LoginAuthenticationSuccessHandler : AuthenticationSuccessHandler {


    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication
    ) {
        val principal = authentication.principal as OAuth2User

 

AuthenticationSuccessHandler 의 커스텀 구현체로, CustomOAuth2LoginAuthenticationSuccessHandler를 작성하고 여기서 소셜로그인 처리를 진행해야합니다.

 

 

일단 Authentication 자리에는 OAuth2Authentication 이 있을테니 여기서 principal 을 꺼내면 해당 클래스는 OAuth2User의 구현체일겁니다. OAuth2 방식일 때는 DefaultOAuth2User 가 올 것이고 Oidc 방식일 경우 DefaultOidcUser 가 오겠죠.

 

public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {


	@Nullable
	@SuppressWarnings("unchecked")
	default <A> A getAttribute(String name) {
		return (A) getAttributes().get(name);
	}


	Map<String, Object> getAttributes();


	Collection<? extends GrantedAuthority> getAuthorities();

}

 

 

문제는 사용자를 소셜로그인 처리할 때 저희가 필요한 정보를 가져오기 OAuth2User 사양으로는 불편하다는 점입니다.

소셜로그인/회원가입을 위해서는

 

사용자가 어느 소셜서비스의 회원인지

사용자의 고유 식별자가 무엇인지

사용자의 이메일일 무엇인지

이 3가지의 정보가 필요한데요.

 

구글, 네이버, 카카오는 API 응답 사양이나 인증 방식이 각기 다르고 사용자 정보가 저장된 필드가 각각 다른 문제가 있습니다. 그리고 OAuth2User 사양 그 자체로는 사용자 정보를 획득하려면 getAttribute 메서드를 통해 사용자 정보가 담긴 Map 을 얻어오고 여기서 특정 필드를 각 서비스마다 맞게 꺼내야합니다.

 

또, OAuth2User 사양에는 사용자가 어느 소셜서비스의 회원인지에 해당하는 정보가 포함되어 있지 않다보니 이 정보도 추가해야합니다.

 

OAuth2User를 우리가 원하는 사양으로 가져와서 처리할 수 있게 하려면 OAuth2User, OidcUser 의 구현체를 변경하고OAuth2UserService, OidcUserService 구현체 역시 약간 변경할 필요가 있습니다.


6. 커스텀 OAuth2User, OidcUser

저는 사용자의 정보를 OAuth2User 사양으로 갖고왔을 때, 편리하게 사용자 정보를 획득할 필요성을 느꼈고

이를 위해 OAuth2User, OidcUser 의 구현체 사양부터 손보기로 했습니다.

 

6.1 SocialServiceUserPrincipal

package com.ttasjwi.board.system.external.spring.security.oauth2

interface SocialServiceUserPrincipal {
    fun socialServiceName(): String
    fun socialServiceUserId(): String
    fun email(): String
}

 

소셜 서비스 인증객체에서, 제가 필요한 정보는

소셜서비스 이름, 소셜서비스측 사용자 아이디, 이메일입니다.

 

이를 일관되게 가져올 수 있도록 SocialServiceUserPrincipal 을 정의했습니다.

 

6.2 CustomOAuth2User

internal abstract class CustomOAuth2User
protected constructor(
    socialServiceName: String,
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : OAuth2User, SocialServiceUserPrincipal {

    private val _socialServiceName: String = socialServiceName
    private val _authorities = authorities
    private val _attributes = attributes

    override fun socialServiceName(): String {
        return _socialServiceName
    }

    companion object {

        private val oauth2UserFactory: Map<String, (MutableCollection<out GrantedAuthority>, MutableMap<String, Any>) -> CustomOAuth2User> = mapOf(
            "google" to ::GoogleOAuth2User,
            "kakao" to ::KakaoOAuth2User,
            "naver" to ::NaverOAuth2User
        )

        fun from(socialServiceName: String, oauth2User: OAuth2User): CustomOAuth2User {
            return oauth2UserFactory[socialServiceName]?.invoke(oauth2User.authorities, oauth2User.attributes)
                ?: throw IllegalStateException("지원되지 않는 oauth2 서비스 제공자: $socialServiceName")
        }
    }

    override fun getName(): String {
        return socialServiceUserId()
    }

    override fun getAttributes(): MutableMap<String, Any> {
        return _attributes
    }

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        return _authorities
    }
}

 

먼저 OAuth2 방식으로 사용자를 갖고왔을 때는 CustomOAuth2User 형태로 만들거에요.

OAuth2User 를 구현하면서, SocialServiceUserPrincipal 을 구현합니다.

 

생성시 from 메서드를 통해 서비스 이름과 OAuth2User 를 전달받아서 구체 클래스를 생성하도록 할거에요.

internal class GoogleOAuth2User(
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : CustomOAuth2User(
    socialServiceName = "google",
    authorities = authorities,
    attributes = attributes,
) {

    override fun socialServiceUserId(): String {
        return attributes["sub"] as String
    }

    override fun email(): String {
        return attributes["email"] as String
    }
}

internal class KakaoOAuth2User(
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : CustomOAuth2User(
    socialServiceName = "kakao",
    authorities = authorities,
    attributes = attributes,
) {

    override fun socialServiceUserId(): String {
        return attributes["id"] as String
    }

    override fun email(): String {
        return (attributes["kakao_account"] as Map<*, *>)["email"] as String
    }
}

internal class NaverOAuth2User(
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : CustomOAuth2User(
    socialServiceName = "naver",
    authorities = authorities,
    attributes = attributes,
) {

    override fun socialServiceUserId(): String {
        return (attributes["response"] as Map<*, *>)["id"] as String
    }

    override fun email(): String {
        return (attributes["response"] as Map<*, *>)["email"] as String
    }
}

 

구현체 GoogleOAuth2User, KakaoOAuth2User, NaverOAuth2User 입니다.

 

구글의 사용자 엔드포인트에서 사용자 정보를 가져올 때는 sub, email 필드에서 꺼내와야하고

카카오의 사용자 엔드포인트에서 사용자 정보를 가져올 때는 id, kakao_account.email 필드에서 꺼내와야하고,

네이버의 사용자 엔드포인트에서 사용자 정보를 가져올 때는 response.id, response.email 필드에서 꺼내와야하는데요.

 

이런 방식을 AuthenticationSuccessHandler 에서 분기처리하는 것은 설계상 불편점이 많기 때문에

socialServiceUserId(), email() 메서드를 통해 획득 방법을 내부에 추상화시킨겁니다.

가져오는 측에서는 그냥 socialService(), email() 을 통해 가져오기만 하면 되는거죠.

 

(+ 사실 여기서 OAuth2 방식으로 회원정보를 갖고 오는건 Naver 뿐이므로 사실 CustomOAuth2User 사양은 Naver 만 만들어주면 되긴합니다. 저는 예시를 들기 위해 세가지 모두 OAuth2 방식으로 구현을 해봤어요.)

 

6.3 CustomOidcUser

internal abstract class CustomOidcUser
protected constructor(
    socialServiceName: String,
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : OidcUser, SocialServiceUserPrincipal {

    private val _socialServiceName = socialServiceName
    private val _authorities = authorities
    private val _attributes = attributes

    override fun socialServiceName(): String {
        return _socialServiceName
    }

    companion object {


        private val oidcUserFactory: Map<String, (MutableCollection<out GrantedAuthority>, MutableMap<String, Any>) -> CustomOidcUser> =
            mapOf(
                "google" to ::GoogleOidcUser,
                "kakao" to ::KakaoOidcUser,
            )

        fun from(socialServiceName: String, oidcUser: OidcUser): CustomOidcUser {
            return oidcUserFactory[socialServiceName]?.invoke(oidcUser.authorities, oidcUser.attributes)
                ?: throw IllegalStateException("지원되지 않는 oidc 서비스 제공자: $socialServiceName")
        }
    }

    override fun getName(): String {
        return socialServiceUserId()
    }

    override fun getAttributes(): MutableMap<String, Any> {
        return _attributes
    }

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
        return _authorities
    }

    override fun getIdToken(): OidcIdToken {
        throw UnsupportedOperationException("This method should not be called")
    }

    override fun getClaims(): MutableMap<String, Any> {
        throw UnsupportedOperationException("This method should not be called")
    }

    override fun getUserInfo(): OidcUserInfo {
        throw UnsupportedOperationException("This method should not be called")
    }
}

 

OidcUser 사양도 커스텀하게 만들기 위해 CustomOidcUser 사양도 정의했습니다.

OidcUser 를 구현하면서, SocialServiceUserPrincipal 을 구현합니다.

 

internal class GoogleOidcUser
internal constructor(
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : CustomOidcUser(
    socialServiceName = "google",
    authorities = authorities,
    attributes = attributes,
) {

    override fun socialServiceUserId(): String {
        return attributes["sub"] as String
    }

    override fun email(): String {
        return attributes["email"] as String
    }
}

internal class KakaoOidcUser
internal constructor(
    authorities: MutableCollection<out GrantedAuthority>,
    attributes: MutableMap<String, Any>,
) : CustomOidcUser(
    socialServiceName = "kakao",
    authorities = authorities,
    attributes = attributes,
) {

    override fun socialServiceUserId(): String {
        return attributes["sub"] as String
    }

    override fun email(): String {
        return attributes["email"] as String
    }
}

 

구현체 GoogleOidcUser, KakaoOidcUser 입니다.

 

구글의 Id 토큰으로부터 사용자 정보를 가져올 때는 sub, email 필드에서 꺼내와야하고

카카오의 Id 토큰으로부터 사용자 정보를 가져올 때는 sub, email 필드에서 꺼내와야하는데

 

이를 socialServiceUserId(), email() 로 획득하도록 추상화시킵니다.

 

 

이렇게 만들어진 클래스 관계입니다. 실질적인 구현체들은 NaverOAuth2User, GoogleOidcUser, KakaoOidcUser인데

이들은 모두 최종 부모에 SocialServiceUserPrincipal 이 있습니다.

 

OAuth2Authentication 내부의 principal 필드에는 OAuth2User 사양이 들어와야한다는 제약사항을 지키면서도,

모두 SocialServiceUserPrincipal 로 업캐스팅할 수 있습니다.

 

또 OAuth2UserService에서는 OAuth2User 형태로 반환해야하고

OidcUserService 에서는 OidcUser 형태로 반환해야하는데 이 계약 역시 준수할 수 있습니다.


7. CustomOAuth2UserService, CustomOidcUserService

7.1 CustomOAuth2UserService

class CustomOAuth2UserService(
    private val delegate: OAuth2UserService<OAuth2UserRequest, OAuth2User>
) : OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
        val clientRegistration = userRequest.clientRegistration
        val oauth2User = delegate.loadUser(userRequest)

        return CustomOAuth2User.from(
            socialServiceName = clientRegistration.registrationId,
            oauth2User = oauth2User
        )
    }
}

 

커스텀 OAuth2UserService 입니다.

내부적으로 다른 OAuth2UserService를 의존하고 있고 이곳을 통해 OAuth2User 획득을 위임합니다. 

그리고 위에서 얻은 OAuth2User 정보와, ClientRegistration 정보를 합해서 CustomOAuth2User 를 생성하고 반환합니다.

 

이렇게 하면 OAuth2User 사양을 반환해야한다는 제약을 지킬 수 있습니다.

7.2 CustomOidcUserService

class CustomOidcUserService(
    private val delegate: OidcUserService
) : OidcUserService() {

    override fun loadUser(userRequest: OidcUserRequest): OidcUser {
        val clientRegistration = userRequest.clientRegistration
        val oidcUser = delegate.loadUser(userRequest)

        return CustomOidcUser.from(
            socialServiceName = clientRegistration.registrationId,
            oidcUser = oidcUser
        )
    }
}

 

커스텀 OidcUserService 입니다.

내부적으로 다른 OidcUserService 를 의존하고 있고 이곳을 통해 OidcUser 획득을 위임합니다. 

그리고 위에서 얻은 OidcUser 정보와, ClientRegistration 정보를 합해서 CustomOidcUser 를 생성하고 반환합니다.

 

이렇게 하면 OidcUser 사양을 반환해야한다는 제약을 지킬 수 있습니다.

 

7.3 설정

package com.ttasjwi.board.system.core.config

@Configuration
class FilterChainConfig(
	// 생략
) {
	// 생략


    private fun customOAuth2UserService(): OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        return CustomOAuth2UserService(DefaultOAuth2UserService())
    }

    private fun customOidcUserService(): OidcUserService {
        return CustomOidcUserService(OidcUserService())
    }
}

 

실제 생성하는 부분 코드는 위와 같습니다.

내부에서 필요로하는 OAuth2UserService 는 DefaultOAuth2UserService , DefaultOidcUserService 를 주입했습니다.

 

 

이렇게 하면 이후 인증 객체인 OAuth2Authentication 의 principal 자리에는 OAuth2User 사양을 지키면서, 저희가 정의한 SocialServicePrincipal 사양을 준수하는 객체가 들어가게 됩니다.

 

class CustomOAuth2LoginAuthenticationSuccessHandler(

) : AuthenticationSuccessHandler {


    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication
    ) {
        val socialServiceUserPrincipal = authentication.principal as SocialServiceUserPrincipal

 

이제 커스텀 OAuth2LoginAuthenticationSuccessHandler 쪽에서는 사용자 정보를 SocialServiceUserPrincipal로

업캐스팅할 수 있게 됐습니다.


8. 소셜로그인 유즈케이스

 

인증 애플리케이션 모듈쪽에 SocialLoginUseCase 를 정의했습니다.

 

package com.ttasjwi.board.system.auth.application.usecase

import java.time.ZonedDateTime

interface SocialLoginUseCase {

    /**
     * 소셜 연동 정보를 기반으로 액세스토큰, 리프레시 토큰을 얻어옵니다.
     * 만약 소셜 연동에 해당하는 회원이 없으면 회원을 생성합니다.
     */
    fun socialLogin(request: SocialLoginRequest): SocialLoginResult
}

data class SocialLoginRequest(
    val socialServiceName: String,
    val socialServiceUserId: String,
    val email: String,
)

data class SocialLoginResult(
    val accessToken: String,
    val accessTokenExpiresAt: ZonedDateTime,
    val refreshToken: String,
    val refreshTokenExpiresAt: ZonedDateTime,
    val memberCreated: Boolean,
    val createdMember: CreatedMember?,
) {

    data class CreatedMember(
        val memberId: Long,
        val email: String,
        val username: String,
        val nickname: String,
        val role: String,
        val registeredAt: ZonedDateTime,
    )
}

 

이 유즈케이스는 소셜서비스이름, 소셜서비스 회원 아이디, 이메일 주소를 파라미터로 받고 그 결과를 반환할 책임이 있습니다.

 

소셜 로그인 후,

액세스토큰/액세스토큰 만료시각/리프레시토큰/리프레시토큰 만료시각을 응답으로 주도록 했고요.

회원이 생성됐다면(가입) 생성된 회원의 간단한 정보도 응답으로 줄 수 있도록 했습니다.

 

@ApplicationService
internal class SocialLoginApplicationService() : SocialLoginUseCase {

    override fun socialLogin(request: SocialLoginRequest): SocialLoginResult {
    	TODO("Not yet implemented")
    }
}

 

이것을 실제 구현하는 작업도 필요한데 이것까지 이 글에서 다루면 글의 내용이 길어지므로 뒤의 글에서 마저 구현하도록 하겠습니다. 일단 이번 글에서는 미구현 구현체 골격만 만들어뒀습니다.


9. 커스텀 AuthenticationSuccessHandler

class CustomOAuth2LoginAuthenticationSuccessHandler(
    private val useCase: SocialLoginUseCase,
    private val messageResolver: MessageResolver,
    private val localeManager: LocaleManager,
) : AuthenticationSuccessHandler {

    companion object {
        internal const val TOKEN_TYPE = "Bearer"
        private val objectMapper = jacksonObjectMapper()
            .registerModules(JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    }

    override fun onAuthenticationSuccess(
        request: HttpServletRequest,
        response: HttpServletResponse,
        authentication: Authentication
    ) {
        val socialServiceUserPrincipal = authentication.principal as SocialServiceUserPrincipal

        val socialLoginRequest = SocialLoginRequest(
            socialServiceName = socialServiceUserPrincipal.socialServiceName(),
            socialServiceUserId = socialServiceUserPrincipal.socialServiceUserId(),
            email = socialServiceUserPrincipal.email()
        )

        val result = useCase.socialLogin(socialLoginRequest)

        // Http 응답 작성
        writeResponse(result, response)
    }

    private fun writeResponse(
        result: SocialLoginResult,
        response: HttpServletResponse
    ) {

        val socialLoginResponse = makeResponse(result)

        response.status = HttpStatus.OK.value()
        response.contentType = "application/json; charset=utf-8"
        response.writer.write(objectMapper.writeValueAsString(socialLoginResponse))
    }

    private fun makeResponse(result: SocialLoginResult): SuccessResponse<SocialLoginResponse> {
        val code = "SocialLogin.Complete"
        val locale = localeManager.getCurrentLocale()
        return SuccessResponse(
            code = code,
            message = messageResolver.resolve("$code.message", locale),
            description = messageResolver.resolve("$code.description", locale),
            data = SocialLoginResponse(
                accessToken = SocialLoginResponse.AccessToken(
                    tokenValue = result.accessToken,
                    tokenType = TOKEN_TYPE,
                    tokenExpiresAt = result.accessTokenExpiresAt
                ),
                refreshToken = SocialLoginResponse.RefreshToken(
                    tokenValue = result.refreshToken,
                    tokenExpiresAt = result.refreshTokenExpiresAt
                ),
                memberCreated = result.memberCreated,
                createdMember = result.createdMember
            )
        )
    }
}

data class SocialLoginResponse(
    val accessToken: AccessToken,
    val refreshToken: RefreshToken,
    val memberCreated: Boolean,
    val createdMember: SocialLoginResult.CreatedMember?
) {

    data class AccessToken(
        val tokenValue: String,
        val tokenType: String,
        val tokenExpiresAt: ZonedDateTime,
    )

    data class RefreshToken(
        val tokenValue: String,
        val tokenExpiresAt: ZonedDateTime
    )
}

 

최종완성된 커스텀 AuthenticationSuccessHandler 입니다.

 

1. SocialServiceUserPrincipal 추출

파라미터에 포함된 Authentication 의 principal 을 SocialServiceUserPrincipal 로 업캐스팅합니다.

 

2. 회원정보 추출

SocialServiceUserPrincipal  에서

소셜서비스의 이름을 socialServiceName()

소셜서비스 사용자 아이디를 socialServiceUserId()

이메일을 email() 메서드로 추출할 수 있습니다.

 

각각의 구현은 내부에서 추상화되어 있으니 AuthenticationSuccessHandler 에서는 처리할 필요가 없습니다.

 

3. 소셜로그인 유즈케이스 호출

2에서 추출한 정보를 기반으로 SocialLoginUseCase 의 요청파라미터를 만들어서 전달하고, 소셜로그인 결과를 반환받습니다.

 

4. 응답 메시지 작성

3에서 얻어온 결과를 응답 메시지로 가공해, HttpServletResponse 를 통해 작성해 내보냅니다.

 

@Configuration
class FilterChainConfig(

	// 생략
    
    private val localeManager: LocaleManager,
    private val messageResolver: MessageResolver,
    private val socialLoginUseCase: SocialLoginUseCase,
) {

	// 생략

    private fun customAuthenticationSuccessHandler(): AuthenticationSuccessHandler {
        return CustomOAuth2LoginAuthenticationSuccessHandler(
            useCase = socialLoginUseCase,
            messageResolver = messageResolver,
            localeManager = localeManager
        )
    }

}

 

AuthenticationSuccessHandler 설정부입니다.

유즈케이스와 여러가지 응답 작성에 필요한 의존성을 끌어다 생성했습니다.


10. OAuth2AuthorizedClientRepository

package com.ttasjwi.board.system.external.spring.security.oauth2

import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.core.Authentication
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository

class NullOAuth2AuthorizedClientRepository : OAuth2AuthorizedClientRepository {

    override fun <T : OAuth2AuthorizedClient?> loadAuthorizedClient(
        clientRegistrationId: String?,
        principal: Authentication?,
        request: HttpServletRequest?
    ): T? {
        return null
    }

    override fun saveAuthorizedClient(
        authorizedClient: OAuth2AuthorizedClient?,
        principal: Authentication?,
        request: HttpServletRequest?,
        response: HttpServletResponse?
    ) {}

    override fun removeAuthorizedClient(
        clientRegistrationId: String?,
        principal: Authentication?,
        request: HttpServletRequest?,
        response: HttpServletResponse?
    ) {}
}

 

OAuthLoginAuthenticationFilter 중간에서 인증된 OAuth2 사용자 정보를 OAuth2AuthorizedClientRepository 에 저장하는 부분이, 세션에 저장되는 식으로 구현되어있는데

 

저는 사용자 정보 획득 후, 이메일/소셜서비스 사용자 아이디 획득만 하고 마치기 때문에

이후 액세스토큰, 리프레시토큰, 그 외 정보들을 기억해 기억할 필요가 없습니다.

 

그래서 저는 이 부분을 커스텀한 구현체를 만들어서 어떤 작업도 하지 않게 구현했습니다.

 

package com.ttasjwi.board.system.core.config


@Configuration
class FilterChainConfig(

// 생략
) {


    private fun customOAuth2AuthorizedClientRepository(): OAuth2AuthorizedClientRepository {
        return NullOAuth2AuthorizedClientRepository()
    }
}

 

설정은 필터체인 설정 클래스에서 private 메서드를 통해 만들도록 했습니다.


11. 필터체인 조립

companion object {
    private const val OAUTH2_AUTHORIZATION_REQUEST_BASE_URI = "/api/v1/auth/oauth2/authorization"
}

@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        // 생략

        oauth2Login {
            loginProcessingUrl = "/api/v1/auth/login/oauth2/code/*"
            authorizationEndpoint {
                baseUri = OAUTH2_AUTHORIZATION_REQUEST_BASE_URI
                authorizationRequestRepository = oauth2AuthorizationRequestRepository
            }
            userInfoEndpoint {
                userService = customOAuth2UserService()
                oidcUserService = customOidcUserService()
            }
                authorizedClientRepository = customOAuth2AuthorizedClientRepository()
                authenticationSuccessHandler = customOAuth2LoginAuthenticationSuccessHandler()
                authenticationFailureHandler = customAuthenticationFailureHandler()
        }

        // OAuth2 인가요청 리다이렉트 필터
        addFilterBefore<OAuth2AuthorizationRequestRedirectFilter>(customOauth2AuthorizationRequestRedirectFilter())

        // 생략
    }
    return http.build()
}

 

필터체인 설정은 간단합니다.

oauth2Login 에서 커스터마이징 API를 적절히 사용해주면 되거든요.

 

최초 인가 요청이 들어왔을 때 커스텀 리다이렉트 필터가 작동하는 부분은 그대로 두면서,

실제 소셜로그인 처리부분은 oauth2Login(...) 메서드에서 설정을 작성해주면 됩니다.

 

oauth2Login.usefInfoEndpoint 설정에서 커스텀 OAuth2UserService, 커스텀 OidcUserService 설정을 바꿔주고

authenticationSuccessHandler, authenticationFailureHandler 설정을 내부에 삽입해주면 됩니다.

또, OAuth2AuthorizedClientRepository 설정도 커스텀하게 해줬습니다.

 

이게 어떻게 돌아가는 설정이 동작하는 지는 OAuth2LoginConfigurer 의 init, configurer 를 보면 되고 지난 글에 작성해뒀으니 참고해주시면 될 것 같아요.

 

@Configuration
class FilterChainConfig(
    private val accessTokenManager: AccessTokenManager,
    private val localeManager: LocaleManager,
    private val messageResolver: MessageResolver,
    private val timeManager: TimeManager,
    private val socialLoginUseCase: SocialLoginUseCase,
    @Qualifier(value = "handlerExceptionResolver")
    private val handlerExceptionResolver: HandlerExceptionResolver,
    private val clientRegistrationRepository: ClientRegistrationRepository,
    private val oauth2AuthorizationRequestRepository: AuthorizationRequestRepository<OAuth2AuthorizationRequest>
) {

    companion object {
        private const val OAUTH2_AUTHORIZATION_REQUEST_BASE_URI = "/api/v1/auth/oauth2/authorization"
    }

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeHttpRequests {
                authorize(PathRequest.toStaticResources().atCommonLocations(), permitAll)
                authorize(HttpMethod.GET, "/api/v1/deploy/health-check", permitAll)
                authorize(HttpMethod.GET, "/api/v1/members/email-available", permitAll)
                authorize(HttpMethod.GET, "/api/v1/members/username-available", permitAll)
                authorize(HttpMethod.GET, "/api/v1/members/nickname-available", permitAll)
                authorize(HttpMethod.POST, "/api/v1/members/email-verification/start", permitAll)
                authorize(HttpMethod.POST, "/api/v1/members/email-verification", permitAll)
                authorize(HttpMethod.POST, "/api/v1/members", permitAll)
                authorize(HttpMethod.POST, "/api/v1/auth/login", permitAll)
                authorize(HttpMethod.POST, "/api/v1/auth/token-refresh", permitAll)
                authorize(anyRequest, authenticated)
            }

            oauth2Login {
                loginProcessingUrl = "/api/v1/auth/login/oauth2/code/*"
                authorizationEndpoint {
                    baseUri = OAUTH2_AUTHORIZATION_REQUEST_BASE_URI
                    authorizationRequestRepository = oauth2AuthorizationRequestRepository
                }
                userInfoEndpoint {
                    userService = customOAuth2UserService()
                    oidcUserService = customOidcUserService()
                }
                authorizedClientRepository = customOAuth2AuthorizedClientRepository()
                authenticationSuccessHandler = customOAuth2LoginAuthenticationSuccessHandler()
                authenticationFailureHandler = customAuthenticationFailureHandler()
            }

            // OAuth2 인가요청 리다이렉트 필터
            addFilterBefore<OAuth2AuthorizationRequestRedirectFilter>(customOauth2AuthorizationRequestRedirectFilter())

            csrf { disable() }

            sessionManagement {
                sessionCreationPolicy = SessionCreationPolicy.STATELESS
            }

            requestCache {
                requestCache = NullRequestCache()
            }

            exceptionHandling {
                authenticationEntryPoint = CustomAuthenticationEntryPoint(handlerExceptionResolver)
                accessDeniedHandler = CustomAccessDeniedHandler(handlerExceptionResolver)
            }
            addFilterBefore<UsernamePasswordAuthenticationFilter>(accessTokenAuthenticationFilter())
        }
        return http.build()
    }

    private fun accessTokenAuthenticationFilter(): OncePerRequestFilter {
        return AccessTokenAuthenticationFilter(
            bearerTokenResolver = BearerTokenResolver(),
            accessTokenManager = accessTokenManager,
            timeManager = timeManager,
        )
    }

    @Bean
    fun customOauth2AuthorizationRequestRedirectFilter(): OAuth2AuthorizationRequestRedirectFilter {
        val oauth2AuthorizationRequestRedirectFilter = OAuth2AuthorizationRequestRedirectFilter(
            customOAuth2AuthorizationRequestResolver()
        )
        oauth2AuthorizationRequestRedirectFilter.setAuthorizationRequestRepository(oauth2AuthorizationRequestRepository)
        oauth2AuthorizationRequestRedirectFilter.setRequestCache(NullRequestCache())
        oauth2AuthorizationRequestRedirectFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler())
        return oauth2AuthorizationRequestRedirectFilter
    }

    private fun customOAuth2AuthorizationRequestResolver(): OAuth2AuthorizationRequestResolver {
        return CustomOAuth2AuthorizationRequestResolver.of(
            clientRegistrationRepository = clientRegistrationRepository,
            authorizationRequestBaseUri = OAUTH2_AUTHORIZATION_REQUEST_BASE_URI
        )
    }

    @Bean
    fun customAuthenticationFailureHandler(): AuthenticationFailureHandler {
        return CustomAuthenticationFailureHandler(handlerExceptionResolver)
    }

    private fun customOAuth2LoginAuthenticationSuccessHandler(): AuthenticationSuccessHandler {
        return CustomOAuth2LoginAuthenticationSuccessHandler(
            useCase = socialLoginUseCase,
            messageResolver = messageResolver,
            localeManager = localeManager
        )
    }

    private fun customOAuth2UserService(): OAuth2UserService<OAuth2UserRequest, OAuth2User> {
        return CustomOAuth2UserService(DefaultOAuth2UserService())
    }

    private fun customOidcUserService(): OidcUserService {
        return CustomOidcUserService(OidcUserService())
    }

    private fun customOAuth2AuthorizedClientRepository(): OAuth2AuthorizedClientRepository {
        return NullOAuth2AuthorizedClientRepository()
    }
}

 

최종적으로 구성된 FilterChainConfig 전체 코드입니다.

대부분의 기능은 스프링이 해줘서 저희가 노력해야하는 부분은 몇 가지 구현체 변경만 해주면 되는거죠.


12. 디버깅 실행

 

커스텀 AuthenticationSuccessHandler 에 디버거를 걸고 소셜 로그인 기능을 실행해보겠습니다.

 

 

12.1 구글

 

우선 소셜서비스의 인가를 위해 인가 요청을 합니다.

 

 

승인페이지로 리다이렉트 되고, 승인하면

 

state, code가 포함된 채 리다이렉트됩니다.

여기서 쿼리 파라미터들을 그대로 복사해서

 

 

소셜로그인 엔드포인트에 그대로 전달해보겠습니다.

 

 

실제 유즈케이스 호출쪽에 디버거를 걸었을 때 SocialServiceUserPrincipal 로 GoogleOidcUser가 잘 바인딩된 것을 볼 수 있습니다. 사용자의 소셜서비스 신원을 저희가 원하는 형태로 잘 가져왔어요.

 

그리고 여기서 추출한 결과로 SocialLoginRequest 를 구성하는데 의도한대로 필드들이 채워졌죠.

 

12.2 카카오

카카오 방식도 잘 작동하는게 확인됐습니다. 

KakaoOidcUser 형태로 회원의 신원을 확보할 수 있었고 이를 SocialLoginRequest에 필드들도 잘 바인딩했습니다.

 

12.3 네이버

네이버 방식도 잘 작동되는게 확인됐습니다.

네이버는 OAuth2 방식을 써야하는데, 의도한대로 NaverOAuth2User 형태로 가져와졌습니다.

그리고 이 결과를 받아서, SocialLoginRequest 가 잘 구성됐습니다.

 

12.4 PKCE 적용 확인

 

도중에 OAuth2 인가서버와 실제 통신하는 부분은 OAuth2AccessTokenResponseClient 가 담당한다고 했었는데,

 

기본 구현체인 DefaultAuthorizationCodeTokenResponseClient 쪽에 디버거를 걸어보면 code_verifier 가 파라미터에 포함되어 있는 것을 볼 수 있습니다. 이전 글에서 인가서버에 보냈던 code_challenge, code_challenge_method 를 인가서버가 기억하고 있는데, 지금 code_verifier 를 함께 보내면 인가서버는 code_verifier 와 code_challenge_method를 통해 code_challenge 를 다시 또 만들어내 요청이 위변조되지 않았는지 확인할 수 있게됩니다.

 

PKCE 가 잘 적용된 것을 볼 수 있습니다.

 

12.5 Nonce 검증 확인

 

OidcAuthorizationCodeAuthenticationProvider 에도 디버거를 걸어봤습니다.

 

여기서는 Oidc 방식으로 액세스토큰/아이디 토큰을 발급받았었는데요.

이 과정에서 ID Token 안에 위치한 nonce 를 확인하는 과정이 포함되어 있습니다.

 

최초 인가요청시 보냈던 nonce 값이 잘 토큰에 포함됐는지 확인하는 것을 볼 수 있습니다.

 


이렇게 소셜 로그인을 위한 사전작업은 모두 끝났습니다.

사용자의 소셜서비스 신원을 확인할 수 있게 됐으니 이걸 토대로 실제로 사용자를 로그인시키거나 회원가입을 시키면 돼요. 이 부분에 대한 구현은 뒤에서 이어서 하겠습니다. 감사합니다.


GitHub 리포지토리

https://github.com/ttasjwi/board-system

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

Comments