Skip to content

Feat/#208 social login & mypage api url 변경#238

Merged
oculo0204 merged 6 commits intodevelopfrom
feat/#208-social-login
Feb 23, 2026
Merged

Feat/#208 social login & mypage api url 변경#238
oculo0204 merged 6 commits intodevelopfrom
feat/#208-social-login

Conversation

@oculo0204
Copy link
Collaborator

@oculo0204 oculo0204 commented Feb 23, 2026

🔗 관련 이슈

#208


📌 작업 내용

OAuth2 로그인 성공 후 딥링크 리다이렉트 URL 구조를 개선하고, 기존 targetPath 세션 로직을 제거했습니다.

1️⃣ Non-Functional Requirement

  • 딥링크 URL 구조 표준화: 기존 /auth?path=...&token=... → 신규 /auth?provider=...&result=...&status=...&accessToken=...&refreshToken=... 형태로 변경
  • 세션 의존성 제거: OAuth2SuccessHandler에서 oauth_target_path 세션 속성 사용 로직 삭제

2️⃣ Functional Requirement

  • 딥링크 파라미터 확장:

    • provider (kakao/google/apple)
    • result (SUCCESS/FAIL)
    • status (ACTIVE/TEMP/DELETED)
    • accessToken, refreshToken (status=ACTIVE일 때)
    • socialToken (status=TEMP일 때)
    • error, errorMessage (result=FAIL일 때)
  • targetPath 로직 제거 이유:

    1. 새 URL 구조에 path 파라미터 자리가 없음
    2. 모바일 딥링크 방식에서는 앱이 네비게이션 스택을 자체 관리하므로, 서버가 path를 전달할 필요 없음
    3. OAuthController.javaoauth_target_path 세션 저장 로직도 함께 제거됨
  • mypage api url변경


참고

1️⃣ ACTIVE (기존 회원 로그인 성공)
GET https://linkuserver.store/auth

쿼리 파라미터:

provider=kakao

result=SUCCESS

status=ACTIVE

accessToken=... ← 기존 그대로 유지

refreshToken=... ← 신규 추가 (앱 자동 로그인용)

2️⃣ TEMP (신규 회원, 추가 정보 입력 필요)
GET https://linkuserver.store/auth

쿼리 파라미터:

provider=kakao

result=SUCCESS

status=TEMP

socialToken=... ← 기존 그대로 유지

userId는 URL에 포함하지 않음

이후 PATCH /api/users/social/complete 응답에서 userId를 내려받아 사용 (URL 보안 이슈 회피)

3️⃣ FAIL (로그인 실패)
GET https://linkuserver.store/auth

쿼리 파라미터:

provider=kakao

result=FAIL

errorCode=... ← 기존 유지

Summary by CodeRabbit

  • 새로운 기능

    • OAuth 로그인 후 사용자 상태(TEMP/ACTIVE)에 따른 맞춤 리디렉션 및 토큰 발급 흐름 추가
    • 로그인 실패 또는 오류 시 상태(FAIL)로의 명확한 리디렉션 및 에러 코드 전달
  • 개선 사항

    • 소셜 로그인 시 제공자 정보 기반 딥링크 리디렉션 지원
    • 갱신 토큰 관리 강화(기존 토큰 무효화 및 신규 토큰 적용)
    • 사용자 프로필 조회 API를 /me 엔드포인트로 단순화

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 23, 2026

Warning

Rate limit exceeded

@oculo0204 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 2 minutes and 47 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

OAuth2 성공 핸들러가 사용자 상태(TEMP/ACTIVE)에 따른 토큰 발급·저장·딥링크 리다이렉트로 대체되었고, 사용자 정보 조회 엔드포인트가 경로 기반에서 인증된 사용자 대상의 /me로 변경되었습니다.

Changes

