Feat/#208 social login & mypage api url 변경#238
Conversation
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the 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. WalkthroughOAuth2 성공 핸들러가 사용자 상태(TEMP/ACTIVE)에 따른 토큰 발급·저장·딥링크 리다이렉트로 대체되었고, 사용자 정보 조회 엔드포인트가 경로 기반에서 인증된 사용자 대상의 Changes
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 리다이렉트 응답
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| // 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)); |
There was a problem hiding this comment.
🧩 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 fRepository: 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 -nRepository: 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) 문제가 있습니다.
현재 코드는 findByUserId → deleteById → save 작업이 동기화 없이 순차적으로 실행됩니다. 동일 사용자가 동시에 로그인할 때 두 스레드가 모두 같은 토큰을 조회한 후 각각 다른 새 토큰을 저장하게 되어, 마지막 저장만 남고 첫 번째 토큰이 손실됩니다. 이는 repository 주석의 "단일 세션 모델"을 위반합니다.
참고: deleteById(t.getRefreshToken())에서 getRefreshToken()은 @Id 필드를 올바르게 참조하므로 이 부분은 정상입니다.
There was a problem hiding this comment.
🧩 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();
}
}
There was a problem hiding this comment.
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.
🔗 관련 이슈
#208
📌 작업 내용
1️⃣ Non-Functional Requirement
/auth?path=...&token=...→ 신규/auth?provider=...&result=...&status=...&accessToken=...&refreshToken=...형태로 변경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 로직 제거 이유:
path파라미터 자리가 없음OAuthController.java의oauth_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
새로운 기능
개선 사항