Skip to content

Comments

feat: JWT refresh API 구현#19

Merged
dev-ant merged 3 commits intodevfrom
feat/refresh-token-api
Feb 8, 2026
Merged

feat: JWT refresh API 구현#19
dev-ant merged 3 commits intodevfrom
feat/refresh-token-api

Conversation

@dev-ant
Copy link
Contributor

@dev-ant dev-ant commented Feb 8, 2026

📋 상세 설명

  • /api/auth/refresh 엔드포인트 추가
  • 쿠키에 새 access/refresh 토큰 설정
  • TokenService.refresh() 구현
    • 리프레시 토큰 검증 → revoke → 새 토큰 발급/저장 및 RefreshTokenRepository 조회 메서드 추가

📊 체크리스트

  • PR 제목이 형식에 맞나요 e.g. feat: PR을 등록한다
  • 코드가 테스트 되었나요
  • 문서는 업데이트 되었나요
  • 불필요한 코드를 제거했나요
  • 이슈와 라벨이 등록되었나요

@dev-ant dev-ant requested a review from Copilot February 8, 2026 22:55
@dev-ant dev-ant self-assigned this Feb 8, 2026
@dev-ant dev-ant added the ✨feature 새로운 기능 추가 label Feb 8, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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/refresh behavior is not covered by controller tests. Since this controller already has AuthControllerTest, 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/refresh vulnerable to CSRF-triggered token rotation (denial of service / session disruption) because cross-site requests will include cookies when SameSite=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.

Comment on lines +238 to +241

cookieUtil.createAccessTokenCookie(response, loginResultResponse.accessToken());
cookieUtil.createRefreshTokenCookie(response, loginResultResponse.refreshToken());

Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +63 to +78
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 발급 (토큰 로테이션)
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +90
// 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();
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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);
}

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +85
.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 반환
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@dev-ant dev-ant merged commit c56fa95 into dev Feb 8, 2026
2 checks passed
@dev-ant dev-ant deleted the feat/refresh-token-api branch February 8, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨feature 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant