JWT 그런데 OAuth2.0 곁들인 (2)
2024. 11. 10. 16:14ㆍJava
이전 글은 https://minthug.tistory.com/5 이며 JWT 위주로 글이 작성되어 있습니다.
초기 로그인 요청 흐름
[사용자 로그인 요청]
↓
CustomOAuth2UserService.loadUser()
├─► super.loadUser(userRequest) // 기본 OAuth2 인증
├─► 소셜 로그인 정보 추출
│ ├─► registrationId 확인 (kakao/naver/google)
│ ├─► OAuthProvider.getOAuthProvider(registrationId)
│ └─► provider.getOAuthUserInfo(attributes)
│ // 각 제공자별 정보 추출 (extractKakaoUserInfo 등)
├─► RegisterUserCommand 생성
├─► memberService.getOrRegisterMember() // 회원가입 또는 조회
└─► new CustomOAuth2User(memberResponse, attributes)
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberService memberService;
@Override
@Transactional
public OAuth2User loadUser(final OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest); // 기본 OAuth2 인증
Map<String, Object> attributes = oAuth2User.getAttributes();
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // (kakao/naver/google)
OAuthUserInfo oAuthUserInfo = OAuthProvider.getOAuthProvider(registrationId)
.getOAuthUserInfo(attributes);
RegisterUserCommand registryUserCommand = RegisterUserCommand.of(
oAuthUserInfo.nickname(),
oAuthUserInfo.email(),
registrationId,
oAuthUserInfo.oAuthUserId(),
MemberRole.ROLE_OLDER,
MemberGrade.NORMAL);
RegisterMemberResponse memberResponse = memberService.getOrRegisterMember(registryUserCommand);
return new CustomOAuth2User(memberResponse, attributes);
}
}
@Builder
public record RegisterUserCommand(String nickname,
String email,
String provider,
String providerId,
MemberRole memberRole,
MemberGrade memberGrade) {
public static RegisterUserCommand of(final String nickname,
final String email,
final String provider,
final String providerId,
final MemberRole memberRole,
final MemberGrade memberGrade) {
return RegisterUserCommand.builder()
.nickname(nickname)
.email(email)
.provider(provider)
.providerId(providerId)
.memberRole(memberRole)
.memberGrade(memberGrade)
.build();
}
}
MemberService.class
@Transactional
public RegisterMemberResponse getOrRegisterMember(final RegisterUserCommand registryUserCommand) {
Member findMember = memberRepository.findByProviderAndProviderId(
registryUserCommand.provider(),
registryUserCommand.providerId())
.orElseGet(() -> {
Member member = Member.builder()
.nickname(registryUserCommand.nickname())
.email(registryUserCommand.email())
.provider(registryUserCommand.provider())
.providerId(registryUserCommand.providerId())
.memberRole(registryUserCommand.memberRole())
.memberGrade(registryUserCommand.memberGrade())
.build();
memberRepository.save(member);
return member;
});
return RegisterMemberResponse.from(findMember);
}
인증 성공 처리
OAuth2AuthenticationSuccessHandler.onAuthenticationSuccess()
├─► CustomOAuth2User 타입 확인
├─► CreateTokenCommand.from(memberResponse)
├─► tokenProvider.createToken() // JWT 토큰 생성
└─► sendAccessToken(response, accessToken)
public class OAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
@Override
public void onAuthenticationSuccess(final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication) throws ServletException, IOException {
if (authentication.getPrincipal() instanceof CustomOAuth2User customOAuth2User) {
CreateTokenCommand createTokenCommand = CreateTokenCommand.from(customOAuth2User.getMemberResponse());
String accessToken = tokenProvider.createToken(createTokenCommand);
sendAccessToken(response, accessToken);
} else {
super.onAuthenticationSuccess(request, response, authentication);
}
}
private void sendAccessToken(final HttpServletResponse response, final String accessToken) throws IOException {
response.setContentType("application/json");
response.setContentLength(accessToken.getBytes().length);
response.getWriter().write(accessToken);
}
}
토큰 갱신 프로세스 흐름
OAuthRestClient.refreshAccessTokenIfNotValid()
├─► 토큰 만료 확인
└─► callRefreshAccessToken()
├─► OAuthProvider 조회
├─► OAuth2AuthorizedClient 로드
├─► refreshAccessTokenRequest 생성
├─► 토큰 갱신 API 호출
└─► 새 토큰 저장
private void refreshAccessTokenIfNotValid(FindUserDetailResponse userDetailResponse,
OAuth2AuthorizedClient oAuth2AuthorizedClient) {
Instant expiresAt = oAuth2AuthorizedClient.getAccessToken().getExpiresAt();
if (expiresAt.isBefore(Instant.now())) {
callRefreshAccessToken(userDetailResponse);
}
}
위 메서드에서 토큰 만료를 확인하는 과정을 거친 뒤
public void callRefreshAccessToken(final FindUserDetailResponse userDetailResponse) {
OAuthProvider oAuthProvider = OAuthProvider.getOAuthProvider(userDetailResponse.provider());
OAuthHttpMessageProvider oAuthHttpMessageProvider = oAuthProvider.getOAuthHttpMessageProvider();
OAuth2AuthorizedClient oAuth2AuthorizedClient = authorizedClientService.loadAuthorizedClient(
userDetailResponse.provider(),
userDetailResponse.providerId());
OAuthHttpMessage refreshAccessTokenRequest = oAuthHttpMessageProvider.createRefreshAccessTokenRequest(oAuth2AuthorizedClient);
Map response = sendPostApiRequest(refreshAccessTokenRequest);
OAuth2AccessToken refreshedAccessToken = oAuthHttpMessageProvider.extractAccessToken(response);
OAuth2RefreshToken refreshedRefreshToken = oAuthHttpMessageProvider.extractRefreshToken(response)
.orElse(oAuth2AuthorizedClient.getRefreshToken());
OAuth2AuthorizedClient updatedAuthorizedClinet = new OAuth2AuthorizedClient(
oAuth2AuthorizedClient.getClientRegistration(),
oAuth2AuthorizedClient.getPrincipalName(),
refreshedAccessToken,
refreshedRefreshToken);
Authentication authenticationForTokenRefresh = getAuthenticationForTokenRefresh(updatedAuthorizedClinet);
authorizedClientService.saveAuthorizedClient(
updatedAuthorizedClinet,
authenticationForTokenRefresh);
}
제공자별 메시지 처리
GoogleMessageProvider
├─► createUnlinkUserRequest() // 연동 해제
│ ├─► 액세스 토큰 추출
│ └─► HTTP 요청 메시지 생성
│
├─► createRefreshAccessTokenRequest() // 토큰 갱신
│ ├─► HTTP 헤더/바디 생성
│ └─► 클라이언트 정보 포함
│
└─► extractAccessToken()/extractRefreshToken()
// 응답에서 토큰 정보 추출
Provider 별로 MessageProvider를 생성해놨는데
public interface OAuthHttpMessageProvider {
OAuthHttpMessage createUnlinkUserRequest(final FindUserDetailResponse userDetailResponse,
final OAuth2AuthorizedClient oAuth2AuthorizedClient);
void verifySuccessUnlinkUserRequest(Map<String, Object> unlinkResponse);
OAuthHttpMessage createRefreshAccessTokenRequest(OAuth2AuthorizedClient authorizedClient);
OAuth2AccessToken extractAccessToken(Map response);
Optional<OAuth2RefreshToken> extractRefreshToken(Map response);
}
카카오 네이버 구글 세 가지 모두 비슷한 형태를 취하고있지만 요구하는 사항이 살짝 다르다보니 그건 주의해서 작성하시면 될 거 같습니다.
연동 해제 프로세스
OAuthRestClient.callUnlinkOAuthUser()
├─► OAuthProvider 조회
├─► OAuth2AuthorizedClient 로드
├─► 토큰 유효성 검사 및 갱신
├─► unlink 요청 생성 및 전송
├─► 응답 검증
└─► 인증 정보 제거
public void callUnlinkOAuthUser(final FindUserDetailResponse userDetailResponse) {
OAuthProvider oAuthProvider = OAuthProvider.getOAuthProvider(userDetailResponse.provider());
OAuthHttpMessageProvider oAuthHttpMessageProvider = oAuthProvider.getOAuthHttpMessageProvider();
OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(
userDetailResponse.provider(),
userDetailResponse.providerId());
refreshAccessTokenIfNotValid(userDetailResponse, authorizedClient);
OAuthHttpMessage unlinkHttpMessage = oAuthHttpMessageProvider.createUnlinkUserRequest(userDetailResponse, authorizedClient);
Map<String, Object> response = sendPostApiRequest(unlinkHttpMessage);
log.info("회원의 연결이 종료되었습니다. 회원 ID={}", response);
oAuthHttpMessageProvider.verifySuccessUnlinkUserRequest(response);
authorizedClientService.removeAuthorizedClient(
userDetailResponse.provider(),
userDetailResponse.providerId());
}
데이터 구조
1. 소셜 로그인 사용자 정보
public record OAuthUserInfo(String oAuthUserId, String nickname, String email) {
}
OAuthUserID // 소셜 ID
nickname // 닉네임
email // 이메일
2. HTTP 요청 메시지
public record OAuthHttpMessage (String uri,
HttpEntity<MultiValueMap<String, String>> httpMessage,
Map<String, String> uriVariables) {
}
String uri, // 요청 URI
HttpEntity<...> message, // HTTP 메시지
Map<String, String> variables // URI 변수
Provider 열거형 구조
public enum OAuthProvider {
KAKAO("kakao", OAuthProvider::extractKakaoUserInfo, new KakaoMessageProvider()),
NAVER("naver", OAuthProvider::extractNaverUserInfo, new NaverMessageProvider()),
GOOGLE("google", OAuthProvider::extractGoogleUserInfo, new GoogleMessageProvider());
private static final Map<String, OAuthProvider> PROVIDER = Collections.unmodifiableMap(Stream.of(values())
.collect(Collectors.toMap(OAuthProvider::getName, Function.identity())));
private final String name;
private final Function<Map<String, Object>, OAuthUserInfo> extractUserInfo;
private final OAuthHttpMessageProvider oAuthHttpMessageProvider;
private static OAuthUserInfo extractNaverUserInfo(Map<String, Object> attributes) {
try {
Map<String, String> response = (Map<String, String>) attributes.get("response");
String oAuthUserId = response.get("id");
String nickname = response.get("nickname");
String email = response.get("email");
return new OAuthUserInfo(oAuthUserId, nickname, email);
} catch (Exception e) {
log.error("Failed to extract Naver user info: {}", e.getMessage());
throw new OAuthUserInfoExtractException("네이버 사용자 정보 추출 실패");
}
}
private static OAuthUserInfo extractKakaoUserInfo(Map<String, Object> attributes) {
try {
Map<String, String> properties = (Map<String, String>) attributes.get("properties");
String oAuthUserId = String.valueOf(attributes.get("id"));
String nickname = properties.get("nickname");
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
String email = String.valueOf(kakaoAccount.get("email"));
return new OAuthUserInfo(oAuthUserId, nickname, email);
} catch (Exception e) {
log.error("Failed to extract Kakao user info: {}", e.getMessage());
throw new OAuthUserInfoExtractException("카카오 사용자 정보 추출 실패");
}
}
private static OAuthUserInfo extractGoogleUserInfo(Map<String, Object> attributes) {
try {
String oAUthUserId = (String) attributes.get("id");
String nickname = (String) attributes.get("nickname"); // or "Given_name"
String email = (String) attributes.get("email");
return new OAuthUserInfo(oAUthUserId, nickname, email);
} catch (Exception e) {
log.error("Failed to extract Google user info: {}", e.getMessage());
throw new OAuthUserInfoExtractException("구글 사용자 정보 추출 실패");
}
}
public static OAuthProvider getOAuthProvider(final String provider) {
OAuthProvider oAuthProvider = PROVIDER.get(provider);
return Optional.ofNullable(oAuthProvider)
.orElseThrow(() -> new InvalidProviderException("지원하지 않는 소셜 로그인 입니다"));
}
public OAuthUserInfo getOAuthUserInfo(final Map<String, Object> attributes) {
return this.extractUserInfo.apply(attributes);
}
}
이렇게 구성되어있는데
장/단점을 보유하고 있어서 언급을 하고 넘어가야 할 거 같다.
코드의 장점이라 하면
Function 인터페이스를 사용해 별도 인터페이스/클래스 정의 불필요
static 메서드로 구현하여 메모리 효율성 향상
코드 간결성 유지
단점:
각 추출 로직이 열거형 내부에 있어 단일 책임 원칙 위배
테스트가 어려움 (메서드 분리가 안 되어 있음)
새로운 제공자 추가 시 열거형 수정 필요
추후 단일 책임 원칙 위배하는 부분부터 수정 후 좀 더 큰 프로젝트에 사용할 때 문제가 발생하지 않게 수정일 필요할 거같다.
상수 관리
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class OAuthConstant {
public static final String CLIENT_ID = "client_Id";
public static final String CLIENT_SECRET = "client_secret";
public static final String ACCESS_TOKEN = "access_token";
...
이렇게 상수관리하는데 물론 더 좋게 할 수 있지만 아마도 여기서 OAuth2 관련된 소셜 기능은 늘어나지 않을거같아서 이대로 사용해도 무방해보인다.
이렇게 1편과 2편으로 구성되어 있는데 문제 없이 작동은 하고 있지만 아직 개선이 필요한 부분이 여러 곳 존재하며,
다른 개발자들이 보았을때 부족한 점이 있겠지만 댓글로 지적해주시면 감사하겠습니다.
'Java' 카테고리의 다른 글
Kafka 적응기 (1) | 2024.11.13 |
---|---|
Controller Restful 관련 (0) | 2024.11.11 |
JWT 그런데 OAuth2.0 곁들인 (1) | 2024.11.08 |
SSE(Server-Sent Events) (0) | 2024.11.06 |
정적 팩토리 메서드 네이밍의 차이 (of vs from) (0) | 2024.11.04 |