-
Notifications
You must be signed in to change notification settings - Fork 0
[fix] #196 - 재발급 에러 해결 #198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7401a95
9ede751
ad4428c
65da8b7
45908cf
8abb8ea
a3df657
640ae82
be115fd
537851f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| 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<String> 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 { | ||
| 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()); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] legacy 쿠키 정리는 다음 요청부터 적용되므로, 현재 reissue 요청은 여전히 실패할 수 있을 것 같습니다. 필터에서 현재 구현은 동일 오류가 반복되는 것은 막아주지만, 프론트가 reissue 실패 즉시 로그아웃 처리한다면 사용자는 최초 한 번의 오류로 세션을 잃게 됩니다. 이 동작이 의도된 것인지 확인이 필요해 보입니다. 완전한 마이그레이션이 필요하다면 신규 쿠키 이름을 분리하거나, 중복 쿠키 후보 중 Redis에서 유효한 token/session 조합을 찾거나, 쿠키 정리 후 한 번만 재시도하는 방식도 검토할 수 있을 것 같습니다. |
||
| } | ||
| filterChain.doFilter(request, response); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,22 @@ | ||
| 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.Set; | ||
| import java.util.Optional; | ||
| import java.util.UUID; | ||
| import java.util.concurrent.TimeUnit; | ||
| import lombok.RequiredArgsConstructor; | ||
| 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 | ||
|
|
@@ -21,6 +27,24 @@ 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; | ||
|
|
||
| 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[6] .. ':' .. ARGV[3], 'EX', ARGV[5]) | ||
| redis.call('DEL', KEYS[1]) | ||
| return 1 | ||
| """; | ||
|
|
||
| private final RedisScript<Long> rotateScript = new DefaultRedisScript<>(ROTATE_SCRIPT, Long.class); | ||
|
|
||
| public String saveRefreshToken(String userId, String refreshToken){ | ||
| String sessionId = UUID.randomUUID().toString(); | ||
|
|
@@ -37,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]) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] 연속 rotation 이후에도 최신 세션이 삭제되도록 보완이 필요해 보입니다. 현재 삭제 로직은 기존 세션이 없으면 rotation mapping을 한 번만 따라가는 것 같네요. 따라서 유예시간 안에 이 경우 로그아웃 API는 성공하지만
실은 저도 rotation 부분은 처음봐서 너무 어렵네요.... |
||
| 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<Long> deleteScript = new DefaultRedisScript<>(DELETE_SCRIPT, Long.class); | ||
|
|
||
| public void deleteRefreshToken(String userId, String sessionId) { | ||
| redisTemplate.delete(KEY_PREFIX + userId + ":" + sessionId); | ||
| List<String> keys = List.of( | ||
| KEY_PREFIX + userId + ":" + sessionId, | ||
| ROTATED_PREFIX + userId + ":" + sessionId | ||
| ); | ||
| redisTemplate.execute(deleteScript, keys, KEY_PREFIX, userId); | ||
| } | ||
|
|
||
| public void deleteAllRefreshTokens(String userId) { | ||
|
|
@@ -63,4 +109,68 @@ public void deleteAllRefreshTokens(String userId) { | |
| public boolean isRefreshTokenValid(String userId, String sessionId, String refreshToken) { | ||
| return Objects.equals(refreshToken, getRefreshToken(userId, sessionId)); | ||
| } | ||
|
|
||
| public Optional<String> rotateIfValid( | ||
| String userId, String oldSessionId, String expectedRefreshToken, String newRefreshToken | ||
| ) { | ||
| String newSessionId = UUID.randomUUID().toString(); | ||
|
|
||
| List<String> keys = List.of( | ||
| KEY_PREFIX + userId + ":" + oldSessionId, | ||
| ROTATED_PREFIX + userId + ":" + oldSessionId, | ||
| KEY_PREFIX + userId + ":" + newSessionId | ||
| ); | ||
|
|
||
| Long result = redisTemplate.execute( | ||
| rotateScript, | ||
| keys, | ||
| expectedRefreshToken, | ||
| newRefreshToken, | ||
| newSessionId, | ||
| String.valueOf(jwtTokenProvider.getRefreshTokenExpiry()), | ||
| String.valueOf(ROTATION_GRACE_SECONDS), | ||
| sha256Hex(expectedRefreshToken) | ||
| ); | ||
|
|
||
| if (result != null && result == 1L) { | ||
| return Optional.of(newSessionId); | ||
| } | ||
| return Optional.empty(); | ||
| } | ||
|
|
||
| public Optional<String> 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); | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.