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..2d2eefdd --- /dev/null +++ b/src/main/java/com/Timo/Timo/global/auth/filter/LegacyCookieCleanupFilter.java @@ -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 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()); + } + filterChain.doFilter(request, response); + } +} 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"); 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 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..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 @@ -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; @@ -13,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; @@ -76,22 +76,35 @@ 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); + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + + Optional rotatedSessionId = + refreshTokenService.rotateIfValid(userIdKey, sessionId, refreshToken, newRefreshToken); + + if (rotatedSessionId.isPresent()) { + return new ReissueResult(newAccessToken, newRefreshToken, rotatedSessionId.get()); } - refreshTokenService.deleteRefreshToken(String.valueOf(userId), sessionId); + return refreshTokenService.findRotatedSessionId(userIdKey, sessionId, refreshToken) + .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) { 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..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 @@ -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 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]) + 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) { @@ -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 rotateIfValid( + String userId, String oldSessionId, String expectedRefreshToken, String newRefreshToken + ) { + String newSessionId = UUID.randomUUID().toString(); + + 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, + 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 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); + } + } } 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(); + } } 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; + } }