Cohort / File(s) Summary
OAuth2 토큰·리다이렉트 처리
src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java
OAuth2 성공 콜백에서 제공자 추출, 이메일로 사용자 조회, 상태별 분기(TEMP: 액세스/소셜 토큰, ACTIVE: 액세스+리프레시 토큰), 기존 리프레시 토큰 삭제 및 HMAC 해시 저장, 리다이렉트 URI 구성 및 오류 분기 로직 추가. 설정값(refresh TTL, HMAC 시크릿) 주입.
사용자 컨트롤러 경로 변경
src/main/java/com/umc/linkyou/web/controller/UserController.java
GET /{userId} 엔드포인트를 인증 사용자 전용 GET /me로 변경. @PathVariable 제거하고 @AuthenticationPrincipal로 사용자 ID 획득.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Handler as OAuth2SuccessHandler
    participant UserRepo as UserRepository
    participant TokenSvc as TokenService
    participant RefreshRepo as UserRefreshTokenRepository
    participant Redirect as Redirect Response

    Client->>Handler: OAuth2 성공 콜백 요청
    Handler->>Handler: 요청 URI에서 provider 추출
    Handler->>UserRepo: 이메일로 사용자 조회
    UserRepo-->>Handler: 사용자(+상태)

    alt 상태 == TEMP
        Handler->>TokenSvc: 액세스(소셜) 토큰 발급 요청
        TokenSvc-->>Handler: socialToken
        Handler->>Redirect: deep-link 리다이렉트 (status=TEMP, socialToken)
    else 상태 == ACTIVE
        Handler->>TokenSvc: 액세스+리프레시 토큰 발급 요청
        TokenSvc-->>Handler: accessToken + refreshToken
        Handler->>RefreshRepo: 기존 리프레시 토큰 삭제
        Handler->>Handler: refreshToken HMAC 해시 생성
        Handler->>RefreshRepo: 해시된 리프레시 토큰 저장 (TTL)
        RefreshRepo-->>Handler: 저장 확인
        Handler->>Redirect: deep-link 리다이렉트 (status=ACTIVE, tokens)
    else 오류/없음
        Handler->>Redirect: deep-link 리다이렉트 (status=FAIL, errorCode)
    end

    Redirect-->>Client: HTTP 리다이렉트 응답
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Feat/#208 social login #229: OAuth2SuccessHandler의 빈 초기화(@Lazy) 및 토큰 관련 클라이언트 변경으로 본 PR의 OAuth2 흐름 수정과 직접 연관
  • feat: #208 add deeplink #224: 초기 딥링크 리다이렉트 및 OAuth2SuccessHandler 도입 PR로, 본 PR에서 제공자 파싱·토큰 저장·상태 분기 등으로 확장됨

Poem

🐰 소셜문 열리니 깡충 뛰어들고,
토큰 한줌, 리프레시 한줌 비밀로 감추고,
HMAC으로 꿀꺽 숨은 단서를 묶고,
"/me"는 내 발자국 소곤대고,
개발자들 웃으며 배포하네. 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately reflects the main changes: OAuth2 social login deep-link URL restructuring and mypage API endpoint change from path variable to '/me'.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#208-social-login

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java (1)

103-108: provider 값이 검증 없이 리다이렉트 URL에 삽입됩니다.

extractProvider가 URI의 마지막 세그먼트를 그대로 반환하고, 이 값이 URL 인코딩이나 허용 목록 검증 없이 리다이렉트 URL에 직접 삽입됩니다. Spring Security가 OAuth2 콜백 URI를 제어하므로 실질적 위험은 낮지만, 방어적으로 허용된 provider 목록과 대조하는 것이 안전합니다.

