1. 서론
이전 포스팅(https://inthebleakmidwinter.tistory.com/4)에서 OAuth2LoginAuthenticationFilter가 4. Access Token 요청 ~ 7. 유저 정보 제공 과정을 처리한다고 설명했다. 개발자가 SecurityConfiguration을 커스터마이징할 때, oauth2Login()을 활성화시키면 OAuth2LoginConfigurer가 추가되어 이 configurer가 Security Filter Chain에 OAuth2LoginAuthenticationFilter를 추가한다. request는 Dispatcher Servlet에 도달하기전 이 필터(또는 다른 여러 필터)를 거치게 되며 지정된 redirect-uri 패턴으로 오는 request를 가로채 로그인 프로세스를 진행하게 된다.
개발자가 커스터마이징한 Security Configuration과 OAuth2LoginAuthenticationFilter 코드 분석으로 시작해 나머지 OAuth 2.0 로그인 과정을 살펴보자.
2. Security Configuration with OAuth
SecurityConfig.java
...
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtCookieProvider jwtCookieProvider;
private final UrcarcherOAuth2Service urcarcherOAuth2Service;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
private static final String[] BLACK_LIST = {"/api/paymentPlace/**, /api/exchange/**, /api/reserve/**"};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
// OAuth 로그인에 대한 흐름 설정
.oauth2Login(oauth2Config -> {
oauth2Config
// Backend가 다중 서버로 구성될 경우 필요한 설정, OAuth 인증 과정에서 사용되는 여러 파라미터를 쿠키로 저장하여 진행시킴
.authorizationEndpoint(authEndPoint -> {
authEndPoint.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository);
})
// 로그인 성공 시 urcarcherOAuth2Service에서 제공받은 유저 정보를 처리함
.userInfoEndpoint(load->load.userService(urcarcherOAuth2Service))
// 로그인 성공 시 수행해야할 필요한 로직을 처리함
.successHandler(oAuth2SuccessHandler);
})
// UsernamePasswordAuthenticationFilter보다 앞 순위에 필터를 배치한다는 뜻, 우리는 이 필터가 비활성화 돼 있으므로
// 실질적으로 사용자는 JwtAuthenticationFilter를 거치게 됨.
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, jwtCookieProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
위 HttpSecurity의 설정을 보면, oauth2Login() 메서드 안에서 람다식으로 설정이 진행되고 있음을 알 수 있다.
...
public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {
...
public HttpSecurity oauth2Login(Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer)
throws Exception {
oauth2LoginCustomizer.customize(getOrApply(new OAuth2LoginConfigurer<>()));
return HttpSecurity.this;
}
...
...
public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, OAuth2LoginConfigurer<B>, OAuth2LoginAuthenticationFilter> {
...
private final UserInfoEndpointConfig userInfoEndpointConfig = new UserInfoEndpointConfig();
...
public OAuth2LoginConfigurer<B> userInfoEndpoint(Customizer<UserInfoEndpointConfig> userInfoEndpointCustomizer) {
userInfoEndpointCustomizer.customize(this.userInfoEndpointConfig);
return this;
}
...
public final class UserInfoEndpointConfig {
...
public UserInfoEndpointConfig userService(OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
Assert.notNull(userService, "userService cannot be null");
this.userService = userService;
return this;
}
...
...
public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter>
extends AbstractHttpConfigurer<T, B> {
...
public final T successHandler(AuthenticationSuccessHandler successHandler) {
this.successHandler = successHandler;
return getSelf();
}
...
즉, HttpSecurity.class에서 oauth2Login()의 매개변수에는 Customizer<OAuth2LoginConfigurer>의 구현체가 들어가며 SecurityConfig.java에서 알 수 있듯이 customize() 메서드를 overriding하는 람다식이 oauth2Login() 메서드로 전달된다. 따라서 SecurityConfig.java의 oauth2Login() 람다식 안의 매개변수로 전달되는 oauth2Config는 OAuth2LoginConfigurer이다. OAuth2LoginConfigurer의 필드에는 내부 클래스인 UserInfoEndpointConfig 객체가 존재한다. 같은 논리로 .userInfoEndpoint(load->load.userService(urcarcherOAuth2Service))에서 load에는 userInfoEndpointConfig가 담기며 userService에 개발자가 커스터마이징한 userService를 set한다.
아울러 OAuth2LoginConfigurer는 부모 클래스인 AbstractAuthenticationFilterConfigurer를 가지는데, .successHandler(oAuth2SuccessHandler)로 OAuth 로그인 성공 시 어떤 로직을 진행할지에 대한 handler를 설정할 수 있는 것이다.(본래는 failureHandler도 설정해줘야한다. 프로젝트 구현할 때 놓쳤었다. 아쉬운 부분.)
결론적으로 Security Confiuration을 통해 OAuth2LoginConfigurer에 개발자가 커스터마이징한 userService와 handler를 setting 할 수 있다. OAuth2LoginConfigurer에 setting되고 나면, init()과 configure()를 통해 OAuth2LoginConfigurer가 관리하는 Filter에도 이것들이 setting된다. 이후 과정에서도 이를 염두에 두고 과정을 이해해야 한다. (authorizationEndpoint()는 추후 설명)
3. Access Token(인증 대행 서버) 요청 및 발급 전 과정
위 그림 2. 인증 대행 서버에 인증 요청에서 사용자가 해당 소셜 로그인 페이지로 이동해서 아이디, 비밀번호 입력 후 인증을 성공하면, 소셜 로그인 페이지 url에 담겨 있던 redirect_uri 파라미터의 주소로 code 파라미터와 함께 Spring boot server로 redirect 된다. ex) "https://{domain}/login/oauth2/code/google?code={Authorization_code}" 그러면 이 request가 OAuth2LoginAuthenticationFilter를 거치게 되는 것이다.
...
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
MultiValueMap<String, String> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
...
}
...
request.getParameterMap()은 "https://{domain}/login/oauth2/code/google?code={Authorization_code}"와 같이 request에 대해 물음표 뒤에 오는 추가 데이터를 key, value의 형식의 데이터로 반환한다. 이를 toMultiMap() 메서드로 MultiValueMap 객체로 변환시켜 저장한다. (MultiValueMap은 같은 key에 다른 value들을 넣을 수 있다. 즉, value가 List 형태로 저장된다.)
...
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
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();
...
}
...
registrationId를 통해 clientId와 cliendSecret, scope등의 현재 OAuth 설정에 대한 정보가 담겨 있는 ClientRegistration 객체를 생성하고 현재 request url에서 redirectUri를 추출한다.
...
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params,
redirectUri);
...
}
...
...
final class OAuth2AuthorizationResponseUtils {
...
static OAuth2AuthorizationResponse convert(MultiValueMap<String, String> request, String redirectUri) {
String code = request.getFirst(OAuth2ParameterNames.CODE);
String errorCode = request.getFirst(OAuth2ParameterNames.ERROR);
String state = request.getFirst(OAuth2ParameterNames.STATE);
if (StringUtils.hasText(code)) {
return OAuth2AuthorizationResponse.success(code).redirectUri(redirectUri).state(state).build();
}
...
}
}
OAuth2AuthorizationResponseUtils의 convert() 메서드를 이용해 Authorization code와 redirectUri 등을 담아 OAuth2AuthorizationResponse 객체를 생성한다. 이 객체를 이용해서 인증 대행 서버로부터 Access Token을 가져오게 된다.
...
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(clientRegistration,
new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);
OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
.getAuthenticationManager()
.authenticate(authenticationRequest);
...
}
...
생성한 OAuth2AuthorizationResponse 객체를 OAuth2AuthorizationExchange에 담고, 이를 위에서 추출한 ClientRegistration와 함께 OAuth2LoginAuthenticationToken에 담아 이를 생성한다. 이제 인증 대행 서버로부터 유저 정보를 얻어오기 위한 Access Token을 요청하기 위한 사전 준비가 완료됐다.
4. OAuth2LoginAuthenticationProvider
...
public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, OAuth2LoginConfigurer<B>, OAuth2LoginAuthenticationFilter> {
...
@Override
public void init(B http) throws Exception {
...
OAuth2LoginAuthenticationProvider oauth2LoginAuthenticationProvider = new OAuth2LoginAuthenticationProvider(
accessTokenResponseClient, oauth2UserService);
GrantedAuthoritiesMapper userAuthoritiesMapper = this.getGrantedAuthoritiesMapper();
if (userAuthoritiesMapper != null) {
oauth2LoginAuthenticationProvider.setAuthoritiesMapper(userAuthoritiesMapper);
}
http.authenticationProvider(this.postProcess(oauth2LoginAuthenticationProvider));
...
}
@Override
public void configure(B http) throws Exception {
...
super.configure(http);
}
...
...
public abstract class AbstractAuthenticationFilterConfigurer<B extends HttpSecurityBuilder<B>, T extends AbstractAuthenticationFilterConfigurer<B, T, F>, F extends AbstractAuthenticationProcessingFilter>
extends AbstractHttpConfigurer<T, B> {
...
@Override
public void configure(B http) throws Exception {
...
this.authFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
...
}
...
OAuth2LoginAuthenticationFilter를 필터 체인에 추가한 OAuth2LoginConfigurer가 초기화(init()) 될 때 build되는 HttpSecurity의 필드에 AuthenticationProvider를 set하고, 구성(configure())될 때 부모 클래스인 AbstractAuthenticationFilterConfigurer의 configure() 메서드를 호출하면서 이 메서드 내부에서 OAuth2LoginAuthenticationFilter의 부모 클래스인 AbstractAuthenticationProcessingFilter의 필드에 AuthenticationProvider를 저장한다.
따라서 위 과정에 의해 this.getAuthenticationManager()는 OAuth2LoginAuthenticationProvider를 반환하게 되고 OAuth2LoginAuthenticationProvider의 authenticate() 메서드가 실행된다. authenticate() 메서드에 OAuth2LoginAuthenticationToken이 매개변수로 전달되면서 Access Token 발급 절차가 진행되는 것이다.
...
public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider;
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
...
try {
authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken) this.authorizationCodeAuthenticationProvider
.authenticate(
new OAuth2AuthorizationCodeAuthenticationToken(loginAuthenticationToken.getClientRegistration(),
loginAuthenticationToken.getAuthorizationExchange()));
}
...
}
...
...
public class OAuth2AuthorizationCodeAuthenticationProvider implements AuthenticationProvider {
private static final String INVALID_STATE_PARAMETER_ERROR_CODE = "invalid_state_parameter";
private final OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient;
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
OAuth2AccessTokenResponse accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
new OAuth2AuthorizationCodeGrantRequest(authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange()));
OAuth2AuthorizationCodeAuthenticationToken authenticationResult = new OAuth2AuthorizationCodeAuthenticationToken(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange(), accessTokenResponse.getAccessToken(),
accessTokenResponse.getRefreshToken(), accessTokenResponse.getAdditionalParameters());
authenticationResult.setDetails(authorizationCodeAuthentication.getDetails());
return authenticationResult;
}
...
...
public final class DefaultAuthorizationCodeTokenResponseClient
implements OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> {
...
private RestOperations restOperations;
public DefaultAuthorizationCodeTokenResponseClient() {
RestTemplate restTemplate = new RestTemplate(
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
this.restOperations = restTemplate;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(
OAuth2AuthorizationCodeGrantRequest authorizationCodeGrantRequest) {
Assert.notNull(authorizationCodeGrantRequest, "authorizationCodeGrantRequest cannot be null");
RequestEntity<?> request = this.requestEntityConverter.convert(authorizationCodeGrantRequest);
ResponseEntity<OAuth2AccessTokenResponse> response = getResponse(request);
OAuth2AccessTokenResponse tokenResponse = response.getBody();
Assert.notNull(tokenResponse,
"The authorization server responded to this Authorization Code grant request with an empty body; as such, it cannot be materialized into an OAuth2AccessTokenResponse instance. Please check the HTTP response code in your server logs for more details.");
return tokenResponse;
}
private ResponseEntity<OAuth2AccessTokenResponse> getResponse(RequestEntity<?> request) {
try {
return this.restOperations.exchange(request, OAuth2AccessTokenResponse.class);
}
catch (RestClientException ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
+ ex.getMessage(),
null);
throw new OAuth2AuthorizationException(oauth2Error, ex);
}
}
...
OAuth2LoginAuthenticationProvider는 생성될 때 필드에 OAuth2AuthorizationCodeAuthenticationProvider 객체를 가진다. OAuth2LoginAuthenticationProvider의 authenticate() 메서드 내부의 try 문 안에서 OAuth2AuthorizationCodeAuthenticationProvider의 authenticate() 메서드가 호출된다. 바로 이 메서드에서 Access Token을 요청하고 발급받는다.(메서드 내부 this.accessTokenResponseClient.getTokenResponse()) OAuth2AuthorizationCodeAuthenticationProvider 필드의 OAuth2AccessTokenResponseClient의 구현체인 DefaultAuthorizationCodeTokenResponseClient의 getTokenResponse() 메서드의 return 값으로 Access Token을 반환 받는다. DefaultAuthorizationCodeTokenResponseClient.class를 살펴보면, 필드에 restOperations라는 RestTemplate 객체를 가지고 있는 것을 볼 수 있다. 따라서 getTokenResponse() 내부에서 getResponse() 메서드가 실행되며 RestTemplate를 이용해 OAuth 서버로부터 response를 받아온 뒤에 response body를 추출하여 반환한다. 이 body 내부에 Access token이 담기어 있는 것이다.
5. loadUser()
...
public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider;
...
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
...
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;
}
...
다시 OAuth2LoginAuthenticationProvider로 돌아와보자. Access token을 발급 받았으므로 이를 가지고 이제 유저 정보를 받아올 수 있다. 2. Security Configuration with OAuth에서 언급한 것처럼 configurer 설정에 의해 OAuth2LoginAuthenticationProvider는 개발자가 커스터마이징한 userService를 가지고 있으므로 본 서비스가 Overriding한 loadUser() 메서드가 실행된다.
UrcarcherOAuth2Service.java
...
@RequiredArgsConstructor
@Service
public class UrcarcherOAuth2Service extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public AuthorizedUser loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 부모 클래스인 DefaultOAuth2UserService loadUser() 메서드 먼저 수행
Map<String, Object> oAuth2UserAttributes = super.loadUser(userRequest).getAttributes();
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest
.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
// OAuth 유저의 Profile 생성 후 Entity 객체 만들기
OAuth2Profile oAuth2Profile = OAuth2Profile.of(registrationId, oAuth2UserAttributes, userNameAttributeName);
Member member = memberRepository
.findByEmailAndProvider(oAuth2Profile.getEmail(), oAuth2Profile.getProvider())
.orElseGet(oAuth2Profile::toMember);
// 추가 정보입력이 필요하므로 일단 data 저장
memberRepository.save(member);
return AuthorizedUser.builder()
.member(member)
.attributes(oAuth2UserAttributes)
.attributeName(userNameAttributeName)
.build();
}
...
}
OAuth2Profile.java
...
@Builder
@Data
public class OAuth2Profile {
private String sub;
private String email;
private String name;
private String picture;
private MemberProvider provider;
public static OAuth2Profile of(String regisrationId, Map<String, Object> attributes, String attributeName) {
return switch (regisrationId) {
case "google" -> ofGoogle(attributes, attributeName);
default -> OAuth2Profile.builder().build();
};
}
private static OAuth2Profile ofGoogle(Map<String, Object> attributes, String attributeName) {
return OAuth2Profile.builder()
.sub((String) attributes.get(attributeName))
.email((String) attributes.get("email"))
.name((String) attributes.get("name"))
.picture((String) attributes.get("picture"))
.provider(MemberProvider.GOOGLE)
.build();
}
public Member toMember() {
return Member.builder()
.memberId(sub)
.email(email)
.name(name)
.picture(picture)
.role(MemberRole.GUEST) // 추가 정보 입력이 필요한 초기 회원의 경우 Role을 GUEST로 설정
.provider(provider)
.build();
}
}
여기서 OAuth 유저 정보를 가져오는 핵심 코드는 super.loadUser()이다.
...
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);
return new DefaultOAuth2User(authorities, attributes, userNameAttributeName);
}
...
private ResponseEntity<Map<String, Object>> getResponse(OAuth2UserRequest userRequest, RequestEntity<?> request) {
try {
return this.restOperations.exchange(request, PARAMETERIZED_RESPONSE_TYPE);
}
...
}
부모 클래스인 DefaultOAuth2UserService의 loadUser() 메서드가 가장 먼저 실행되는데, Access Token을 담고 있는 OAuth2UserRequest로 OAuth 서버에 유저 정보를 요청하게 된다. DefaultOAuth2UserService의 필드에 있는 this.restOperations도 마찬가지로 RestTemplate 객체이며 이를 이용해 유저 정보를 response로 받아온다. body에 user의 정보가 담겨 있고 이를 가져와서 활용하는 것이다. 본 프로젝트에서는 추후 추가 정보 입력을 위해 이 data를 저장하고(memberRepository.save(member);) OAuth2User의 구현체인 AuthorizedUser를 반환한다.
결과적으로 OAuth2LoginAuthenticationProvider의 authenticate() 메서드는 유저 정보가 담긴(OAuth2User) Authentication 객체를 반환하게 된다.
6. OAuth Success Handler
...
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
OAuth2AuthenticationToken oauth2Authentication = this.authenticationResultConverter
.convert(authenticationResult);
...
return oauth2Authentication;
}
...
...
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
...
try {
Authentication authenticationResult = attemptAuthentication(request, response);
...
successfulAuthentication(request, response, chain, authenticationResult);
}
...
}
...
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
...
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
...
OAuth2LoginAuthenticationProvider가 반환한 Authentication 객체는 OAuth2LoginAuthenticationFilter 필드에 존재하는 this.authenticationResultConverter에 의해 인증이 완료된 Authentication 객체로 변환되며 attemptAuthentication() 메서드는 이 Authentication를 반환한다.
먼 길 돌아왔지만 OAuth2LoginAuthenticationFilter의 doFilter() 메서드(부모 클래스인AbstractAuthenticationProcessingFilter가 가지고 있음.)가 진행 중이었던 것이고, 성공적으로 반환되면 이를 가지고 successfulAuthentication() 메서드를 호출한다. 이어서 이 메서드는 결국 Filter에 설정된 Success Handler에게 요청을 위임한다.(2. Security Configuration with OAuth에서 설명했듯이 개발자가 설정함.)
OAuth2SuccessHandler.java
...
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenProvider jwtTokenProvider;
private final JwtCookieProvider jwtCookieProvider;
@Value("${OAUTH2_SUCCESS_TARGET_URL}")
private String targetURL;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
// Authentication에 담겨 있는 유저 정보를 가져오기.(AuthorizedUser는 OAuth2User 구현체)
AuthorizedUser authorizedUser = (AuthorizedUser) authentication.getPrincipal();
// 이 유저 정보를 해당 프로젝트 Member Entity로 변환
Member currentOAuthMember = authorizedUser.getMember();
// 추가 정보를 입력해야하는 초기 회원의 경우 이 회원의 정보를 프론트 단에 전달하기 위한 쿠키
response.addCookie(jwtCookieProvider.createOAuthInfoCookie(currentOAuthMember.getEmail(), currentOAuthMember.getRole().name(), currentOAuthMember.getProvider().name()));
// 초기 회원이 아닌 경우 해당 프로젝트 서비스에 대한 Access token과 Refresh token 발급
if(currentOAuthMember.getRole() != MemberRole.GUEST) {
TokenDTO token = jwtTokenProvider.generateToken(authentication);
response.addCookie(jwtCookieProvider.createCookieForAccessToken(token.getAccessToken()));
response.addCookie(jwtCookieProvider.createCookieForRefreshToken(token.getRefreshToken()));
}
// 원하는 프론트 page로 redirect 시키기
getRedirectStrategy().sendRedirect(request, response, targetURL);
}
}
본 프로젝트에서 구현한 Success Handler이다. OAuth 서버로부터 받아온 유저 정보를 Authentication 객체에서 추출한다. 이를 프로젝트의 Member Entity로 변환하는데, 추가 정보가 필요한 초기 OAuth 회원일 경우 해당 Member의 role은 GUEST인 상태이다. GUEST가 아닐 경우 response에 Access Token과 Refresh Token을 쿠키로 담아 프론트 단으로 redirect 시키게 된다. GUEST인 경우 기본 OAuth 정보를 쿠키에 담아 redirect 시킨다. 어떤 경우든 프론트 단에서 대응하여 처리할 수 있는 것이다. 여기까지가 OAuth 프로토콜을 이용한 소셜 로그인 과정이다.
7. AuthorizationRequestRepository
..
public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, OAuth2LoginConfigurer<B>, OAuth2LoginAuthenticationFilter> {
private final AuthorizationEndpointConfig authorizationEndpointConfig = new AuthorizationEndpointConfig();
...
public final class AuthorizationEndpointConfig {
...
public AuthorizationEndpointConfig authorizationRequestRepository(
AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository) {
Assert.notNull(authorizationRequestRepository, "authorizationRequestRepository cannot be null");
this.authorizationRequestRepository = authorizationRequestRepository;
return this;
}
...
}
...
SecurityConfig.java에서 생략하고 넘어갔던 authEndPoint.authorizationRequestRepository() 부분을 보자. 이 설정에 의해 OAuth2LoginConfigurer 필드 존재하는 내부 클래스 AuthorizationEndpointConfig 객체에 개발자가 커스터마이징한 AuthorizationRequestRepository 객체가 저장된다.
설정에 대해 설명하기에 앞서 먼저 AuthorizationRequestRepository는 해당 OAuth 서버에 인가 요청을 시작한 시점부터 인가 요청을 받는 시점까지 OAuth2AuthorizationRequest를 저장한다. 즉, 일관된 사용자가 인가 응답을 연계하고 있는지 검증하기 위해서 쓰이는 것이다. 지금까지 설명했던 소셜 로그인 작동 과정을 간단하게 살펴보면 다음과 같다.
- 사용자가 소셜 로그인 버튼을 누르면 "/oauth2/authorization/{registrationId}" uri로 request를 보낸다.
- OAuth2AuthorizationRequestRedirectFilter는 이를 가로채고 OAuth2AuthorizationRequest 객체를 생성해 AuthorizationRequestRepository에 저장한 뒤 OAuth 서버(provider)의 로그인 페이지로 redirect 시킨다.
- 사용자가 해당 페이지에서 인증에 성공하면 provider는 redirect-uri로 브라우저를 redirect 시킨다.
- OAuth2LoginAuthenticationFilter는 이를 가로채고 AuthorizationRequestRepository에 저장된 OAuth2AuthorizationRequest를 가져와 state를 비교하여 검증한다.
- 검증이 완료된 후 Access token과 관련된 로직이 진행된다.
즉, 지금까지 살펴보았던 소셜 로그인 작동 과정에서 AuthorizationRequestRepository에 의해 정상적인 인가 응답이 연계되는지 검증되고 있었던 것이다.
...
public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, OAuth2LoginConfigurer<B>, OAuth2LoginAuthenticationFilter> {
...
@Override
public void configure(B http) throws Exception {
OAuth2AuthorizationRequestRedirectFilter authorizationRequestFilter;
...
if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
authorizationRequestFilter
.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
}
...
OAuth2LoginAuthenticationFilter authenticationFilter = this.getAuthenticationFilter();
...
if (this.authorizationEndpointConfig.authorizationRequestRepository != null) {
authenticationFilter
.setAuthorizationRequestRepository(this.authorizationEndpointConfig.authorizationRequestRepository);
}
...
}
...
OAuth2LoginConfigurer가 구성(configure())되는 과정에서 OAuth2AuthorizationRequestRedirectFilter와 OAuth2LoginAuthenticationFilter에 커스텀한 AuthorizationRequestRepository가 저장된다. 만약 Security Configuration에서 따로 커스텀한 AuthorizationRequestRepository를 지정하지 않았을 경우 HttpSessionOAuth2AuthorizationRequestRepository가 default로 저장된다.
...
public class OAuth2AuthorizationRequestRedirectFilter extends OncePerRequestFilter {
...
private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository();
...
@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;
}
}
...
}
private void sendRedirectForAuthorization(HttpServletRequest request, HttpServletResponse response,
OAuth2AuthorizationRequest authorizationRequest) throws IOException {
if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
}
this.authorizationRedirectStrategy.sendRedirect(request, response,
authorizationRequest.getAuthorizationRequestUri());
}
...
...
public class OAuth2LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
private AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new HttpSessionOAuth2AuthorizationRequestRepository();
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
...
OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
.removeAuthorizationRequest(request, response);
...
}
위 코드를 보면 알 수 있듯이, 정리했던 소셜 로그인 작동 과정에서의 2번 과정에서 this.authorizationRequestRepository.saveAuthorizationRequest()로 AuthorizationRequestRepository에 OAuth2AuthorizationRequest가 저장된다. 이후 4번 과정에서 this.authorizationRequestRepository.removeAuthorizationRequest()로 저장된 OAuth2AuthorizationRequest를 검증하고, 문제가 없다면 과정이 계속 진행된다.
8. Cookie 기반으로 구현한 AuthorizationRequestRepository
그렇다면 필자는 왜 커스텀한 AuthorizationRequestRepository가 필요했는가?
...
public final class HttpSessionOAuth2AuthorizationRequestRepository
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
private static final String DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME = HttpSessionOAuth2AuthorizationRequestRepository.class
.getName() + ".AUTHORIZATION_REQUEST";
private final String sessionAttributeName = DEFAULT_AUTHORIZATION_REQUEST_ATTR_NAME;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
Assert.notNull(request, "request cannot be null");
String stateParameter = getStateParameter(request);
if (stateParameter == null) {
return null;
}
OAuth2AuthorizationRequest authorizationRequest = getAuthorizationRequest(request);
return (authorizationRequest != null && stateParameter.equals(authorizationRequest.getState()))
? authorizationRequest : null;
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,
HttpServletResponse response) {
Assert.notNull(request, "request cannot be null");
Assert.notNull(response, "response cannot be null");
if (authorizationRequest == null) {
removeAuthorizationRequest(request, response);
return;
}
String state = authorizationRequest.getState();
Assert.hasText(state, "authorizationRequest.state cannot be empty");
request.getSession().setAttribute(this.sessionAttributeName, authorizationRequest);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
HttpServletResponse response) {
Assert.notNull(response, "response cannot be null");
OAuth2AuthorizationRequest authorizationRequest = loadAuthorizationRequest(request);
if (authorizationRequest != null) {
request.getSession().removeAttribute(this.sessionAttributeName);
}
return authorizationRequest;
}
private String getStateParameter(HttpServletRequest request) {
return request.getParameter(OAuth2ParameterNames.STATE);
}
private OAuth2AuthorizationRequest getAuthorizationRequest(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return (session != null) ? (OAuth2AuthorizationRequest) session.getAttribute(this.sessionAttributeName) : null;
}
}
먼저 커스텀하지 않을 경우 default로 사용하는 HttpSessionOAuth2AuthorizationRequestRepository이다. 코드 구성을 보면 알 수 있듯이, 저장소가 HttpSession 객체로 이용되고 있다. OAuth2AuthorizationRequest는 세션에 저장이 되는 것이고, 세션은 서버 내에 저장이 된다.
즉, 단일 서버 환경에서는 문제가 발생하지 않으나 백엔드를 다중 서버로 배치할 경우, OAuth 로그인의 인가 연계가 무너지게 된다. 서버 간 session storage가 공유되도록 따로 설계하지 않았다는 전제면, 인가 연계가 한 서버에서 진행된다는 보장이 없으므로 이 상태로는 OAuth2AuthorizationRequest를 검증할 수 없다. 쉽게 말하면, 이 서버 저 서버 왔다갔다하면서 소셜 로그인 인가 연계가 진행되므로, 한 서버 내부에 저장되는 Session에 의존하는 방식으로는 이를 검증할 수 없다는 말이다.
HttpCookieOAuth2AuthorizationRequestRepository.java
...
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
OAuth2AuthorizationRequest oAuth2AuthorizationRequest = CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
return oAuth2AuthorizationRequest;
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest auth2AuthorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (auth2AuthorizationRequest == null) {
removeAuthorizationRequest(request, response);
return;
}
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(auth2AuthorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
CookieUtils.java
...
public class CookieUtils {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if(cookies != null && cookies.length > 0) {
for(Cookie cookie :cookies) {
if(cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object object) {
return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));
}
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
필자는 해당 프로젝트에서 백엔드에 대해 다중 서버 체계를 채택했으므로 AuthorizationRequestRepository를 Cookie 기반 저장소로 커스텀한 HttpCookieOAuth2AuthorizationRequestRepository.java를 사용했다. 저장 위치가 서버가 아닌 클라이언트(웹 브라우저)로 변경되었으므로 다중 서버 환경에 상관없이 OAuth2AuthorizationRequest를 검증할 수 있다.
따라서 기존의 세션기반 AuthorizationRequestRepository으로 인해 다중 서버 환경에서 발생하던 문제를 극복할 수 있었다.
Project Github Repo.
https://github.com/1nthebleakmidwinter/Urcarcher_Card-Service_ShinhanDS-Academy
References
- https://velog.io/@max9106/OAuth3
- https://taehoung0102.tistory.com/182
- https://jee2memory.tistory.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81%EC%9D%98-%EC%A0%95%EC%84%9D%EC%B0%B8%EA%B3%A0-1-4-HttpServletRequest-%EA%B0%9D%EC%B2%B4%EC%9D%98-%EB%A9%94%EC%84%9C%EB%93%9C
- https://velog.io/@young224/Google-OAuth-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84
- https://velog.io/@smc2315/authorization-request-not-found
- https://godekdls.github.io/Spring%20Security/oauth2/
'Spring boot > Spring security' 카테고리의 다른 글
[Spring boot] OAuth(Google) & JWT Login (7) - JWT 인증 필터 + 그 외 (1) | 2024.11.17 |
---|---|
[Spring boot] OAuth(Google) & JWT Login (6) - JWT + 쿠키 (0) | 2024.11.11 |
[Spring boot] OAuth(Google) & JWT Login (4) - OAuth 동작 ① (1) | 2024.11.04 |
[Spring boot] OAuth(Google) & JWT Login (3) - Security 구성 및 동작 (5) | 2024.10.31 |
[Spring boot] OAuth(Google) & JWT Login (2) - 자체 로그인 동작 구조 (6) | 2024.10.28 |