JWT 그런데 OAuth2.0 곁들인 (2)

2024. 11. 10. 16:14Java

이전 글은 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