Skip to content

fix(S15P11A705-131): Refresh 재사용을 감지해도 회원의 다른 세션이 살아남는다 #78

Description

@cherry-go-round

요약

AuthTokenService.rotate는 이미 소비된 jti로 들어온 요청에 401을 돌려주고 WARN만 남긴다. 그 회원의 다른 Refresh 토큰은 건드리지 않는다. 토큰이 유출되면 공격자가 먼저 회전해 새 토큰을 얻고, 정상 사용자만 401로 끊긴다 — 공격자 세션은 최대 7일 살아남는다.

Jira (필수)

상위/관련 GitHub Issue (선택)

재현 절차

  1. 한 회원이 두 기기에서 로그인해 서로 다른 Refresh 토큰 A·B를 갖는다
  2. APOST /api/core/v1/auth/refresh → 성공(204), A는 소비되고 A'가 발급된다
  3. A를 다시 사용해 재발급을 시도한다 → 401 (재사용 감지)
  4. 그 상태에서 B로 재발급을 시도한다

기대 / 실제

  • 기대: 3단계에서 재사용이 감지된 순간 그 회원의 Refresh 토큰이 전부 폐기되어, 4단계의 B도 401이 된다
  • 실제: B는 그대로 유효해 재발급이 성공한다. A'(공격자가 받았을 토큰)도 살아 있다

원인 / 해결 방향

RefreshTokenStoreauth:refresh:<memberId>:<jti> 키를 jti마다 하나씩 두고 삭제로 소비한다. 세션 독립성을 위한 의도된 구조(BD-21)지만, 회원 단위로 모아 볼 수단이 없다.

  • 회원별 jti 인덱스를 추가해 회원 단위 일괄 폐기를 만든다
  • SCAN은 쓰지 않는다. auth:refresh:<memberId>:* SCAN은 키 공간에 비례해 운영 Redis에서 권장되지 않는다(BD-32에서 이미 기각한 안)
  • rotate의 재사용 감지 분기에서 그 폐기를 호출한다
  • 이 인덱스는 회원 탈퇴(S15P11A705-65) 가 필요로 하는 것과 같은 연산이다. BD-32에 "두 작업을 같이 하는 것이 자연스럽다"고 적혀 있다

근거: RFC 9700 §4.14.2 — 재사용 감지 시 해당 사용자의 refresh 토큰을 전부 폐기하라

완료 조건

  • 재현 케이스로 실패하는 테스트 추가 (4단계가 401이 아님을 먼저 확인)
  • 토큰 2개 중 하나를 회전 전 재사용하면 나머지 토큰으로도 재발급이 401
  • 정상 회전에서는 다른 기기의 Refresh가 무효화되지 않음 (세션 독립성 회귀 방지)
  • 회원 단위 폐기가 SCAN 없이 동작
  • 재사용 감지 시 WARN 로그 유지
  • BD-32의 상태 갱신 (문서 삭제 없이 상태만)
  • PostgreSQL Testcontainers + Redis 컨테이너 기반 테스트로 검증
  • ./gradlew clean check --no-daemon 통과

참고

BD-32가 감수 사항으로 기록해 둔 내용 — "Refresh가 유출되면 공격자 세션이 최대 7일 살아남는다. 이것은 BD-21의 무효화 지연(로그아웃 후 Access 30분)과 다른 구멍이다. 그쪽은 시간이 지나면 닫히고, 이쪽은 공격자가 계속 회전하는 한 닫히지 않는다."

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug잘못된 동작 (fix)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions