프로젝트에 OAuth2를 적용해봤다.
구글, 네이버, 카카오 이렇게 3개 플렛폼을 넣었다.
기존에 있는 건 ID/PW 기반 로그인 + JWT
먼저 OAuth2를 스프링에 적용하는 과정은 크게 보면 다음과 같다.
- OAuth2 의존성 추가
- SecurityConfig에 OAuth2 Filter 추가
- OAuth2UserService 작성
- SuccessHandler 작성
- FailureHandler 작성
이렇게만 이해하고 실제로 적용하려니 신경써야하는 부분이 많았다.
SecurityConfig에 OAuth2 Filter 추가
.oauth2Login(oauth -> oauth
.userInfoEndpoint(user -> user
.userService(customOAuth2UserService)
)
.successHandler(customSuccessHandler)
.failureHandler(customFailureHandler)
)
이런 식으로 추가한다.
여기에 OAuth2UserService와 SuccessHandler, FailureHandler가 들어간다.
.oauth2Login() 필터가 oauth2 로그인 전반을 설정하는 것이다.
/oauth2/authorization/kakao 가 들어왔을 때
Spring Security는 이를 감지하고 OAuth2 인증 흐름을 시작한다.
1. OAuth2AuthorizationRequestRedirectFilter 동작
/oauth2/authorization/kakao 이 요청이 들어오면 사용자를 해당 Provider에게 리디렉션을 해주는 역할
2. 사용자는 인증 후 리디렉션 되고, 브라우저는 다음 경로로 GET 요청을 보낸다.
여기에는 백엔드 서버가 인증서버로 보낼 Authorization Code가 들어있다.
3. OAuth2LoginAuthenticationFilter 동작
이 필터가 위에 인증 코드가 담긴 요청을 가로채서 처리함
내부적으로 OAuth2LoginAuthenticationProvider 호출하게 된다.
1. 인증코드를 인증 서버에 보내 Access Token을 받아온다.
2. 이 Access Token을 통해 사용자 정보를 받아온다.
3. 이때 내가 넣은 .userService()로 등록된 customOAuth2UserService가 동작한다.
4. customOAuth2UserService 호출
이 건 DefaultOAuth2UserService를 상속해 확장한 것이다.
이걸 호출하는 주체가 OAuth2LoginAuthenticationProvide이다.
여기에 들어있는
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken)authentication;
if (loginAuthenticationToken.getAuthorizationExchange().getAuthorizationRequest().getScopes().contains("openid")) {
return null;
} else {
OAuth2AuthorizationCodeAuthenticationToken authorizationCodeAuthenticationToken;
try {
authorizationCodeAuthenticationToken = (OAuth2AuthorizationCodeAuthenticationToken)this.authorizationCodeAuthenticationProvider.authenticate(new OAuth2AuthorizationCodeAuthenticationToken(loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange()));
} catch (OAuth2AuthorizationException var9) {
OAuth2AuthorizationException ex = var9;
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;
}
}
이 코드에서 Authentication 객체를 만들어준다.
Authentication을 만들기 위해
여기에서 customOAuth2UserService이걸 호출하는 것이다.
customOAuth2UserService이건 DefaultOAuth2UserService를 상속받은 것이고
그 내부에는
// DefaultOAuth2UserService 내부
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
...
}
private ResponseEntity<Map<String, Object>> getResponse(OAuth2UserRequest userRequest, RequestEntity<?> request) {
OAuth2Error oauth2Error;
try {
return this.restOperations.exchange(request, PARAMETERIZED_RESPONSE_TYPE);
} catch (OAuth2AuthorizationException var6) {
...
이렇게 yml에 작성된 인증 서버에서 userinfo를 보내달라는 요청을 보낸다. 그 후 response를 받아온다.
Map<String, Object> attributes = (Map)((Converter)this.attributesConverter.convert(userRequest)).convert((Map)response.getBody());
여기서 받아 온 response를 attributes로 만드는 것
여기에 attributes는 인증 플랫폼이 주는 JSON 형태 그대로를 받아온다.
그 형식은 플랫폼 마다 다르게 주기 때문에 여러 플랫폼은 연동하려면 이걸 맞춰주는 과정이 필요하다.
구글 응답 예시
{
"sub": "113409583049857309458",
"name": "홍길동",
"given_name": "길동",
"family_name": "홍",
"picture": "https://lh3.googleusercontent.com/a/abc123",
"email": "hongildong@gmail.com",
"email_verified": true,
"locale": "ko"
}
네이버 응답 예시
{
"resultcode": "00",
"message": "success",
"response": {
"id": "Qy0VwYkT63c8B8K7Jr1F6AGGJasUaw==",
"email": "hongildong@naver.com",
"name": "홍길동",
"nickname": "길동이",
"profile_image": "https://ssl.pstatic.net/static/pwe/address/img_profile.png",
"age": "20-29",
"gender": "M",
"birthday": "10-01"
}
}
카카오 응답 예시
{
"id": 123456789,
"connected_at": "2023-06-25T01:23:45Z",
"kakao_account": {
"profile_needs_agreement": false,
"profile": {
"nickname": "홍길동",
"thumbnail_image_url": "http://xxx.jpg",
"profile_image_url": "http://xxx.jpg"
},
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "hongildong@kakao.com"
}
}
이런 형식이다.
그걸 위해 CustomOAuth2UserService를 만들어야하는 것이다.
CustomOAuth2UserService
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
...
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
OAuth2User oauth2User = super.loadUser(userRequest);
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oauth2User.getAttributes());
...
}
DefaultOAuth2UserService가 JSON을 Attributes로 바꿔주는 로직은 그대로 써서 받아온다.
이걸 안하려면 내가 그걸 다 구현해줘야한다.
이제 받아온 걸 기반으로 각 플랫폼의 형식을 통일해줘 userInfo를 만들어주고
이걸 기반으로 DB에서 기존 유저인지 아닌지를 파악 후 OAuth2User를 반환해주면 되는 것이다.
그럼 OAuth2LoginAuthenticationProvider 내부에서 이렇게 만들어진 OAuth2User을 포함해 Accesstoken 등 다양한 인증정보를 가진 Authentication 객체를 만드는 것이다.
5. SuccessHandler & FailureHandler
이렇게 만들어진 Authentication 객체가 리턴될 때 token.setAuthenticated(true); 이렇게 인증이 됐는지 안됐는지 체크하는 필드가 있음 이게 True면 등록한 successHandler가 실행된다.
만약 OAuth2LoginAuthenticationProvider.authenticate() 안에서 예외(OAuth2AuthenticationException)가 발생하면 failureHandler가 실행되는 것이다.
실패할 때는 실패했다는걸 프론트로 보내주게 했고
성공 시에는 해당 정보를 통해 JWT를 만들어 프론트로 응답하게 했다.
마무리
교육 듣고 있는 것도 있고, 처음 다뤄본 부분이라 이걸 2주 가까이 걸렸다.
이전 프로젝트 내용도 참고하고, 내 친구(지피티)의 도움으로 연결할 수 있었고,
Spring Security가 특히 내부적으로 이렇게 저렇게 해주는 부분이 참 많아서 내부를 이해하는데,
조금은 힘들었다.