♻️ Provider 검증 추가 제안
+    private static final Set<String> ALLOWED_PROVIDERS = Set.of("kakao", "google", "apple", "naver");
+
     /** /login/oauth2/code/{provider} URI에서 provider 추출 */
     private String extractProvider(String requestUri) {
         if (requestUri == null) return "unknown";
         String[] parts = requestUri.split("/");
-        return parts[parts.length - 1]; // 마지막 세그먼트
+        String candidate = parts[parts.length - 1];
+        return ALLOWED_PROVIDERS.contains(candidate) ? candidate : "unknown";
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java` around lines
103 - 108, extractProvider currently returns the URI's last segment unchecked
and that value is later used in redirect URLs; validate it against a whitelist
of allowed providers (e.g., a Set<String> like allowedProviders) and reject or
default to "unknown" if not present, and URL-encode the validated provider
before inserting into any redirect URI; update the extractProvider method to
perform both the allowlist check and encoding (or move validation to the caller
that builds the redirect) and reference the extractProvider symbol so reviewers
can find and update the redirect logic that consumes its output.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java`:
- Around line 58-96: The deep-link query params in OAuth2SuccessHandler do not
match the PR spec: update the TEMP branch (where socialToken is produced) to use
status=NEEDS_ONBOARDING, result=FAILURE→(for failure branch keep as FAILURE if
PR requires) — specifically rename the TEMP branch's "status=TEMP" to
"status=NEEDS_ONBOARDING" and the token param "socialToken" to "tempToken"; also
change the failure branch's "result=FAIL" to "result=FAILURE" to match the PR;
modify the redirectUrl String.format calls in the TEMP (user.getStatus() ==
UserStatus.TEMP), ACTIVE (if any param naming differs), and else branches in
OAuth2SuccessHandler so all query parameter names match the PR spec.
- Around line 98-100: The log in OAuth2SuccessHandler that prints
redirectUrl.split("&accessToken")[0], email, and user.getStatus() can leak
tokens and PII for TEMP users because socialToken may not match the split;
update the logging to never print raw tokens or email: locate the redirectUrl
variable and replace the current log call with a masked version that strips or
replaces any token parameters (e.g., remove parameters named accessToken or
socialToken or replace their values with a fixed mask) and replace email with a
non-PII identifier (e.g., user.getId() or "REDACTED"); implement or call a
helper (maskToken/maskQueryParams or maskEmail) to centralize masking and use
user.getStatus() for status only.
- Around line 110-119: The hmac method uses platform-default charset when
calling hmacSecret.getBytes() and token.getBytes(), which can produce
inconsistent HMACs; update the hmac(String token) implementation (the hmac
method that uses javax.crypto.Mac and hmacSecret) to call
getBytes(StandardCharsets.UTF_8) for both hmacSecret and token so the HMAC is
computed deterministically using UTF-8.
- Around line 52-54: The current onAuthenticationSuccess in OAuth2SuccessHandler
calls userRepository.findByEmail(...).orElseThrow(...) which will propagate an
IllegalStateException and surface a 500 to the user; change this to handle the
missing Users record defensively: replace the orElseThrow with a safe branch
that logs a warning (referencing OAuth2SuccessHandler and
userRepository.findByEmail/Users), then perform the existing failure deep-link
redirect (use the same redirect logic you use for other failures, e.g.
redirectStrategy.sendRedirect or response.sendRedirect to the configured failure
URL) and return, instead of throwing; ensure no exception bubbles out of
onAuthenticationSuccess.

---

Nitpick comments:
In `@src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java`:
- Around line 103-108: extractProvider currently returns the URI's last segment
unchecked and that value is later used in redirect URLs; validate it against a
whitelist of allowed providers (e.g., a Set<String> like allowedProviders) and
reject or default to "unknown" if not present, and URL-encode the validated
provider before inserting into any redirect URI; update the extractProvider
method to perform both the allowlist check and encoding (or move validation to
the caller that builds the redirect) and reference the extractProvider symbol so
reviewers can find and update the redirect logic that consumes its output.

Comment on lines 74 to 79
// refreshToken 화이트리스트 저장 (기존 토큰 교체)
userRefreshTokenRepository.findByUserId(user.getId())
.ifPresent(t -> userRefreshTokenRepository.deleteById(t.getRefreshToken()));

// path를 통한 보안문제
if (targetPath != null &&
(!targetPath.startsWith("/") || targetPath.length() > 100)) {
targetPath = null; // 안전하지 않음
}
String id = hmac(jwtTokenProvider.normalizeStrict(refreshToken));
userRefreshTokenRepository.save(new UserRefreshToken(id, user.getId(), refreshTtlMs));
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for UserRefreshToken entity and repository files
echo "=== Searching for UserRefreshToken.java ==="
fd "UserRefreshToken.java" --type f

echo -e "\n=== Searching for UserRefreshTokenRepository.java ==="
fd "UserRefreshTokenRepository.java" --type f

Repository: LinkYou-2025/LinkU_backend

Length of output: 300


🏁 Script executed:

#!/bin/bash
# Read UserRefreshToken entity
echo "=== UserRefreshToken.java ==="
cat -n src/main/java/com/umc/linkyou/domain/UserRefreshToken.java

echo -e "\n=== UserRefreshTokenRepository.java ==="
cat -n src/main/java/com/umc/linkyou/repository/UserRefreshTokenRepository.java

# Also check the OAuth2SuccessHandler context around the code in question
echo -e "\n=== OAuth2SuccessHandler.java (lines 70-85) ==="
sed -n '70,85p' src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java | cat -n

Repository: LinkYou-2025/LinkU_backend

Length of output: 2760


🏁 Script executed:

#!/bin/bash
# Verify if OAuth2SuccessHandler has `@Transactional` annotation on the method
echo "=== Checking OAuth2SuccessHandler method annotations ==="
sed -n '50,85p' src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java | cat -n

# Check imports in OAuth2SuccessHandler
echo -e "\n=== Checking imports ==="
head -30 src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java | grep -E "import|@"

Repository: LinkYou-2025/LinkU_backend

Length of output: 2972


동시 로그인 시 갱신 토큰 경쟁 상태(race condition) 문제가 있습니다.

현재 코드는 findByUserIddeleteByIdsave 작업이 동기화 없이 순차적으로 실행됩니다. 동일 사용자가 동시에 로그인할 때 두 스레드가 모두 같은 토큰을 조회한 후 각각 다른 새 토큰을 저장하게 되어, 마지막 저장만 남고 첫 번째 토큰이 손실됩니다. 이는 repository 주석의 "단일 세션 모델"을 위반합니다.

참고: deleteById(t.getRefreshToken())에서 getRefreshToken()@Id 필드를 올바르게 참조하므로 이 부분은 정상입니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#239 에서 앞으로 해결할 예정. 지금은 서버에 영향이 크게 없음

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if issue `#239` exists and get its details
gh issue view 239 --repo LinkYou-2025/LinkU_backend --json title,body,state,url

Length of output: 2681


@oculo0204 알겠습니다! #239에서 체계적으로 해결하시는 것이 좋겠네요. 해당 이슈에 문제 상황과 해결 방안이 잘 정리되어 있습니다. 초기 단계에서는 말씀하신 대로 실질적인 영향이 제한적일 것으로 보입니다. 👍


🧬 Code Graph Analysis Results

<src/main/java/com/umc/linkyou/oauth/OAuth2UserServiceImpl.java> (lines 33-235)

`@Slf4j`
`@Service`
`@RequiredArgsConstructor`
`@Transactional`
public class OAuth2UserServiceImpl implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository usersRepository;
    private final AuthAccountRepository authAccountRepository;

    private final GoogleUserInfoExtractor googleUserInfoExtractor;
    private final KakaoUserInfoExtractor kakaoUserInfoExtractor;
    private final NaverUserInfoExtractor naverUserInfoExtractor;

    private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();

    `@PersistenceContext`
    private EntityManager entityManager;

    //소셜 api에서 받은 OAuth 객체
    `@Override`
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 어느 소셜에서 보내는 지 확인
        // Spring Security에서 파싱한다.
        // 프론트가 OAuthConroller로 보낸다 -> 리다이렉트: /oauth2/authorization/{registrationId} → registrationId="kakao"
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        Provider provider = getProvider(registrationId);

        // 소셜 API 요청 및 JSON Parsing
        OAuth2User oAuth2User = delegate.loadUser(userRequest); //리다이렉트 요청이 들어오면 그걸 받아서 소셜 API호출
        OAuth2UserInfoExtractor extractor = getExtractor(provider); //어떤 소셜인지에 따라 json파서 선택

        //parsing된 값들
        String externalId = extractor.getExternalId(oAuth2User);
        String email = extractor.getEmail(oAuth2User);
        String name = extractor.getName(oAuth2User);
        String profileImage = extractor.getProfileImage(oAuth2User);

        // 이메일 검증
        if (email == null || email.isBlank()) {
            throw new GeneralException(ErrorStatus._SOCIAL_EMAIL_REQUIRED);  // 모든 소셜에서 이메일 필수!
        }
        // 3가지 케이스에 따라 다르게 DB 접근 및 저장
        // 케이스1) 소셜로그인한적이 있다. 새로 User, AuthAccount객체를 생성하지 않는다.
        // 케이스2) JWT일반로그인이나 다른 소셜로그인을 한적 있지만 해당 소셜로그인을 한적 없다. 기존 User에 AuthAccount 객체를 생성하여 연결한다.
        // 케이스3) JWT일반 로그인도 소셜로그로그인도 한 적이 없다. User, AuthAccount객체 모두 생성한다.
        Users user = findOrCreateUser(email, name, provider, externalId, userRequest, profileImage);
        boolean isNewUser = user.getStatus() == UserStatus.TEMP;

        //OAuth 객체 반환
        return createCustomOAuth2User(user, oAuth2User, isNewUser);
    }


    /**
     * 모든 사용자 처리 케이스를 한 메서드로!
     */
    private Users findOrCreateUser(String email, String name, Provider provider,
                                   String externalId, OAuth2UserRequest userRequest, String profileImage) {
        // 케이스 1: 이미 이 소셜 계정으로 로그인한 적 있음 (재로그인)
        Optional<AuthAccount> authAccountOpt = authAccountRepository.findByProviderAndExternalId(provider, externalId);
        if (authAccountOpt.isPresent()) {
            return updateExistingUser(authAccountOpt.get(), name, profileImage, userRequest);
        }

        // 케이스 2: 이메일은 있지만 다른 소셜 계정 (소셜 연결)
        Optional<Users> existingUserOpt = usersRepository.findByEmail(email);
        if (existingUserOpt.isPresent()) {
            Users user = existingUserOpt.get();
            log.info("기존 사용자에 소셜 계정 연결: userId={}, provider={}, email={}",
                    user.getId(), provider, email);
            createAuthAccount(user, provider, externalId, userRequest, profileImage);
            return user;
        }

        // 케이스 3: 완전 새로운 사용자
        log.info("새 소셜 사용자 생성: provider={}, email={}", provider, email);
        return createNewUserWithAccount(email, name, provider, externalId, userRequest, profileImage);
    }
    /** 1. 재로그인: 닉네임/프로필/토큰 업데이트 */
    private Users updateExistingUser(AuthAccount authAccount, String name, String profileImage, OAuth2UserRequest userRequest) {
        Users user = authAccount.getUser();

        // 닉네임 업데이트 (Users 공통, 중복 체크)
        if (name != null && !name.equals(user.getNickName()) && !usersRepository.existsByNickName(name)) {
            user.setNickName(name);
            log.info("기존 사용자 닉네임 업데이트: id={}, old={}, new={}",
                    user.getId(), user.getNickName(), name);
        }

        // 프로필 이미지 업데이트 (AuthAccount 소셜별)
        if (profileImage != null && !profileImage.equals(authAccount.getProfileImage())) {
            authAccount.updateProfileImage(profileImage);
            log.info("소셜 프로필 이미지 업데이트: provider={}, userId={}",
                    authAccount.getProvider(), user.getId(), profileImage);
        }

        authAccount.updateToken(userRequest.getAccessToken().getTokenValue());
        return user;
    }
    /** 2. 이메일이 있지만 다른 소셜로그인 계정 + 다른 곳에서도 쓰는 메서드*/
    private AuthAccount createAuthAccount(Users user, Provider provider, String externalId, OAuth2UserRequest userRequest, String profileImage) {
        try {
            return authAccountRepository.save(
                    AuthAccount.builder()
                            .user(user)
                            .provider(provider)
                            .externalId(externalId)
                            .profileImage(profileImage)
                            .socialToken(userRequest.getAccessToken().getTokenValue())
                            .build()
            );
        } catch (Exception e) {
            log.error("AuthAccount 저장 실패: user.id={}, provider={}", user.getId(), provider, e);
            throw new GeneralException(ErrorStatus._AUTH_ACCOUNT_SAVE_FAILED);
        }
    }
    private Users createNewUser(String email, String name) {
        try {
            String nickname;
            if (name != null && !name.isBlank()) {
                nickname = name;
            } else {
                // email@domain.com → domain
                String domain = email.substring(email.indexOf("@") + 1);
                nickname = domain.replaceAll("[^a-zA-Z0-9]", "").toLowerCase();
                if (nickname.isBlank()) nickname = "user";
            }

            // 최대 3회 시도 (중복시 다음으로 계속)
            for (int i = 0; i < 3; i++) {
                String finalNickname = i == 0 ? nickname : nickname + "_" + i;

                Users user = Users.builder()
                        .email(email)
                        .password(null)
                        .nickName(finalNickname)
                        .gender(null)
                        .role(Role.USER)
                        .status(UserStatus.TEMP)
                        .build();

                try {
                    Users savedUser = usersRepository.saveAndFlush(user);
                    log.info("소셜 사용자 생성: id={}, nickName={}, from={}",
                            savedUser.getId(), finalNickname, name != null ? "name" : "email");
                    entityManager.flush();
                    return savedUser;
                } catch (DataIntegrityViolationException e) {
                    log.warn("닉네임 중복 (시도 {}/3): {}", i + 1, finalNickname);
                    if (i == 2) {
                        log.error("닉네임 생성 완전 실패: email={}, 모든 후보 중복", email);
                        throw new GeneralException(ErrorStatus._DUPLICATE_NICKNAME);
                    }
                }
            }
            throw new GeneralException(ErrorStatus._DUPLICATE_NICKNAME);
        } catch (Exception e) {
            log.error("사용자 생성 실패: email={}", email, e);
            throw new GeneralException(ErrorStatus._USER_SOCIAL_CREATION_FAILED);
        }
    }

    /** 3.새 사용자 + AuthAccount 일괄 생성*/
    private Users createNewUserWithAccount(String email, String name, Provider provider,
                                           String externalId, OAuth2UserRequest userRequest, String profileImage) {
        Users user = createNewUser(email, name);  // 기존 메서드 재사용
        createAuthAccount(user, provider, externalId, userRequest, profileImage);
        return user;
    }

    /**Spring Security에 저잫할 Oauth 객체 생성*/
    private CustomOAuth2User createCustomOAuth2User(Users user, OAuth2User oAuth2User, boolean isNewUser) {
        Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes());
        if (isNewUser) {
            attributes.put("needsTermsAgreement", true);
        }
        return new CustomOAuth2User(
                Collections.singletonList(new SimpleGrantedAuthority(user.getRole().name())),
                attributes, "email", user.getEmail()
        );
    }



    private Provider getProvider(String registrationId) {
        return switch (registrationId.toLowerCase()) {
            case "google" -> Provider.GOOGLE;
            case "kakao" -> Provider.KAKAO;
            case "naver" -> Provider.NAVER;

            default -> throw new GeneralException(ErrorStatus._SOCIAL_UNSUPPORTED_PROVIDER);
        };
    }

    private OAuth2UserInfoExtractor getExtractor(Provider provider) {
        return switch (provider) {
            case GOOGLE -> googleUserInfoExtractor;
            case KAKAO -> kakaoUserInfoExtractor;
            case NAVER -> naverUserInfoExtractor;
            default -> throw new GeneralException(ErrorStatus._SOCIAL_UNSUPPORTED_PROVIDER);
        };
    }
}

