From 7401a95754069a6bd79bb968479e4b6e39808933 Mon Sep 17 00:00:00 2001 From: jy000n Date: Tue, 1 Sep 2026 15:41:26 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20RefreshTokenService=EC=97=90=20re?= =?UTF-8?q?freshToken=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 02/10] =?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 03/10] =?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 04/10] =?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 05/10] =?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) { From 8abb8eae0573f47debd4911e53b0b25408c67041 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 10 Sep 2026 20:09:20 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20refreshToken=20rotation=EC=9D=98?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=8A=A4=20=EC=BB=A8=EB=94=94=EC=85=98?= =?UTF-8?q?=EC=9D=84=20Redis=20Lua=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=9B=90=EC=9E=90=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/service/AuthService.java | 13 +++--- .../auth/service/RefreshTokenService.java | 45 ++++++++++++++++--- 2 files changed, 46 insertions(+), 12 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 26bfe47d..ab9ba5bb 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 @@ -12,6 +12,7 @@ import com.Timo.Timo.global.exception.CustomException; import com.Timo.Timo.global.exception.code.ErrorCode; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -81,12 +82,14 @@ public ReissueResult reissue(String refreshToken, String sessionId) { throw new CustomException(UserErrorCode.USER_NOT_FOUND); } - if (refreshTokenService.isRefreshTokenValid(userIdKey, sessionId, refreshToken)) { - String newAccessToken = jwtTokenProvider.generateAccessToken(userId); - String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); - String newSessionId = refreshTokenService.rotateRefreshToken(userIdKey, sessionId, newRefreshToken); + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + + Optional rotatedSessionId = + refreshTokenService.rotateIfValid(userIdKey, sessionId, refreshToken, newRefreshToken); - return new ReissueResult(newAccessToken, newRefreshToken, newSessionId); + if (rotatedSessionId.isPresent()) { + return new ReissueResult(newAccessToken, newRefreshToken, rotatedSessionId.get()); } return refreshTokenService.findRotatedSessionId(userIdKey, sessionId) 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 b0a64f6b..bb1690ce 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 @@ -11,6 +11,8 @@ import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; @Service @@ -24,6 +26,22 @@ public class RefreshTokenService { private static final String ROTATED_PREFIX = "refresh:rotated:"; private static final long ROTATION_GRACE_SECONDS = 5; + private static final String ROTATE_SCRIPT = """ + local current = redis.call('GET', KEYS[1]) + if current == false then + return 0 + end + if current ~= ARGV[1] then + return -1 + end + redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[4]) + redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[5]) + redis.call('DEL', KEYS[1]) + return 1 + """; + + private final RedisScript rotateScript = new DefaultRedisScript<>(ROTATE_SCRIPT, Long.class); + public String saveRefreshToken(String userId, String refreshToken){ String sessionId = UUID.randomUUID().toString(); redisTemplate.opsForValue().set( @@ -66,18 +84,31 @@ public boolean isRefreshTokenValid(String userId, String sessionId, String refre return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); } - public String rotateRefreshToken(String userId, String oldSessionId, String newRefreshToken) { - String newSessionId = saveRefreshToken(userId, newRefreshToken); + public Optional rotateIfValid( + String userId, String oldSessionId, String expectedRefreshToken, String newRefreshToken + ) { + String newSessionId = UUID.randomUUID().toString(); - redisTemplate.opsForValue().set( + List keys = List.of( + KEY_PREFIX + userId + ":" + oldSessionId, ROTATED_PREFIX + userId + ":" + oldSessionId, + KEY_PREFIX + userId + ":" + newSessionId + ); + + Long result = redisTemplate.execute( + rotateScript, + keys, + expectedRefreshToken, + newRefreshToken, newSessionId, - ROTATION_GRACE_SECONDS, - TimeUnit.SECONDS + String.valueOf(jwtTokenProvider.getRefreshTokenExpiry()), + String.valueOf(ROTATION_GRACE_SECONDS) ); - deleteRefreshToken(userId, oldSessionId); - return newSessionId; + if (result != null && result == 1L) { + return Optional.of(newSessionId); + } + return Optional.empty(); } public Optional findRotatedSessionId(String userId, String oldSessionId) { From a3df65767a126e15bab0e2afa582e2ca421e8f5e Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 10 Sep 2026 20:09:54 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20legacy=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=A7=8C=EB=A3=8C=20=EC=B2=98=EB=A6=AC=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=EB=8F=84=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=ED=95=98=EB=8F=84=EB=A1=9D=20Filter=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/factory/AuthResponseFactory.java | 25 +++---------- .../filter/LegacyCookieCleanupFilter.java | 37 +++++++++++++++++++ .../Timo/global/config/SecurityConfig.java | 15 +++++++- 3 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java 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 dc5b9ab5..c470653b 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,21 +33,11 @@ public ResponseEntity> reissueResponse(Reissue .accessToken(result.getAccessToken()) .build(); - ResponseEntity.BodyBuilder builder = ResponseEntity.ok() + return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result.getRefreshToken())) .header(HttpHeaders.SET_COOKIE, sessionIdCookie(result.getSessionId())) - .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()); - } + .header("Cache-Control", "no-store") + .body(BaseResponse.onSuccess(AuthSuccessCode.REISSUE_SUCCESS, body)); } public ResponseEntity> logoutResponse() { @@ -59,14 +49,11 @@ public ResponseEntity> withdrawResponse() { } private ResponseEntity> expiredCookieResponse(AuthSuccessCode successCode) { - ResponseEntity.BodyBuilder builder = ResponseEntity.ok() + return 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"); - - addLegacyCookieCleanup(builder); - - return builder.body(BaseResponse.onSuccess(successCode, null)); + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(BaseResponse.onSuccess(successCode, null)); } private String refreshTokenCookie(String refreshToken) { diff --git a/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java b/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java new file mode 100644 index 00000000..a30c2d9b --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java @@ -0,0 +1,37 @@ +package com.Timo.Timo.global.auth.filter; + +import com.Timo.Timo.global.auth.utils.CookieUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class LegacyCookieCleanupFilter extends OncePerRequestFilter { + + private static final List TARGET_PATHS = List.of( + "/api/v1/auth/reissue", + "/api/v1/auth/logout", + "/api/v1/auth/withdraw" + ); + + @Value("${app.auth.cookie-secure}") + private boolean cookieSecure; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain + ) throws ServletException, IOException { + if (cookieSecure && TARGET_PATHS.contains(request.getRequestURI())) { + response.addHeader(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("refreshToken").toString()); + response.addHeader(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("sessionId").toString()); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java index 902faa58..1d04cfef 100644 --- a/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java +++ b/src/main/java/com/Timo/Timo/global/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.Timo.Timo.global.config; +import com.Timo.Timo.global.auth.filter.LegacyCookieCleanupFilter; import com.Timo.Timo.global.auth.filter.OriginValidationFilter; import com.Timo.Timo.global.auth.handler.JwtAuthenticationEntryPoint; import com.Timo.Timo.global.auth.handler.OAuthFailureHandler; @@ -34,6 +35,7 @@ public class SecurityConfig { private final CorsConfigurationSource corsConfigurationSource; private final OAuthOriginCaptureFilter oAuthOriginCaptureFilter; private final OriginValidationFilter originValidationFilter; + private final LegacyCookieCleanupFilter legacyCookieCleanupFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -72,7 +74,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(mdcLoggingFilter(), JwtAuthenticationFilter.class) .addFilterBefore(oAuthOriginCaptureFilter, OAuth2AuthorizationRequestRedirectFilter.class) - .addFilterBefore(originValidationFilter, JwtAuthenticationFilter.class); + .addFilterBefore(originValidationFilter, JwtAuthenticationFilter.class) + .addFilterBefore(legacyCookieCleanupFilter, JwtAuthenticationFilter.class); return http.build(); } @@ -100,4 +103,14 @@ public FilterRegistrationBean originValidationFilterRegi registrationBean.setEnabled(false); return registrationBean; } + + @Bean + public FilterRegistrationBean legacyCookieCleanupFilterRegistration( + LegacyCookieCleanupFilter legacyCookieCleanupFilter + ) { + FilterRegistrationBean registrationBean = + new FilterRegistrationBean<>(legacyCookieCleanupFilter); + registrationBean.setEnabled(false); + return registrationBean; + } } From 640ae82ca573871fd5e7a53a929edce2de476012 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 10 Sep 2026 20:43:05 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=ED=95=84=ED=84=B0=EA=B0=80=20cont?= =?UTF-8?q?ext=20path=EB=A5=BC=20=EC=A0=9C=EC=99=B8=ED=95=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EB=8C=80=EC=83=81=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=EB=A5=BC=20=ED=8C=90=EB=8B=A8=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Timo/global/auth/filter/LegacyCookieCleanupFilter.java | 4 +++- .../Timo/Timo/global/auth/filter/OriginValidationFilter.java | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java b/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java index a30c2d9b..2d2eefdd 100644 --- a/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java +++ b/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java @@ -28,7 +28,9 @@ public class LegacyCookieCleanupFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { - if (cookieSecure && TARGET_PATHS.contains(request.getRequestURI())) { + String path = request.getRequestURI().substring(request.getContextPath().length()); + + if (cookieSecure && TARGET_PATHS.contains(path)) { response.addHeader(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("refreshToken").toString()); response.addHeader(HttpHeaders.SET_COOKIE, CookieUtil.expireLegacyCookie("sessionId").toString()); } diff --git a/src/main/java/com/Timo/Timo/global/auth/filter/OriginValidationFilter.java b/src/main/java/com/Timo/Timo/global/auth/filter/OriginValidationFilter.java index 225dd979..1832a24d 100644 --- a/src/main/java/com/Timo/Timo/global/auth/filter/OriginValidationFilter.java +++ b/src/main/java/com/Timo/Timo/global/auth/filter/OriginValidationFilter.java @@ -26,7 +26,7 @@ public class OriginValidationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { - String path = request.getRequestURI(); + String path = request.getRequestURI().substring(request.getContextPath().length()); if (PROTECTED_PATHS.stream().anyMatch(path::equals)) { String origin = request.getHeader("Origin"); From be115fd48d2b51fddd1ea1586be4bf4479eff422 Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 10 Sep 2026 22:08:40 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20refreshToken=20rotation=20fallback?= =?UTF-8?q?=EC=97=90=20refreshToken=20=EB=8B=A4=EC=9D=B4=EC=A0=9C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=80=EC=A6=9D=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/service/AuthService.java | 2 +- .../auth/service/RefreshTokenService.java | 45 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 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 ab9ba5bb..f21fd7fd 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 @@ -92,7 +92,7 @@ public ReissueResult reissue(String refreshToken, String sessionId) { return new ReissueResult(newAccessToken, newRefreshToken, rotatedSessionId.get()); } - return refreshTokenService.findRotatedSessionId(userIdKey, sessionId) + return refreshTokenService.findRotatedSessionId(userIdKey, sessionId, refreshToken) .map(newSessionId -> reissueFromAlreadyRotatedSession(userId, userIdKey, newSessionId)) .orElseThrow(() -> new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN)); } 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 bb1690ce..8f9f10b5 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 @@ -1,7 +1,11 @@ package com.Timo.Timo.global.auth.service; import com.Timo.Timo.global.jwt.provider.JwtTokenProvider; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.HexFormat; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -35,7 +39,7 @@ public class RefreshTokenService { return -1 end redis.call('SET', KEYS[3], ARGV[2], 'EX', ARGV[4]) - redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[5]) + redis.call('SET', KEYS[2], ARGV[6] .. ':' .. ARGV[3], 'EX', ARGV[5]) redis.call('DEL', KEYS[1]) return 1 """; @@ -102,7 +106,8 @@ public Optional rotateIfValid( newRefreshToken, newSessionId, String.valueOf(jwtTokenProvider.getRefreshTokenExpiry()), - String.valueOf(ROTATION_GRACE_SECONDS) + String.valueOf(ROTATION_GRACE_SECONDS), + sha256Hex(expectedRefreshToken) ); if (result != null && result == 1L) { @@ -111,9 +116,39 @@ public Optional rotateIfValid( return Optional.empty(); } - public Optional findRotatedSessionId(String userId, String oldSessionId) { - return Optional.ofNullable( - redisTemplate.opsForValue().get(ROTATED_PREFIX + userId + ":" + oldSessionId) + public Optional findRotatedSessionId(String userId, String oldSessionId, String refreshToken) { + String stored = redisTemplate.opsForValue().get(ROTATED_PREFIX + userId + ":" + oldSessionId); + if (stored == null) { + return Optional.empty(); + } + + int separatorIndex = stored.indexOf(':'); + if (separatorIndex < 0) { + return Optional.empty(); + } + + String storedDigest = stored.substring(0, separatorIndex); + String newSessionId = stored.substring(separatorIndex + 1); + + boolean digestMatches = MessageDigest.isEqual( + storedDigest.getBytes(StandardCharsets.UTF_8), + sha256Hex(refreshToken).getBytes(StandardCharsets.UTF_8) ); + + if (!digestMatches) { + return Optional.empty(); + } + + return Optional.of(newSessionId); + } + + private static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 알고리즘을 사용할 수 없습니다.", e); + } } } From 537851f834cda601425f7a6eb4ce9c172fedb2bf Mon Sep 17 00:00:00 2001 From: jy000n Date: Thu, 10 Sep 2026 22:10:02 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=9D=B4=20=EC=A7=81=EC=A0=84=EC=97=90=20rotate?= =?UTF-8?q?=EB=90=9C=20=EC=84=B8=EC=85=98=EA=B9=8C=EC=A7=80=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EC=82=AD=EC=A0=9C=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= 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 8f9f10b5..7d8cfacd 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 @@ -61,8 +61,30 @@ public String getRefreshToken(String userId, String sessionId) { return redisTemplate.opsForValue().get(KEY_PREFIX + userId + ":" + sessionId); } + private static final String DELETE_SCRIPT = """ + local deleted = redis.call('DEL', KEYS[1]) + if deleted == 0 then + local pointer = redis.call('GET', KEYS[2]) + if pointer then + local sep = string.find(pointer, ':') + if sep then + local newSessionId = string.sub(pointer, sep + 1) + redis.call('DEL', ARGV[1] .. ARGV[2] .. ':' .. newSessionId) + end + end + end + redis.call('DEL', KEYS[2]) + return deleted + """; + + private final RedisScript deleteScript = new DefaultRedisScript<>(DELETE_SCRIPT, Long.class); + public void deleteRefreshToken(String userId, String sessionId) { - redisTemplate.delete(KEY_PREFIX + userId + ":" + sessionId); + List keys = List.of( + KEY_PREFIX + userId + ":" + sessionId, + ROTATED_PREFIX + userId + ":" + sessionId + ); + redisTemplate.execute(deleteScript, keys, KEY_PREFIX, userId); } public void deleteAllRefreshTokens(String userId) {