fix(S15P11A705-131): Refresh 재사용 시 회원 단위 세션 폐기 - #81
Conversation
재사용 감지가 401만 돌려주고 끝나 유출 시 공격자 세션이 살아남았다. 공격자가 먼저 회전하면 정상 사용자만 401로 끊기고, 공격자가 방금 받은 토큰은 최대 7일 유효하다(RFC 9700 §4.14.2). BD-32가 PR 크기 때문에 미뤄 둔 구멍이다. RED — 한 회원의 두 세션 중 하나를 회전 후 재사용했을 때 expected: 401 but was: 204 (다른 기기의 Refresh가 살아 있었다) GREEN - RefreshTokenStore에 회원별 인덱스(auth:refresh-index:<memberId> Set)와 revokeAll(memberId) 추가. SCAN은 쓰지 않는다 — 키 공간에 비례해 운영 Redis에 권장되지 않아 BD-32가 이미 기각했다 - rotate가 재사용을 감지하면 revokeAll 후 401 구현하며 드러난 것 - 기존 회전 테스트가 재사용 401 확인 뒤에 "회전 후 토큰은 계속 유효하다"를 단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다 - 폐기 대상에 회전으로 갓 발급된 토큰이 들어가야 한다. 유출 시나리오에서 그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다 - SADD만 하면 Set에 TTL이 없어 영구 키가 된다. 발급마다 EXPIRE를 다시 건다 과잉 폐기를 잡는 반대 방향 회귀도 넣었다(normalRotationKeepsOtherSessionsAlive) — 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다. 감수하는 것은 오탐이다. 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이 된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한 쪽을 골랐다. 방어선은 08 §3.3의 "재발급은 동시에 하나만"이다. REFACTOR - 인라인 주석을 Javadoc으로 옮겼다. 폐기 근거와 오탐 트레이드오프는 코드를 읽는 사람만이 아니라 이 API를 쓰는 사람이 알아야 한다 - 틀린 근거를 단 주석을 지웠다 — 접두사를 나눠야 키가 겹치지 않는다고 썼지만 auth:refresh:123과 auth:refresh:123:abc는 애초에 겹치지 않는다 - key/index → tokenKey/indexKey, valid → consumed(delete 후 변수라 "지금 유효하다"로 읽히면 틀리다), deviceA → deviceARefresh(토큰 문자열이다) 문서: BD-35 신규(새 트레이드오프), BD-32는 Superseded by BD-35로 상태만 갱신, BI-21 + 인덱스 + WORKLOG. 처음 BD-34/BI-20으로 썼다가 dev의 Feed 작업(#77)이 같은 번호를 먼저 확정해 재배정했다 — 번호는 dev 머지 순서로 확정된다. 회원 탈퇴(S15P11A705-65)가 같은 revokeAll을 쓴다. Refs #78 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
minyongP
left a comment
There was a problem hiding this comment.
로컬 dev(dda2ed3)와 대조해 봤습니다. RFC 9700 근거, (c) 계열 id 기각 논리("유출 토큰과 정상 토큰이 같은 계열"), 반대 방향 회귀와 전제 단언(⑤)까지 — 보안 변경으로서 갖춰야 할 것을 다 갖췄습니다. 기존 테스트를 지우지 않고 순서를 뒤집은 판단(리뷰 포인트 2)도 옳습니다. logout이 revokeAll이 아니라 consume을 쓰는 것도 맞습니다(한 기기 로그아웃이 다른 기기를 끊으면 안 됩니다).
문제는 폐기 연산이 원자적이지 않다는 것이고, 둘 다 이 PR이 막으려는 바로 그 시나리오에서 열립니다.
🔴 #80과 BD-35·BI-21 번호가 충돌합니다
같이 열려 있는 #80이 BD-35와 BI-21을 똑같이 씁니다.
| 이 PR | #80 | |
|---|---|---|
| BD-35 | BD-35-refresh-reuse-family-revocation.md |
BD-35-ai-derived-invalidation-inside-deletion-transaction.md |
| BI-21 | BI-21-2026-07-29-refresh-reuse-family-revocation.md |
BI-21-2026-07-29-ai-derived-invalidation-on-delete.md |
파일 이름이 달라 git이 충돌로 잡지 않습니다. WORKLOG.md는 merge=union이라 두 행이 나란히 들어가고, 각각 서로 다른 파일을 BD-35·BI-21로 링크합니다. dev의 2026-07-29 기록이 V4·BD-29·BI-18로 이미 한 번 남긴 실패 형태 그대로입니다 — "텍스트 충돌이 없는 것이 더 위험한 경우".
본문 "미결 / 후속"에 BD-34/BI-20 선점 때문에 이미 한 번 재배정하셨다고 적혀 있는데, 같은 일이 #80과 다시 걸렸습니다. decisions/README.md 규칙대로 나중에 머지되는 쪽이 BD-36·BI-22입니다. 이 PR은 decisions/README.md·implements/README.md 색인을 이미 갱신해 뒀으니, 이 PR이 먼저 머지되면 #80이 재배정 + 색인 추가를 하면 됩니다(#80은 색인을 갱신하지 않았습니다).
🔴 revokeAll의 read-then-delete 경합 — 공격자 토큰이 살아남습니다
Set<String> tokenIds = redisTemplate.opsForSet().members(indexKey); // ① 스냅샷
...
redisTemplate.delete(tokenKeys); // ③ 스냅샷만 삭제
redisTemplate.delete(indexKey); // ④ 인덱스 통째 삭제①과 ③ 사이에 정상 회전 한 건이 끼면(save가 j3을 인덱스에 추가) — j3은 스냅샷에 없어 ③에서 살아남고, ④가 인덱스를 지워 j3이 인덱스에서도 사라집니다. 결과는 *"유효하지만 이후 어떤 revokeAll로도 폐기되지 않는 Refresh 토큰"*입니다.
가설적이지 않다고 보는 이유: 재사용 감지는 회전 요청이 트리거이고, 유출 시나리오에서 정상 클라이언트와 공격자는 구조적으로 동시에 회전을 시도합니다. 창이 좁아도 폐기의 목적을 정확히 무력화합니다.
SMEMBERS + DEL을 Lua 스크립트 하나로 묶으면 닫힙니다. revokeAll 호출 빈도가 극히 낮으니 비용도 문제없습니다.
🔴 save의 세 왕복도 원자적이지 않습니다
redisTemplate.opsForValue().set(tokenKey(...), "1", ttl); // ①
redisTemplate.opsForSet().add(indexKey, tokenId); // ②
redisTemplate.expire(indexKey, ttl); // ③- ①만 성공하고 ②가 실패하면 유효하지만 인덱스에 없는 토큰이 생깁니다 →
revokeAll이 영원히 못 잡습니다(7일). - ②만 성공하고 ③이 실패하면 BI-21 ④가 고쳤다고 적은 바로 그 영구 키가 생깁니다.
SessionCallback 파이프라인이나 Lua 하나로 셋을 묶으면 둘 다 닫힙니다.
인덱스 TTL 계산 자체는 따로 검증해 봤는데 맞습니다 — save마다 인덱스 TTL을 다시 걸므로 항상 인덱스 만료 ≥ 모든 살아 있는 토큰의 만료가 성립합니다.
🟡 BD-35의 "감수하는 것"에 빠진 것 둘
revokeAll은 Access 토큰을 못 끊습니다. stateless RS256이고application.yml이access-token-ttl: 30m입니다. 따라서 요약의 *"공격자 세션이 즉시 끊긴다"*는 최대 30분 어긋납니다. RFC 9700 §4.14.2는 refresh 대상이라 구현은 준수 상태지만, 사고 대응하는 사람이 필요한 숫자라 결과 절에 남아야 합니다.- 만료 전 구 Refresh 토큰이 "이 사용자를 전 기기에서 로그아웃시키는" 수단이 됩니다. 반복 사용도 가능합니다. 문서는 오탐을 *"클라이언트가 실수로 두 번 보냄"*으로만 틀 잡았는데, 이건 의도적 공격이라 성질이 다릅니다. 비대칭 논거가 여전히 유효하므로 결정을 바꿀 이유는 아니고, 오탐 항목 옆에 나란히 적어야 할 것으로 봅니다.
🟡 기록의 테스트 수가 서로 다릅니다
PR 본문은 clean check "총 295개", BI-21 마지막 줄과 WORKLOG 행은 **"229개 통과"**입니다. 자리 바뀜으로 보이고 dev의 직전 WORKLOG가 292개라 295가 맞을 것 같습니다. BI-21·WORKLOG는 오래 남는 기록이니 고쳐 주세요.
🟡 ④(인덱스 TTL)를 고정하는 테스트가 없습니다
BI-21이 *"SADD만 하면 영구 키가 된다"*를 문제로 짚고 expire 한 줄로 고쳤는데, 그 한 줄을 지켜 주는 단언이 없습니다. 나중에 누가 지우면 증상은 "Redis에 회원 수만큼 영구 키가 쌓임"이고 어떤 테스트도 실패하지 않습니다. TTL auth:refresh-index:<memberId> > 0 단언 하나면 됩니다.
정리하면 설계 판단(오탐을 감수하고 전량 폐기)은 되돌릴 필요가 없고, 원자성이 남은 일입니다. 머지 전 필수는 번호 재배정입니다.
리뷰에서 지적된 🔴 두 건은 실제 결함이었고, 둘 다 이 변경이 막으려는 시나리오에서 정확히 열렸다. ① revokeAll의 read-then-delete 경합 SMEMBERS로 목록을 읽고 지우고 인덱스를 지우는 세 왕복 사이에 정상 회전 한 건이 끼면, 새 jti는 읽은 목록에 없어 삭제를 피하고 뒤따르는 인덱스 삭제가 그것을 인덱스에서도 지운다 → "유효하지만 이후 어떤 폐기로도 잡히지 않는 토큰". 재사용 감지는 회전 요청이 트리거이므로 유출 시나리오에서 이 창은 구조적으로 열린다. 놓친 것은 판단의 종류였다. BD-35에 "멱등하므로 동시 요청도 문제없다"고 적었는데, 멱등성은 같은 연산을 두 번 해도 같다는 성질이고 필요한 것은 다른 연산이 중간에 끼지 못한다는 성질이었다. ② save의 세 왕복도 같은 종류 토큰만 저장되고 SADD가 실패하면 인덱스에 없는 유효 토큰(7일간 폐기 불가), EXPIRE가 실패하면 직전 커밋이 고쳤다고 적은 바로 그 영구 키가 생긴다. 둘 다 Lua 스크립트 하나로 묶었다. 대가는 폐기 스크립트가 토큰 키를 ARGV로 조립해 Redis Cluster 규약과 어긋나는 것이다(BD-35 재검토 트리거에 기록). ③ EXPIRE 한 줄을 지켜 주는 단언 추가 지우면 증상이 "회원 수만큼 영구 키 누적"뿐이고 HTTP 계약 테스트는 전부 통과한다. RefreshTokenStoreTest로 Redis에 직접 만료를 묻는다. 통과만으로는 증거가 아니므로 EXPIRE를 임시로 빼서 -1(만료 없음)로 실패하는 것을 확인하고 원복했다. ④ BD-35의 감수 사항 둘 보강 - revokeAll은 Access를 끊지 못한다. stateless RS256 + 30분 수명이라 "즉시 끊긴다"가 최대 30분 어긋난다. RFC 9700 §4.14.2 대상은 refresh라 준수 상태다 - 만료 전 구 Refresh가 대상 회원을 반복해서 전 기기 로그아웃시키는 수단이 된다. 오탐의 악의적 쌍이며, 비대칭 논거가 유효해 결정은 바꾸지 않는다 ⑤ 테스트 수 정정 — BI-21·WORKLOG가 dev 병합 전 수치(229)를 들고 있었다 → 295 번호 충돌(#80이 같은 BD-35·BI-21 사용)은 현행 유지한다. 푸시 시점에 #80은 미머지이고 dev에 두 번호가 없다. 나중에 머지되는 쪽이 재배정한다. clean check 300개 통과, 실패 0 Refs #78 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
리뷰 감사합니다. 지적하신 🔴 두 건은 실제 결함이었고, 둘 다 이 PR이 막으려는 시나리오에서 정확히 열렸습니다. 🔴
|
코드에서 SAVE와 REVOKE_ALL은 대칭으로 보이지만 근거가 비대칭이다. 적어두지 않으면 "이 코드베이스는 왜 Lua를 쓰지, 트랜잭션으로 바꾸자"는 정리가 REVOKE_ALL까지 건드리고, 그러면 직전 커밋이 닫은 경합이 그대로 돌아온다. - REVOKE_ALL: MULTI로 대체 불가. 트랜잭션은 응답이 EXEC 때 와서 SMEMBERS 결과로 삭제 대상을 정할 수 없다. 이 선택은 취향이 아니고, 바꿔도 잡는 테스트가 없다는 사실까지 적었다 - SAVE: MULTI로도 정확하다. 여기서 막는 것은 경합이 아니라 부분 실패이고, Lua인 것은 메커니즘 통일을 위한 선택이라 이쪽만 트랜잭션으로 바꿔도 맞다 경합을 재현하는 테스트가 없다는 것은 BI-21에 이미 적혀 있다. 그래서 이 부분의 정확성은 테스트가 아니라 기록이 지킨다 — 리팩터하는 사람이 실제로 읽는 자리인 javadoc에 둔다. 동작 변경 없음. clean check 300개 통과 유지 Refs #78 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
요약
Refresh 재사용을 감지해도 401만 돌려주고 끝나, 토큰이 유출되면 공격자 세션이 최대 7일 살아남았다. 공격자가 먼저 회전하면 정상 사용자만 끊기고 공격자가 방금 받은 토큰은 유효하다. 재사용을 유출 신호로 보고 그 회원의 Refresh를 전부 폐기한다(RFC 9700 §4.14.2).
Jira (필수)
관련 GitHub Issue (선택)
변경 사항
RefreshTokenStore에 회원별 인덱스(auth:refresh-index:<memberId>Set)와revokeAll(memberId)추가save가jti를 인덱스에 넣고 인덱스 TTL을 발급마다 다시 건다consume이 소비한jti를 인덱스에서 뺀다(정리 목적, 정확성과 무관)SCAN은 쓰지 않는다 — 키 공간에 비례해 운영 Redis에 권장되지 않아 BD-32가 이미 기각한 안AuthTokenService.rotate가 재사용 감지 시revokeAll호출 후 401Superseded by BD-35(상태만 갱신), BI-21 + 인덱스 + WORKLOG테스트 / 검증
./gradlew clean check --no-daemonIntegrationContainerSupport(PostgreSQL + Redis 컨테이너) 기반docs/backend/decisions/에 BD 추가 또는 기존 BD 링크 — BD-35RED — 구현 전, 완료 조건을 그대로 옮긴 테스트가 실패했다.
GREEN — 구현 후 같은 명령.
Regression —
devrebase 후 전체.배경
BD-32가 계열 폐기를 미룬 근거는 위험의 크기가 아니라 PR 크기였다(#73이 파일 100개를 넘겼다). 이 PR이 그 트리거를 소화한다.
폐기 범위를 좁히는 안(토큰마다 계열 id 부여)을 검토하고 기각했다 — 유출된 토큰과 정상 토큰이 같은 계열에 있으므로 공격자만 골라 끊을 수 없다. 좁히기의 이득은 오탐 범위뿐인데 그 대가로 토큰에 계열 id를 넣고 회전마다 이어붙이는 구조를 진다. 자세한 트레이드오프는 BD-35에 있다.
리뷰 포인트
refreshRotatesAndInvalidatesPreviousToken이 재사용 401 확인 뒤에 "회전 후 토큰은 계속 유효하다"를 단언했다. 지우지 않고 순서를 뒤집었다 — 두 계약을 한 단언에 섞으면 나중에 어느 쪽이 깨졌는지 알 수 없다.normalRotationKeepsOtherSessionsAlive— 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다. 구현 전에도 통과하고 후에도 통과해야 하는, 방향이 반대인 단언이다.미결 / 후속
revokeAll을 쓴다. BD-32가 "두 작업을 같이 하는 것이 자연스럽다"고 적어 둔 이유이고, 이번에 그 연산이 준비됐다.decisions/README의 "번호는 dev 머지 순서로 확정된다" 규칙대로다.🤖 Generated with Claude Code