<src/main/java/com/umc/linkyou/oauth/utils/GoogleUserInfoExtractor.java> (lines 7-29)

`@Component`
public class GoogleUserInfoExtractor implements OAuth2UserInfoExtractor {

    `@Override`
    public String getExternalId(OAuth2User oAuth2User) {
        return oAuth2User.getAttribute("sub");
    }

    `@Override`
    public String getEmail(OAuth2User oAuth2User) {
        return oAuth2User.getAttribute("email");
    }

    `@Override`
    public String getName(OAuth2User oAuth2User) {
        return oAuth2User.getAttribute("name");
    }

    `@Override`
    public String getProfileImage(OAuth2User oAuth2User) {
        return oAuth2User.getAttribute("picture");
    }
}

<src/main/java/com/umc/linkyou/oauth/utils/KakaoUserInfoExtractor.java> (lines 9-42)

`@Component`
public class KakaoUserInfoExtractor implements OAuth2UserInfoExtractor {

    `@Override`
    public String getExternalId(OAuth2User oAuth2User) {
        return Objects.requireNonNull(oAuth2User.getAttribute("id")).toString();
    }

    `@Override`
    public String getEmail(OAuth2User oAuth2User) {
        Map<String, Object> kakaoAccount = oAuth2User.getAttribute("kakao_account");
        if (kakaoAccount != null) {
            return (String) kakaoAccount.get("email");
        }
        return null;
    }

