From ada922910d53b2f0a3031282d2d5b71f67df4a68 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 29 Oct 2026 12:08:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=ED=9A=8C=EC=A0=84=20=EC=A7=81?= =?UTF-8?q?=ED=9B=84=20=EC=A7=81=EC=A0=84=20refresh=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EC=9E=AC=EC=A0=9C=EC=B6=9C=EC=9D=80=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=EC=9D=84=20=EB=81=8A=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod SEC-05 3건(10/28 17:09, 10/29 09:23·11:08 KST)이 모두 같은 모양이었다. 앱을 열면 만료된 AT 로 요청 여러 개가 401 을 받고, refresh 가 한 번 성공한 뒤 3~8초 뒤에 직전 RT 로 refresh 가 한 번 더 왔다. 서버는 이를 탈취로 보고 family 전체를 폐기해 사용자를 로그아웃시켰다. 회전으로 폐기된 지 30초 안이고 그 회전이 만든 후속 토큰이 모두 살아 있으면 같은 family 에 새 페어를 하나 더 준다. 로그아웃·새 기기 로그인·정지·탈퇴(후속까지 폐기), 후속이 다시 회전된 경우, 30초가 지난 경우는 기존대로 재사용 감지다. 유예는 SEC-05 로 세지 않고 refresh_rotation_grace 로그로 남긴다. 앱 single-flight 는 Android 이슈로 따로 둔다. Co-Authored-By: Claude Opus 5.5 --- .../auth/controller/AuthController.java | 9 ++- .../repository/RefreshTokenRepository.java | 4 + .../auth/service/AuthService.java | 36 +++++++++ .../auth/SessionDeviceFlowIT.java | 76 +++++++++++++++---- 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java b/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java index efc23d78..dd1c65a9 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/controller/AuthController.java @@ -246,9 +246,12 @@ public ApiResponse signup(@RequestBody SignupRequest request) { 이미 무효화된 refreshToken 이 다시 제출되면 **토큰 탈취로 간주**해, 그 토큰에서 파생된 세션 전체를 폐기한다. 이 경우 정상 기기도 함께 로그아웃되며 재로그인이 필요하다. - **클라이언트는 refresh 호출을 반드시 single-flight로 직렬화해야 한다.** 같은 refreshToken 을 두 요청이 - 동시에 제출하면 하나만 회전에 성공하고, 후발 요청은 재사용으로 감지되어 선발 요청이 받은 새 토큰까지 - family 전체가 폐기된다. 새 refreshToken 저장도 응답 처리와 함께 원자적으로 덮어써야 한다. + **회전 유예(30초):** 방금 회전된 refreshToken 이 30초 안에 다시 오고 그 회전으로 받은 토큰이 아직 살아 있으면 + 탈취가 아니라 갱신 경합으로 보고 **새 토큰 쌍을 하나 더** 준다(먼저 받은 쌍도 계속 유효). 로그아웃·다른 기기 로그인· + 정지·탈퇴로 끊긴 세션, 이미 한 번 더 회전된 토큰, 30초가 지난 토큰은 유예하지 않는다. + + **그래도 클라이언트는 refresh 호출을 single-flight로 직렬화해야 한다.** 유예는 경합으로 인한 강제 로그아웃을 + 막는 안전망일 뿐이다. 새 refreshToken 저장도 응답 처리와 함께 원자적으로 덮어써야 한다. 만료·위조·폐기·탈퇴 계정 — 어느 쪽이든 401 `SESSION_EXPIRED` 하나로 내려간다. 클라이언트 처리는 모두 동일하게 "로그인 화면으로" 이므로 원인을 구분하지 않는다. diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java b/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java index 12137e73..aaa28b01 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/repository/RefreshTokenRepository.java @@ -13,6 +13,7 @@ import jakarta.persistence.LockModeType; import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -30,6 +31,9 @@ public interface RefreshTokenRepository extends JpaRepository findByTokenHashForUpdate(@Param("tokenHash") byte[] tokenHash); + /** 이 토큰을 회전해 발급된 후속 토큰들 — 회전 유예 판단용(parent_token_id 인덱스). */ + List findByParentTokenId(UUID parentTokenId); + /** 사용자의 활성 RT 전체 revoke — 로그아웃 전체·신규 기기 로그인·탈퇴·정지 시. */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update RefreshToken t set t.revokedAt = :now where t.user.id = :userId and t.revokedAt is null") diff --git a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java index d25c39d0..0ca54983 100644 --- a/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java +++ b/src/main/java/com/ruleup/ruleup_backend/auth/service/AuthService.java @@ -46,6 +46,7 @@ import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; +import java.time.Duration; import java.time.Instant; import java.util.Map; import java.time.LocalDate; @@ -62,6 +63,12 @@ public class AuthService { private static final Logger log = LoggerFactory.getLogger(AuthService.class); private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + /** + * 회전 직후 직전 토큰을 다시 받아 주는 창. prod 에서 본 갱신 경합은 회전 뒤 3~8초에 왔다(2026-10-28·29 SEC-05 3건). + * 길게 두면 탈취된 직전 토큰이 통하는 창도 늘어난다. + */ + private static final Duration ROTATION_GRACE = Duration.ofSeconds(30); /** 임시 승인 닉네임 INSERT 충돌 시 가입 트랜잭션 재시도 상한 (DB 정리 §6). 같은 패키지 테스트가 참조. */ static final int MAX_SIGNUP_ATTEMPTS = 3; /** 재시도 대상 제약 — 임시 승인 닉네임 UNIQUE. 신청 닉네임 충돌(409)은 재시도 대상이 아니다. */ @@ -616,6 +623,17 @@ public TokenResponse refresh(String refreshTokenValue) { RefreshToken stored = refreshTokenRepository.findByTokenHashForUpdate(TokenService.sha256(refreshTokenValue)) .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_EXPIRED)); + // 회전 유예 — 방금 회전된 토큰이 몇 초 뒤 같은 기기에서 다시 오는 것은 탈취가 아니라 앱의 갱신 경합이다 + // (만료된 AT 로 동시에 나간 요청들이 각자 refresh 를 부른다). 세션을 끊지 않고 같은 family 에 새 페어를 준다. + if (stored.isRevoked() && inRotationGrace(stored)) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.SESSION_EXPIRED)); + log.info("refresh_rotation_grace userId={} sinceRotationMs={}", userId, + Duration.between(stored.getRevokedAt(), Instant.now()).toMillis()); + return TokenResponse.from(tokenService.issueRotatedPair(user, stored.getFamilyId(), stored.getId()), + syncPolicy.forUser(user)); + } + // 재사용 감지 — 이미 폐기된 토큰이 다시 제출되면 탈취 의심 → family 전체 revoke (DB 정리 §11.3) // 이 메서드는 BusinessException에 대해 롤백하지 않으므로 흔적과 revoke가 401 응답 전에 커밋된다. if (stored.isRevoked()) { @@ -633,6 +651,24 @@ public TokenResponse refresh(String refreshTokenValue) { return TokenResponse.from(tokenService.issueRotatedPair(user, stored.getFamilyId(), stored.getId()), syncPolicy.forUser(user)); } + /** + * 회전 유예에 드는지 — 회전으로 폐기된 지 {@link #ROTATION_GRACE} 안이고, 그 회전이 만든 후속 토큰이 + * 모두 살아 있을 때만이다. + * + *

후속 토큰이 살아 있는지가 핵심이다. 로그아웃 · 새 기기 로그인 · 정지 · 탈퇴 · 재사용 감지는 후속 토큰까지 + * 폐기하므로 여기서 걸러진다. 후속 토큰이 이미 다시 회전됐어도(앱이 새 토큰을 쓰기 시작했다) 유예하지 않는다. + * 후속이 없으면 회전이 아니라 로그아웃으로 폐기된 토큰이다. + * + *

창 안에서 탈취된 직전 토큰도 새 페어를 받을 수 있다 — 창을 짧게 두는 이유다. 창이 지나 다시 오면 + * 기존대로 재사용 감지가 family 전체를 끊는다. + */ + private boolean inRotationGrace(RefreshToken stored) { + if (stored.getReuseDetectedAt() != null || stored.isExpired()) return false; + if (stored.getRevokedAt().isBefore(Instant.now().minus(ROTATION_GRACE))) return false; + List successors = refreshTokenRepository.findByParentTokenId(stored.getId()); + return !successors.isEmpty() && successors.stream().noneMatch(RefreshToken::isRevoked); + } + // ===== 로그아웃 (멱등) ===== @Transactional public void logout(String refreshTokenValue) { diff --git a/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java b/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java index 9d6ba0c2..5141780f 100644 --- a/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java +++ b/src/test/java/com/ruleup/ruleup_backend/auth/SessionDeviceFlowIT.java @@ -218,6 +218,12 @@ private UUID insertRefreshToken(UUID userId, Instant expiresAt, return id; } + private int reuseDetectedCount(String refreshToken) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = ? AND reuse_detected_at IS NOT NULL", + Integer.class, (Object) TokenService.sha256(refreshToken)); + } + private int refreshTokenCount(UUID tokenId) { return jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM refresh_tokens WHERE id = ?", Integer.class, bytes(tokenId)); @@ -458,8 +464,8 @@ void refresh_rotates_and_revokes_old() throws Exception { } @Test - @DisplayName("같은 RT 동시 제출은 하나만 회전 성공하고 후발 요청은 재사용으로 감지한다") - void concurrent_refresh_has_single_winner() throws Exception { + @DisplayName("같은 RT 동시 제출은 회전 경합이다 — 둘 다 새 페어를 받고 세션은 유지된다") + void concurrent_refresh_is_a_rotation_race_not_reuse() throws Exception { String tag = uniq(); String rt1 = read(signup(tag), "$.data.refreshToken"); @@ -483,36 +489,78 @@ void concurrent_refresh_has_single_winner() throws Exception { MvcResult r1 = first.get(10, TimeUnit.SECONDS); MvcResult r2 = second.get(10, TimeUnit.SECONDS); assertThat(List.of(r1.getResponse().getStatus(), r2.getResponse().getStatus())) - .containsExactlyInAnyOrder(200, 401); - - MvcResult winner = r1.getResponse().getStatus() == 200 ? r1 : r2; - MvcResult rejected = r1.getResponse().getStatus() == 401 ? r1 : r2; - expectError(rejected, 401, "SESSION_EXPIRED"); - - // 후발 제출은 기존 정책대로 탈취 의심 재사용이다. 따라서 선발 요청이 만든 - // 자식 RT까지 같은 family 전체가 폐기되어 다시 로그인해야 한다. - String rotated = read(winner, "$.data.refreshToken"); - expectError(refresh(rotated), 401, "SESSION_EXPIRED"); + .containsExactly(200, 200); + + // 앱이 어느 응답을 저장하든 세션이 이어진다. + String a = read(r1, "$.data.refreshToken"); + String b = read(r2, "$.data.refreshToken"); + assertThat(a).isNotEqualTo(b); + assertThat(refresh(a).getResponse().getStatus()).isEqualTo(200); + assertThat(refresh(b).getResponse().getStatus()).isEqualTo(200); } finally { executor.shutdownNow(); } } @Test - @DisplayName("revoke 된 RT 재제출(재사용 감지) 시 family 전체를 무효화한다 — 탈취 대응") - void reuse_detection_revokes_whole_family() throws Exception { + @DisplayName("회전 직후 직전 RT 가 다시 오면 같은 family 로 재발급하고 SEC-05 로 세지 않는다 — 앱 갱신 경합(SEC-05 10/28·29)") + void stale_refresh_right_after_rotation_keeps_session() throws Exception { String tag = uniq(); String rt1 = read(signup(tag), "$.data.refreshToken"); + String rt2 = read(refresh(rt1), "$.data.refreshToken"); + + MvcResult again = refresh(rt1); + assertThat(again.getResponse().getStatus()).isEqualTo(200); + String rt2b = read(again, "$.data.refreshToken"); + assertThat(reuseDetectedCount(rt1)).as("재사용으로 기록하지 않는다").isZero(); + assertThat(refresh(rt2).getResponse().getStatus()).as("먼저 받은 페어도 살아 있다").isEqualTo(200); + assertThat(refresh(rt2b).getResponse().getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("유예 창(30초)이 지난 뒤 직전 RT 재제출은 재사용 감지 — family 전체 무효(탈취 대응)") + void reuse_after_grace_window_revokes_whole_family() throws Exception { + String tag = uniq(); + String rt1 = read(signup(tag), "$.data.refreshToken"); String rt2 = read(refresh(rt1), "$.data.refreshToken"); // rt1 은 이 시점에 revoke + jdbcTemplate.update("UPDATE refresh_tokens SET revoked_at = revoked_at - INTERVAL 31 SECOND WHERE token_hash = ?", + (Object) TokenService.sha256(rt1)); // 탈취범이 rt1 을 재사용 → 401 + family 전체 revoke expectError(refresh(rt1), 401, "SESSION_EXPIRED"); + assertThat(reuseDetectedCount(rt1)).isEqualTo(1); // 정상 사용자가 갖고 있던 rt2 도 함께 무효 (재로그인 요구) expectError(refresh(rt2), 401, "SESSION_EXPIRED"); } + @Test + @DisplayName("후속 RT 가 이미 다시 회전됐으면 창 안이어도 직전 RT 재제출은 재사용 감지") + void reuse_after_successor_rotated_revokes_whole_family() throws Exception { + String tag = uniq(); + String rt1 = read(signup(tag), "$.data.refreshToken"); + String rt2 = read(refresh(rt1), "$.data.refreshToken"); + String rt3 = read(refresh(rt2), "$.data.refreshToken"); + + expectError(refresh(rt1), 401, "SESSION_EXPIRED"); + expectError(refresh(rt3), 401, "SESSION_EXPIRED"); + } + + @Test + @DisplayName("후속 RT 로 로그아웃했으면 창 안이어도 직전 RT 로 세션을 되살릴 수 없다") + void logout_is_not_undone_by_stale_refresh() throws Exception { + String tag = uniq(); + MvcResult signup = signup(tag); + String rt1 = read(signup, "$.data.refreshToken"); + MvcResult rotated = refresh(rt1); + String at2 = read(rotated, "$.data.accessToken"); + String rt2 = read(rotated, "$.data.refreshToken"); + + assertThat(logout(at2, rt2).getResponse().getStatus()).isEqualTo(200); + expectError(refresh(rt1), 401, "SESSION_EXPIRED"); + } + @Test @DisplayName("로그아웃하면 해당 RT 는 무효가 되고, 로그아웃은 멱등이다") void logout_revokes_and_is_idempotent() throws Exception {