Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,12 @@ public ApiResponse<SignupResponse> signup(@RequestBody SignupRequest request) {
이미 무효화된 refreshToken 이 다시 제출되면 **토큰 탈취로 간주**해, 그 토큰에서 파생된 세션 전체를 폐기한다.
이 경우 정상 기기도 함께 로그아웃되며 재로그인이 필요하다.

**클라이언트는 refresh 호출을 반드시 single-flight로 직렬화해야 한다.** 같은 refreshToken 을 두 요청이
동시에 제출하면 하나만 회전에 성공하고, 후발 요청은 재사용으로 감지되어 선발 요청이 받은 새 토큰까지
family 전체가 폐기된다. 새 refreshToken 저장도 응답 처리와 함께 원자적으로 덮어써야 한다.
**회전 유예(30초):** 방금 회전된 refreshToken 이 30초 안에 다시 오고 그 회전으로 받은 토큰이 아직 살아 있으면
탈취가 아니라 갱신 경합으로 보고 **새 토큰 쌍을 하나 더** 준다(먼저 받은 쌍도 계속 유효). 로그아웃·다른 기기 로그인·
정지·탈퇴로 끊긴 세션, 이미 한 번 더 회전된 토큰, 30초가 지난 토큰은 유예하지 않는다.

**그래도 클라이언트는 refresh 호출을 single-flight로 직렬화해야 한다.** 유예는 경합으로 인한 강제 로그아웃을
막는 안전망일 뿐이다. 새 refreshToken 저장도 응답 처리와 함께 원자적으로 덮어써야 한다.

만료·위조·폐기·탈퇴 계정 — 어느 쪽이든 401 `SESSION_EXPIRED` 하나로 내려간다.
클라이언트 처리는 모두 동일하게 "로그인 화면으로" 이므로 원인을 구분하지 않는다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import jakarta.persistence.LockModeType;

import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -30,6 +31,9 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID
@Query("select t from RefreshToken t where t.tokenHash = :tokenHash")
Optional<RefreshToken> findByTokenHashForUpdate(@Param("tokenHash") byte[] tokenHash);

/** 이 토큰을 회전해 발급된 후속 토큰들 — 회전 유예 판단용(parent_token_id 인덱스). */
List<RefreshToken> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)은 재시도 대상이 아니다. */
Expand Down Expand Up @@ -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()) {
Expand All @@ -633,6 +651,24 @@ public TokenResponse refresh(String refreshTokenValue) {
return TokenResponse.from(tokenService.issueRotatedPair(user, stored.getFamilyId(), stored.getId()), syncPolicy.forUser(user));
}

/**
* 회전 유예에 드는지 — <b>회전으로</b> 폐기된 지 {@link #ROTATION_GRACE} 안이고, 그 회전이 만든 후속 토큰이
* 모두 살아 있을 때만이다.
*
* <p>후속 토큰이 살아 있는지가 핵심이다. 로그아웃 · 새 기기 로그인 · 정지 · 탈퇴 · 재사용 감지는 후속 토큰까지
* 폐기하므로 여기서 걸러진다. 후속 토큰이 이미 다시 회전됐어도(앱이 새 토큰을 쓰기 시작했다) 유예하지 않는다.
* 후속이 없으면 회전이 아니라 로그아웃으로 폐기된 토큰이다.
*
* <p>창 안에서 탈취된 직전 토큰도 새 페어를 받을 수 있다 — 창을 짧게 두는 이유다. 창이 지나 다시 오면
* 기존대로 재사용 감지가 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<RefreshToken> successors = refreshTokenRepository.findByParentTokenId(stored.getId());
return !successors.isEmpty() && successors.stream().noneMatch(RefreshToken::isRevoked);
}

// ===== 로그아웃 (멱등) =====
@Transactional
public void logout(String refreshTokenValue) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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");

Expand All @@ -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 {
Expand Down
Loading