Conversation
There was a problem hiding this comment.
Pull request overview
JWT refresh(토큰 로테이션) API를 추가해, 쿠키 기반 인증에서 만료된 액세스 토큰을 리프레시 토큰으로 재발급할 수 있도록 하는 PR입니다.
Changes:
/api/auth/refresh엔드포인트 추가 및 쿠키에 새 access/refresh 토큰 설정TokenService.refresh()구현(리프레시 토큰 검증 → revoke → 새 토큰 발급/저장) 및RefreshTokenRepository조회 메서드 추가- 관련 예외 코드 추가 및 Auth 테스트 시그니처/요청 방식 변경,
gradlew스크립트 추가
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/until/the/eternity/das/auth/presentation/AuthController.java | refresh API 엔드포인트 추가 및 쿠키 재설정 |
| src/main/java/until/the/eternity/das/token/application/TokenService.java | refresh 토큰 로테이션 서비스 로직 추가 |
| src/main/java/until/the/eternity/das/token/entity/RefreshTokenRepository.java | 유효(refresh+not revoked) 토큰 조회 메서드 추가 |
| src/main/java/until/the/eternity/das/common/exception/GlobalExceptionCode.java | INVALID_REFRESH_TOKEN 예외 코드 추가 |
| src/test/java/until/the/eternity/das/auth/presentation/AuthControllerTest.java | signup 멀티파트 전환 및 login 시그니처 변경 반영 |
| src/test/java/until/the/eternity/das/auth/application/AuthServiceTest.java | login 시그니처 변경 및 계정 잠금 관련 의존성 Mock 추가 |
| gradlew | Gradle wrapper 실행 스크립트 추가 |
Comments suppressed due to low confidence (2)
src/main/java/until/the/eternity/das/auth/presentation/AuthController.java:250
- The new
/api/auth/refreshbehavior is not covered by controller tests. Since this controller already hasAuthControllerTest, add tests for: (1) refresh cookie present ->tokenService.refresh()called and new cookies set; (2) refresh cookie missing/invalid -> appropriate error response.
responseCode = "200",
content = @Content(schema = @Schema(implementation = LoginResponse.class)))
public ResponseEntity<CommonResponse<LoginResponse>> refresh(
HttpServletRequest request,
HttpServletResponse response
) {
String refreshToken = jwtUtil.extractRefreshToken(request);
if (refreshToken == null) {
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}
LoginResultResponse loginResultResponse = tokenService.refresh(refreshToken);
cookieUtil.createAccessTokenCookie(response, loginResultResponse.accessToken());
cookieUtil.createRefreshTokenCookie(response, loginResultResponse.refreshToken());
LoginResponse loginResponse = LoginResponse.from(loginResultResponse.user());
return ResponseEntity.ok(CommonResponse.success(loginResponse));
}
}
src/main/java/until/the/eternity/das/auth/presentation/AuthController.java:247
- The refresh endpoint rotates tokens based solely on an HttpOnly cookie, and CSRF is disabled in
SecurityConfig. This makes/api/auth/refreshvulnerable to CSRF-triggered token rotation (denial of service / session disruption) because cross-site requests will include cookies whenSameSite=None. Add a CSRF mitigation for this endpoint (e.g., require refresh token in an Authorization/header + CSRF token, validate Origin/Referer, or enable CSRF protection for cookie-based auth flows).
responseCode = "200",
content = @Content(schema = @Schema(implementation = LoginResponse.class)))
public ResponseEntity<CommonResponse<LoginResponse>> refresh(
HttpServletRequest request,
HttpServletResponse response
) {
String refreshToken = jwtUtil.extractRefreshToken(request);
if (refreshToken == null) {
throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN);
}
LoginResultResponse loginResultResponse = tokenService.refresh(refreshToken);
cookieUtil.createAccessTokenCookie(response, loginResultResponse.accessToken());
cookieUtil.createRefreshTokenCookie(response, loginResultResponse.refreshToken());
LoginResponse loginResponse = LoginResponse.from(loginResultResponse.user());
return ResponseEntity.ok(CommonResponse.success(loginResponse));
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| cookieUtil.createAccessTokenCookie(response, loginResultResponse.accessToken()); | ||
| cookieUtil.createRefreshTokenCookie(response, loginResultResponse.refreshToken()); | ||
|
|
There was a problem hiding this comment.
refresh() is extracting the refresh token via jwtUtil.extractRefreshToken(request), but JwtUtil.extractRefreshToken is hard-coded to the cookie name "refresh_token" while the rest of the app uses jwtConstant.getRefreshTokenCookieName() (e.g., logout and cookie creation). If the configured cookie name differs, refresh will always fail. Consider extracting by jwtConstant.getRefreshTokenCookieName() (or updating the util) so refresh is consistent with configured cookie names.
| RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr) | ||
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN)); | ||
|
|
||
| // 4. 사용자 존재 + ACTIVE 상태 확인 | ||
| Long userId = claims.get("userId", Long.class); | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); | ||
|
|
||
| if (user.getStatus() != Status.ACTIVE) { | ||
| throw new CustomException(GlobalExceptionCode.USER_DISABLED); | ||
| } | ||
|
|
||
| // 5. 기존 refresh token revoke | ||
| storedToken.revoke(); | ||
|
|
||
| // 6. 새 access token + 새 refresh token 발급 (토큰 로테이션) |
There was a problem hiding this comment.
TokenService.refresh() has a race condition: two concurrent refresh requests with the same (still valid) refresh token can both pass findByTokenAndRevokedFalse(...) before either transaction commits the revoke, resulting in multiple new refresh tokens issued from one old token. Make the revoke step atomic (e.g., UPDATE ... SET revoked=true WHERE token=? AND revoked=false and check affected rows) or lock the row (@Lock(PESSIMISTIC_WRITE) / optimistic @Version) so only one refresh succeeds.
| // 1. JWT 서명/만료 검증 | ||
| jwtUtil.validateToken(refreshTokenStr); | ||
|
|
||
| // 2. Claims에서 type == "REFRESH" 확인 | ||
| var claims = jwtUtil.extractAllClaims(refreshTokenStr); | ||
| if (!"REFRESH".equals(claims.get("type", String.class))) { | ||
| throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN); | ||
| } | ||
|
|
||
| // 3. DB에서 유효한(revoked=false) 토큰 조회 | ||
| RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr) | ||
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN)); | ||
|
|
||
| // 4. 사용자 존재 + ACTIVE 상태 확인 | ||
| Long userId = claims.get("userId", Long.class); | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); | ||
|
|
||
| if (user.getStatus() != Status.ACTIVE) { | ||
| throw new CustomException(GlobalExceptionCode.USER_DISABLED); | ||
| } | ||
|
|
||
| // 5. 기존 refresh token revoke | ||
| storedToken.revoke(); | ||
|
|
||
| // 6. 새 access token + 새 refresh token 발급 (토큰 로테이션) | ||
| String newAccessToken = jwtUtil.generateAccessToken(user); | ||
| String newRefreshToken = jwtUtil.generateRefreshToken(user); | ||
|
|
||
| // 7. 새 refresh token DB 저장 | ||
| saveNewRefreshToken(userId, newRefreshToken); | ||
|
|
||
| // 8. LoginResultResponse 반환 | ||
| return LoginResultResponse.builder() | ||
| .user(user) | ||
| .accessToken(newAccessToken) | ||
| .refreshToken(newRefreshToken) | ||
| .build(); |
There was a problem hiding this comment.
refresh() parses the JWT twice (validateToken() calls extractAllClaims(), then extractAllClaims() is called again). This is unnecessary work on the hot path and makes it harder to keep token error handling consistent. Consider extracting claims once and handling parse/expiry exceptions in one place.
| // 1. JWT 서명/만료 검증 | |
| jwtUtil.validateToken(refreshTokenStr); | |
| // 2. Claims에서 type == "REFRESH" 확인 | |
| var claims = jwtUtil.extractAllClaims(refreshTokenStr); | |
| if (!"REFRESH".equals(claims.get("type", String.class))) { | |
| throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN); | |
| } | |
| // 3. DB에서 유효한(revoked=false) 토큰 조회 | |
| RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr) | |
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN)); | |
| // 4. 사용자 존재 + ACTIVE 상태 확인 | |
| Long userId = claims.get("userId", Long.class); | |
| User user = userRepository.findById(userId) | |
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); | |
| if (user.getStatus() != Status.ACTIVE) { | |
| throw new CustomException(GlobalExceptionCode.USER_DISABLED); | |
| } | |
| // 5. 기존 refresh token revoke | |
| storedToken.revoke(); | |
| // 6. 새 access token + 새 refresh token 발급 (토큰 로테이션) | |
| String newAccessToken = jwtUtil.generateAccessToken(user); | |
| String newRefreshToken = jwtUtil.generateRefreshToken(user); | |
| // 7. 새 refresh token DB 저장 | |
| saveNewRefreshToken(userId, newRefreshToken); | |
| // 8. LoginResultResponse 반환 | |
| return LoginResultResponse.builder() | |
| .user(user) | |
| .accessToken(newAccessToken) | |
| .refreshToken(newRefreshToken) | |
| .build(); | |
| try { | |
| // 1. Claims에서 type == "REFRESH" 확인 (파싱 및 검증을 한 번에 수행) | |
| var claims = jwtUtil.extractAllClaims(refreshTokenStr); | |
| if (!"REFRESH".equals(claims.get("type", String.class))) { | |
| throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN); | |
| } | |
| // 2. DB에서 유효한(revoked=false) 토큰 조회 | |
| RefreshToken storedToken = refreshTokenRepository.findByTokenAndRevokedFalse(refreshTokenStr) | |
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN)); | |
| // 3. 사용자 존재 + ACTIVE 상태 확인 | |
| Long userId = claims.get("userId", Long.class); | |
| User user = userRepository.findById(userId) | |
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); | |
| if (user.getStatus() != Status.ACTIVE) { | |
| throw new CustomException(GlobalExceptionCode.USER_DISABLED); | |
| } | |
| // 4. 기존 refresh token revoke | |
| storedToken.revoke(); | |
| // 5. 새 access token + 새 refresh token 발급 (토큰 로테이션) | |
| String newAccessToken = jwtUtil.generateAccessToken(user); | |
| String newRefreshToken = jwtUtil.generateRefreshToken(user); | |
| // 6. 새 refresh token DB 저장 | |
| saveNewRefreshToken(userId, newRefreshToken); | |
| // 7. LoginResultResponse 반환 | |
| return LoginResultResponse.builder() | |
| .user(user) | |
| .accessToken(newAccessToken) | |
| .refreshToken(newRefreshToken) | |
| .build(); | |
| } catch (CustomException e) { | |
| // 기존 도메인 예외는 그대로 전파 | |
| throw e; | |
| } catch (Exception e) { | |
| // JWT 파싱/검증 관련 기타 예외는 INVALID_REFRESH_TOKEN으로 매핑 | |
| throw new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN); | |
| } |
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.INVALID_REFRESH_TOKEN)); | ||
|
|
||
| // 4. 사용자 존재 + ACTIVE 상태 확인 | ||
| Long userId = claims.get("userId", Long.class); | ||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new CustomException(GlobalExceptionCode.USER_NOT_EXISTS)); | ||
|
|
||
| if (user.getStatus() != Status.ACTIVE) { | ||
| throw new CustomException(GlobalExceptionCode.USER_DISABLED); | ||
| } | ||
|
|
||
| // 5. 기존 refresh token revoke | ||
| storedToken.revoke(); | ||
|
|
||
| // 6. 새 access token + 새 refresh token 발급 (토큰 로테이션) | ||
| String newAccessToken = jwtUtil.generateAccessToken(user); | ||
| String newRefreshToken = jwtUtil.generateRefreshToken(user); | ||
|
|
||
| // 7. 새 refresh token DB 저장 | ||
| saveNewRefreshToken(userId, newRefreshToken); | ||
|
|
||
| // 8. LoginResultResponse 반환 |
There was a problem hiding this comment.
refresh() loads the user by ID from claims, and then saveNewRefreshToken() loads the same user again. Since storedToken already references the user, you can avoid extra DB lookups by using storedToken.getUser() (and/or adding a save method that accepts User) and reusing it for status checks + saving the rotated token.
📋 상세 설명
/api/auth/refresh엔드포인트 추가📊 체크리스트