From 7401a95754069a6bd79bb968479e4b6e39808933 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 1 Sep 2026 15:41:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20RefreshTokenService=EC=97=90=20refr?= =?UTF-8?q?eshToken=20rotation=20=EC=9C=A0=EC=98=88=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/RefreshTokenService.java | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java b/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java index ef704d23..b0a64f6b 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/RefreshTokenService.java @@ -4,7 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Set; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; @@ -21,6 +21,8 @@ public class RefreshTokenService { private final JwtTokenProvider jwtTokenProvider; private static final String KEY_PREFIX = "refresh:"; + private static final String ROTATED_PREFIX = "refresh:rotated:"; + private static final long ROTATION_GRACE_SECONDS = 5; public String saveRefreshToken(String userId, String refreshToken){ String sessionId = UUID.randomUUID().toString(); @@ -63,4 +65,24 @@ public void deleteAllRefreshTokens(String userId) { public boolean isRefreshTokenValid(String userId, String sessionId, String refreshToken) { return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); } + + public String rotateRefreshToken(String userId, String oldSessionId, String newRefreshToken) { + String newSessionId = saveRefreshToken(userId, newRefreshToken); + + redisTemplate.opsForValue().set( + ROTATED_PREFIX + userId + ":" + oldSessionId, + newSessionId, + ROTATION_GRACE_SECONDS, + TimeUnit.SECONDS + ); + + deleteRefreshToken(userId, oldSessionId); + return newSessionId; + } + + public Optional findRotatedSessionId(String userId, String oldSessionId) { + return Optional.ofNullable( + redisTemplate.opsForValue().get(ROTATED_PREFIX + userId + ":" + oldSessionId) + ); + } } From 9ede751e3c483b36ca054607a77446eaa795d857 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 1 Sep 2026 16:00:15 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=EB=8F=99=EC=8B=9C=20reissue=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=B4=20=EC=84=9C=EB=A1=9C=EC=9D=98=20rot?= =?UTF-8?q?ation=EC=9D=84=20=EB=AC=B4=ED=9A=A8=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/service/AuthService.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java b/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java index 6f1db3cb..26bfe47d 100644 --- a/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java +++ b/src/main/java/com/Timo/Timo/global/auth/service/AuthService.java @@ -1,6 +1,5 @@ package com.Timo.Timo.global.auth.service; -import com.Timo.Timo.domain.calendar.client.GoogleOAuthClient; import com.Timo.Timo.domain.calendar.entity.CalendarRevocationOutbox; import com.Timo.Timo.domain.calendar.repository.CalendarConnectionRepository; import com.Timo.Timo.domain.calendar.repository.CalendarRevocationOutboxRepository; @@ -76,22 +75,33 @@ public ReissueResult reissue(String refreshToken, String sessionId) { } Long userId = jwtTokenProvider.getUserId(refreshToken); + String userIdKey = String.valueOf(userId); if (!userRepository.existsById(userId)) { throw new CustomException(UserErrorCode.USER_NOT_FOUND); } - if (!refreshTokenService.isRefreshTokenValid(String.valueOf(userId), sessionId, refreshToken)){ - throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); + if (refreshTokenService.isRefreshTokenValid(userIdKey, sessionId, refreshToken)) { + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + String newSessionId = refreshTokenService.rotateRefreshToken(userIdKey, sessionId, newRefreshToken); + + return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); } - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + return refreshTokenService.findRotatedSessionId(userIdKey, sessionId) + .map(newSessionId -> reissueFromAlreadyRotatedSession(userId, userIdKey, newSessionId)) + .orElseThrow(() -> new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + } - String newAccessToken = jwtTokenProvider.generateAccessToken(userId); - String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); - String newSessionId = refreshTokenService.saveRefreshToken(String.valueOf(userId), newRefreshToken); + private ReissueResult reissueFromAlreadyRotatedSession(Long userId, String userIdKey, String newSessionId) { + String currentRefreshToken = refreshTokenService.getRefreshToken(userIdKey, newSessionId); + if (currentRefreshToken == null) { + throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } - return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + return new ReissueResult(newAccessToken, currentRefreshToken, newSessionId); } public void logout(String accessToken, Long userId, String sessionId) { From ad4428ce3ba820e986954415cbdebf9fe0b88e1b Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 1 Sep 2026 16:07:47 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20legacy=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=EC=9A=A9=20expireLegacyCookie=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/Timo/Timo/global/auth/utils/CookieUtil.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java index 8d2a2382..e3949ae5 100644 --- a/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java +++ b/src/main/java/com/Timo/Timo/global/auth/utils/CookieUtil.java @@ -34,4 +34,14 @@ public static ResponseCookie expireCookie(String name, boolean secure) { return builder.build(); } + + public static ResponseCookie expireLegacyCookie(String name) { + return ResponseCookie.from(name, "") + .httpOnly(true) + .secure(true) + .path("/api/v1/auth") + .maxAge(0) + .sameSite("None") + .build(); + } } From 65da8b74b23270f7e6e50599643397237357e5ef Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 1 Sep 2026 16:23:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20legacy=20=EC=BF=A0=ED=82=A4=EB=A5=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=EC=8B=9C=ED=82=A4=EA=B3=A0=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=EB=A5=BC=20=EB=B0=9C=EA=B8=89=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/factory/AuthResponseFactory.java | 16 +++++++++++++--- .../global/auth/handler/OAuthSuccessHandler.java | 5 +++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java index c470653b..96f538b7 100644 --- a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -33,11 +33,21 @@ public ResponseEntity> reissueResponse(Reissue .accessToken(result.getAccessToken()) .build(); - return ResponseEntity.ok() + ResponseEntity.BodyBuilder builder = ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result.getRefreshToken())) .header(HttpHeaders.SET_COOKIE, sessionIdCookie(result.getSessionId())) - .header("Cache-Control", "no-store") - .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, body)); + .header("Cache-Control", "no-store"); + + addLegacyCookieCleanup(builder); + + return builder.body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, body)); + } + + private void addLegacyCookieCleanup(ResponseEntity.BodyBuilder builder) { + if (cookieSecure) { + builder.header(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("refreshToken").toString()); + builder.header(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("sessionId").toString()); + } } public ResponseEntity> logoutResponse() { diff --git a/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java index f8146c3a..88d42076 100644 --- a/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java +++ b/src/main/java/com/Timo/Timo/global/auth/handler/OAuthSuccessHandler.java @@ -58,6 +58,11 @@ public void onAuthenticationSuccess( CookieUtil.createCookie("sessionId", sessionId, jwtTokenProvider.getRefreshTokenExpiry(), cookieSecure).toString()); + if (cookieSecure) { + response.addHeader(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("refreshToken").toString()); + response.addHeader(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("sessionId").toString()); + } + String code = authCodeService.generateAndSave( String.valueOf(userId), onboardingCompleted From 45908cfc81d23a3f1850e230d577c89fd7afb0a7 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 1 Sep 2026 17:07:22 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat=20:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83/=ED=83=88=ED=87=B4=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=EB=8F=84=20legacy=20=EC=BF=A0=ED=82=A4=20=EB=A7=8C=EB=A3=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/factory/AuthResponseFactory.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java index 96f538b7..dc5b9ab5 100644 --- a/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java +++ b/src/main/java/com/Timo/Timo/global/auth/factory/AuthResponseFactory.java @@ -59,11 +59,14 @@ public ResponseEntity> withdrawResponse() { } private ResponseEntity> expiredCookieResponse(AuthSuccessCode successCode) { - return ResponseEntity.ok() + ResponseEntity.BodyBuilder builder = ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, CookieUtil.expireCookie("refreshToken", cookieSecure).toString()) .header(HttpHeaders.SET_COOKIE, CookieUtil.expireCookie("sessionId", cookieSecure).toString()) - .header(HttpHeaders.CACHE_CONTROL, "no-store") - .body(BaseResponse.onSuccess(successCode, null)); + .header(HttpHeaders.CACHE_CONTROL, "no-store"); + + addLegacyCookieCleanup(builder); + + return builder.body(BaseResponse.onSuccess(successCode, null)); } private String refreshTokenCookie(String refreshToken) {