    `@Override`
    public String getName(OAuth2User oAuth2User) {
        Map<String, Object> properties = oAuth2User.getAttribute("properties");
        if (properties != null) {
            return (String) properties.get("nickname");
        }
        return null;
    }
    `@Override`
    public String getProfileImage(OAuth2User oAuth2User) {
        Map<String, Object> properties = oAuth2User.getAttribute("properties");
        if (properties != null) {
            return (String) properties.get("profile_image");
        }
        return null;
    }
}

<src/main/java/com/umc/linkyou/oauth/utils/NaverUserInfoExtractor.java> (lines 8-50)

`@Component`
public class NaverUserInfoExtractor implements OAuth2UserInfoExtractor {

    `@Override`
    public String getExternalId(OAuth2User oAuth2User) {
        Map<String, Object> response = oAuth2User.getAttribute("response");
        if (response != null) {
            Object id = response.get("id");
            if (id == null) {
                throw new IllegalStateException("Naver 응답에 id가 없습니다");
            }
            return id.toString();
        }
        throw new IllegalStateException("Naver 응답 데이터가 없습니다");
    }

    `@Override`
    public String getEmail(OAuth2User oAuth2User) {
        Map<String, Object> response = oAuth2User.getAttribute("response");
        if (response != null) {
            return (String) response.get("email");
        }
        return null;
    }

