해당 글은 기술에 대한 이해보다 로직, 데이터 흐름에 대해 살펴보고 정리한 글입니다. 때문에 기술적인 지식을 원하시는 분은 잘 정리된 다른 블로그를 참고하시는걸 추천드립니다.
토이프로젝트로 간단한 게시판 rest api 프로젝트를 구현하면서 소셜 로그인을 적용시키는 과정에서 로직 설계에 어려움을 느꼈다. 때문에 관련 정보를 찾으면서 고민하다가 문득 spring 에서는 어떤식으로 로직이 구현되어 있을지 궁금해져서 찾아본 내용을 정리한 글입니다. 때문에 프로젝트 구현에 대한 설명은 따로 적지 않았습니다. application.properties(or yml) 설정은 완료했다는 가정하에 작성한 글입니다.
- 스프링 부트 3.5.3
- spring-boot-starter-oauth2-client 3.5.3
프로젝트 실행 후 기본적으로 설정된 "/oauth2/authorization/{code}" 로 접근하면 소셜 로그인 창으로 리다이렉트된다.
이 때는 OAuth2AuthorizationRequestRedirectFilter 에서 필터링이 진행된 결과이며 그 과정에서 입력한 authorization-uri, client-id, redirect-uri, scope 를 이용한 소셜로그인 창으로의 url 이 생성된다.
package org.springframework.security.oauth2.client.web;
...
public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
...
public OAuth2AuthorizationRequestRedirectFilter(ClientRegistrationRepository clientRegistrationRepository, String authorizationRequestBaseUri) {
Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null");
Assert.hasText(authorizationRequestBaseUri, "authorizationRequestBaseUri cannot be empty");
this.authorizationRequestResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, authorizationRequestBaseUri);
}
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request);
if (authorizationRequest != null) {
this.sendRedirectForAuthorization(request, response, authorizationRequest);
return;
}
}
catch (Exception ex) {
AuthenticationException wrappedException = new OAuth2AuthorizationRequestException(ex);
this.authenticationFailureHandler.onAuthenticationFailure(request, response, wrappedException);
return;
}
try {
filterChain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Check to see if we need to handle ClientAuthorizationRequiredException
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
ClientAuthorizationRequiredException authzEx = (ClientAuthorizationRequiredException) this.throwableAnalyzer
.getFirstThrowableOfType(ClientAuthorizationRequiredException.class, causeChain);
if (authzEx != null) {
try {
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestResolver.resolve(request,
authzEx.getClientRegistrationId());
if (authorizationRequest == null) {
throw authzEx;
}
this.requestCache.saveRequest(request, response);
this.sendRedirectForAuthorization(request, response, authorizationRequest);
}
catch (Exception failed) {
AuthenticationException wrappedException = new OAuth2AuthorizationRequestException(ex);
this.authenticationFailureHandler.onAuthenticationFailure(request, response, wrappedException);
}
return;
}
if (ex instanceof ServletException) {
throw (ServletException) ex;
}
if (ex instanceof RuntimeException) {
throw (RuntimeException) ex;
}
throw new RuntimeException(ex);
}
}
...
}
필터 생성 시 DefaultOAuth2AuthorizationRequestResolver 가 authorizationRequestResolver 에 참조변수로 사용된다.
DefaultOAuth2AuthorizationRequestResolver 에서 소셜 로그인창으로의 url 을 생성하고 반환해주면 해당 url 로 리다이렉트 된다.
소셜 로그인 창에서 로그인 진행 후, 정상적으로 진행되었다면 redirect-uri 로 로그인 성공 여부와 관련된 정보가 함께 전달된다. 별도로 설정하지 않았다면 "/login/oauth2/code/{code}" 가 기본 url 로 설정되어 있다. 그러면 해당 url 로 정보가 전달되었을 때는 OAuth2LoginAuthenticationFilter 에서 필터링을 진행한다. 이 과정에서 엑세스 토큰 요청과 요청한 토큰을 이용한 유저 정보 요청이 진행된다.
package org.springframework.security.oauth2.client.web;
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@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.fromUriString(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);
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;
}
...
}
package org.springframework.security.oauth2.client.authentication;
public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
...
public OAuth2LoginAuthenticationProvider(
OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient,
OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
Assert.notNull(userService, "userService cannot be null");
this.authorizationCodeAuthenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(
accessTokenResponseClient);
this.userService = userService;
}
...
@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;
}
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;
}
...
}
package org.springframework.security.oauth2.client.userinfo;
public class DefaultOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
...
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
String userNameAttributeName = getUserNameAttributeName(userRequest);
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
OAuth2AccessToken token = userRequest.getAccessToken();
Map<String, Object> attributes = this.attributesConverter.convert(userRequest).convert(response.getBody());
Collection<GrantedAuthority> authorities = getAuthorities(token, attributes, userNameAttributeName);
return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);
}
...
}
OAuth2LoginAuthenticationFilter 에서 OAuth2LoginAuthenticationToken 생성 시
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);
를 호출하는데 이 때 OAuth2LoginAuthenticationProvider 가 호출된다. OAuth2LoginAuthenticationProvider.authenticate(Authentication authentication) 에서는 authorizationCodeAuthenticationToken 초기화 시 엑세스 토큰요청 로직이 진행되고 엑세스 토큰 발급 후 OAuth2User 초기화를 위한 userService.loadUser 에서 user-info-uri 가 사용되며 유저 정보를 가져온다.
요약
- OAuth2AuthorizationRequestRedirectFilter 에서 "/oauth2/authorization/{code}" 필터링 진행
- DefaultOAuth2AuthorizationRequestResolver.resolve(HttpServletRequest request) 의 결과로 authorization-uri 관련 리다이렉트 uri 생성
문제없이 생성되었다면 this.sendRedirectForAuthorization(request, response, authorizationRequest)을 통해 소셜 로그인 서비스로 리다이렉트 - 로그인 진행
- OAuth2LoginAuthenticationFilter 에서 "/login/oauth2/code/{code}" 필터링 진행
- OAuth2LoginAuthenticationToken 생성 시 OAuth2LoginAuthenticationProvider.authenticate(Authentication authentication) 호출
- OAuth2AuthorizationCodeAuthenticationProvider.authenticate(Authentication authentication) 에서 엑세스 토큰 발급 요청
- OAuth2User 객체 생성 시 DefaultOAuth2UserService.loadUser(OAuth2UserRequest userRequest) 호출
- getResponse(OAuth2UserRequest userRequest, RequestEntity<?> request) 에서 user-info 요청