    `@Override`
    public String getName(OAuth2User oAuth2User) {
        Map<String, Object> response = oAuth2User.getAttribute("response");
        if (response != null) {
            return (String) response.get("nickname");
        }
        return null;
    }

    `@Override`
    public String getProfileImage(OAuth2User oAuth2User) {
        Map<String, Object> response = oAuth2User.getAttribute("response");
        if (response != null) {
            return (String) response.get("profile_image");  // http://static.naver.net/...
        }
        return null;
    }
}

<src/main/java/com/umc/linkyou/config/security/jwt/JwtTokenProvider.java> (lines 32-201)

`@Slf4j`
`@Component`
`@RequiredArgsConstructor`
public class JwtTokenProvider {

    private final JwtProperties jwtProperties;

    private final UserRepository userRepository;

    private final ObjectMapper objectMapper = new ObjectMapper();

    private final UserRefreshTokenRepository userRefreshTokenRepository;

    `@Value`("${jwt.hmac-secret}")
    private String hmacSecret;

    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(jwtProperties.getKeys().getAccess().getBytes());
    }

    public String generateToken(Authentication authentication) {
        String email = authentication.getName();

        return Jwts.builder()
                .setSubject(email)
                .claim("role", authentication.getAuthorities().iterator.next().getAuthority())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().getAccess()))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            validateAndParseAccess(token);
            return true;
        } catch (ExpiredJwtException e) {
            // AccessToken이 만료된 경우 예외를 던져서 필터에서 처리하게 함
            throw e;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public Authentication getAuthentication(String token) {
        Claims claims = validateAndParseAccess(token).getBody();
        String email = claims.getSubject();
        // String role = claims.get("role", String.class);

        // Users users = ... // 이메일로 Users 엔티티 조회
        Users users = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 가진 유저가 존재하지 않습니다: " + email));
        CustomUserDetails principal = new CustomUserDetails(users);

        return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
    }

    public static String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(Constants.AUTH_HEADER);
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) {
            return bearerToken.substring(Constants.TOKEN_PREFIX.length());
        }
        return null;
    }

    public Authentication extractAuthentication(HttpServletRequest request){
        String accessToken = resolveToken(request);
        if(accessToken == null || !validateToken(accessToken)) {
            throw new UserHandler(ErrorStatus._INVALID_TOKEN);
        }
        return getAuthentication(accessToken);
    }

    // 리프레시 토큰 발급
    public String createRefreshToken(String subjectEmail) {
        return Jwts.builder()
                .setSubject(subjectEmail)
                .signWith(Keys.hmacShaKeyFor(jwtProperties.getKeys().getRefresh().getBytes()), SignatureAlgorithm.HS256)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh()))
                .compact();
    }

    private String hmac(String token) {
        try {
            var mac = javax.crypto.Mac.getInstance("HmacSHA256");
            mac.init(new javax.crypto.spec.SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8),  "HmacSHA256"));
            return java.util.HexFormat.of().formatHex(mac.doFinal(token.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) { throw new IllegalStateException(e); }
    }

    // 리프레시 토큰 유효성 검증 (Redis 화이트리스트: HMAC id 존재 여부)
    `@Transactional`(readOnly = true)
    public void validateRefreshToken(String refreshToken) {
        // 1) 정규화
        String raw = normalizeStrict(refreshToken);

        // 2) 서명/만료 검증 (여기서 JJWT가 검증함)
        Jws<Claims> jws = validateAndParseRefresh(raw);
        Claims claims = jws.getBody();
        String email = claims.getSubject();

        // 3) 화이트리스트 키 = HMAC(normalized token)
        String id = hmac(raw); // ※ JwtTokenProvider 안에 hmac(secret) 구현 필요

        // 4) Redis 화이트리스트에 키가 존재하는지 확인
        UserRefreshToken saved = userRefreshTokenRepository.findById(id)
                .orElseThrow(() -> new UserHandler(ErrorStatus._INVALID_TOKEN));

        // 5) (권장) 토큰 소유자 일치 검증
        Long expectedUserId = userRepository.findByEmail(email)
                .map(Users::getId)
                .orElseThrow(() -> new UserHandler(ErrorStatus._USER_NOT_FOUND));

        if (!saved.getUserId().equals(expectedUserId)) {
            throw new UserHandler(ErrorStatus._INVALID_TOKEN);
        }
    }

    // 액세스 토큰 생성 (subject 기반)
    public String createAccessToken(String subject) {
        return Jwts.builder()
                .setSubject(subject)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().getAccess()))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    private Jws<Claims> parse(String token, Key key) {
        String cleanToken = normalizeStrict(token);
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(cleanToken);
    }

    private Key getAccessKey()  { return Keys.hmacShaKeyFor(jwtProperties.getKeys().getAccess().getBytes()); }
    private Key getRefreshKey() { return Keys.hmacShaKeyFor(jwtProperties.getKeys().getRefresh().getBytes()); }

    // 액세스 토큰 파싱/검증
    private Jws<Claims> validateAndParseAccess(String token) {
        String cleanToken = normalizeStrict(token);
        return parse(cleanToken, getAccessKey());
    }

    // 리프레시 토큰 파싱/검증
    public Jws<Claims> validateAndParseRefresh(String token) {
        String cleanToken = normalizeStrict(token);
        return parse(cleanToken, getRefreshKey());
    }

    public String normalizeStrict(String token) {
        if (token == null) return null;
        String t = token.trim();
        if (t.startsWith("Bearer ")) t = t.substring(7);
        // 공백/개행/탭/제어문자 제거
        return t.replaceAll("[\\r\\n\\t]", "");
    }

    // 액세스 토큰에서 subject 추출
    private String decodeJwtPayloadSubject(String token) throws JsonProcessingException {
        return objectMapper.readValue(
                new String(Base64.getDecoder().decode(token.split("\\.")[1]), StandardCharsets.UTF_8),
                Map.class
        ).get("sub").toString();
    }

}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java`:
- Around line 108-122: Remove the duplicate info log inside the try block in
OAuth2SuccessHandler so the success path only logs once; specifically, delete
the inner log.info call that references redirectUrl.split("&accessToken")[0],
email, and user.getStatus() (the one executed before the catch), leaving the
single log.info after the try-catch that logs the final redirectUrl and
user.getStatus(); ensure redirectUrl and user are still in scope for the
remaining log and that only the outer log.info and
response.sendRedirect(redirectUrl) remain to avoid duplicate logging.

---

Duplicate comments:
In `@src/main/java/com/umc/linkyou/oauth/OAuth2SuccessHandler.java`:
- Around line 132-141: The hmac(String token) method uses platform-default
encoding via hmacSecret.getBytes() and token.getBytes(); change both calls to
explicitly use StandardCharsets.UTF_8 so the HMAC is stable across environments
(update usages inside the hmac method where hmacSecret and token are converted
to bytes).
- Around line 59-60: Replace any direct logging of the user's email in
OAuth2SuccessHandler: remove email from the log.warn and log.error calls and log
only non-PII identifiers (e.g., when you have a User instance use user.getId())
or other context like provider; for cases where no User exists
(userRepository.findByEmail path) do not log the raw email—either omit it or log
a deterministic hash/placeholder (e.g., sha256(email) or "email_redacted")
instead; update the log.warn in the findByEmail-empty branch and the log.error
usage to follow this pattern so no raw email (PII) is emitted.

@oculo0204 oculo0204 merged commit 4ff94f0 into develop Feb 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant