Skip to content

fix: 자격증명 버전으로 변경과 겹친 로그인·회전 세션 차단, 블랙리스트 ms 비교 - #492

Merged
chanwoo7 merged 2 commits into
developfrom
fix/credential-version-sessions
Oct 4, 2026
Merged

chanwoo7 merged 2 commits into
developfrom
fix/credential-version-sessions

Conversation

@chanwoo7

@chanwoo7 chanwoo7 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

배경

  • 이슈 #452입니다. 비밀번호 변경·초기화와 진행 중인 로그인이 겹치면, 변경 뒤에도 세션이 살아남을 수 있었습니다.
    • 경쟁: 옛 비밀번호 검증을 통과한 로그인이 토큰을 만들기 직전에 변경 트랜잭션(전 세션 폐기)이 커밋되면, 그 뒤 만든 refresh 세션은 폐기를 비껴가고 액세스 토큰도 cutoff 뒤 발급이라 통과했습니다. refresh 회전 중에 변경이 끼어도 같았습니다.
    • 1초 창: 블랙리스트가 JWT iat(초)로 비교해, 변경과 같은 초에 발급된 옛 토큰이 통과했습니다.
  • 영향 경로: 본인 비밀번호 변경, 관리자 비밀번호 초기화(feat: 관리자 비밀번호 초기화(adminResetAdminPassword) #451), 판매자 비밀번호 초기화

변경 (자격증명 버전 대조)

  • 자격증명 버전은 password_updated_at(ms)입니다. 변경 이력이 없거나 자격증명이 없는 구매자(OIDC) 계정은 0입니다.
  • 세션에 버전 기록: auth_refresh_session.credential_version 컬럼을 추가했습니다(마이그레이션).
    • 로그인은 비밀번호를 검증한 행의 버전을 세션과 액세스 토큰(cv 클레임)에 넣습니다. 발급 직전에 다시 읽지 않습니다.
    • 회전은 옛 세션의 버전을 새 세션과 새 토큰에 그대로 옮깁니다.
  • refresh 대조: 세션 버전이 현재 버전과 다르면 그 세션을 폐기하고 INVALID_REFRESH_TOKEN으로 거절합니다.
  • 블랙리스트 ms 비교:
    • 자격증명 키에 변경 시각을 ms로 저장하고, cv < cutoffMs면 거절합니다. 같은 초 문제가 사라집니다.
    • 키 이름(auth:blk:cr: → auth:blk:cv:)과 완전성 표식(auth:blk:ready → auth:blk:ready:v2)을 바꿔, 배포 직후 옛 초 단위 값을 ms로 읽지 않습니다. 새 표식이 서기 전에는 기존 설계대로 DB로 판정합니다.
    • DB 폴백도 같은 기준입니다.
  • 정지·탈퇴: 세션 발급·회전이 이미 계정 행을 잠가 정지와 직렬화됩니다. 코드는 그대로 두고, 이 순서를 고정하는 테스트가 없어 추가했습니다.
  • 가이드 §8 인증에 버전 흐름을 한 줄 추가했습니다.

배포 전환

  • cv가 없는 옛 토큰은 지금 방식(iat 초 비교)으로 판정합니다. 액세스 토큰 수명(15분)이 지나면 사라집니다.
  • 마이그레이션은 컬럼만 추가하고 기존 세션의 버전을 채우지 않습니다(리뷰 반영, 처음 플랜의 백필은 사용자 확인을 거쳐 제거).
    • 백필하면 수정 전 경쟁으로 살아남은 세션까지 현재 버전으로 인정되는데, 행만으로는 그런 세션을 가를 수 없습니다.
    • 비밀번호를 바꾼 적 있는 판매자·관리자의 기존 세션은 다음 refresh에서 폐기되어 배포 뒤 한 번 다시 로그인합니다.
    • 비밀번호를 바꾼 적 없는 계정과 구매자는 버전이 둘 다 비어 있어 그대로 이어집니다.
  • 배포 순서(마이그레이션 → worker → api) 때문에 생기는 짧은 창입니다. 모두 배포 중 수십 초 안에 끝납니다.
    • 마이그레이션 뒤 옛 api가 만든 세션은 버전이 비어, 비밀번호 변경 이력이 있는 판매자·관리자는 그 세션의 다음 refresh에서 다시 로그인해야 합니다(막는 쪽으로 실패).
    • 겹치는 동안 옛 api가 처리한 비밀번호 변경은 옛 키에만 기록되어, 다음 worker 재구축(60초 이내)까지 새 api에서 변경 전 토큰이 통과할 수 있습니다.

테스트

  • credential-version.service.spec(실DB·실Redis·실제 초기화 서비스)을 추가했습니다. 변경 3종(본인 변경·관리자 초기화·판매자 초기화)을 it.each로 돌립니다.
    • 로그인 경쟁: 검증과 발급 사이에 변경을 끼워 넣고, 그 세션이 살아 있는 것부터 확인합니다(경쟁이 실제로 났다는 증거). 액세스 토큰은 Redis 경로와 DB 폴백 모두에서 막히고, refresh는 거절되며 세션은 폐기됩니다.
    • 회전 경쟁: 회전 직전에 변경을 끼워 넣으면, 새 액세스 토큰은 막히고 새 세션은 다음 refresh에서 거절됩니다.
    • 변경 뒤 새 비밀번호 로그인과 refresh, 변경이 없을 때의 로그인과 회전 2번, 구매자 OIDC refresh는 지금처럼 통과합니다.
    • 버전 기록 전 세션: 변경 이력이 없으면 이어지고, 있으면 폐기·거절됩니다.
  • 전략 판정 표(Redis 경로와 DB 폴백 공통): 같은 초의 1ms 전 버전은 막고, 같은 버전은 통과합니다. cv 없는 옛 토큰은 앞 초를 막고 같은 초와 뒤 초는 통과합니다.
  • 블랙리스트: ms 저장과 같은 초 안의 재변경을 확인합니다. 옛 키와 옛 표식만 있으면 무시하는 것도 확인합니다.
  • 정지 경쟁: 정지 트랜잭션이 커밋되기 전에 세션 발급이 계정 행 잠금을 기다리게 만들고, 발급이 ACCOUNT_NOT_ACTIVE로 거절되어 살아 있는 세션이 0개인지 확인합니다.
  • 반증(돌연변이 8종) 결과입니다.
    • 세션 버전 비교를 빼면 로그인·회전 경쟁 6건이 실패합니다.
    • 초 비교로 되돌리면 7건이 실패합니다.
    • 로그인이 발급 때 버전을 다시 읽게 하면 로그인 경쟁 3건이 실패합니다.
    • 회전이 토큰이나 새 세션에 현재 버전을 쓰게 하면 회전 경쟁 3건이 실패합니다.
    • FOR UPDATE를 빼면 정지 경쟁 테스트가 실패합니다.
    • 옛 키 이름이나 옛 표식으로 되돌리면 해당 테스트가 실패합니다.

플랜 대조

플랜 4번 불릿 상태
버전 = password_updated_at(ms), null은 0 한 것
auth_refresh_session.credential_version + 백필 마이그레이션 컬럼은 한 것, 백필은 제거: 리뷰 지적(경쟁 세션까지 인정)을 사용자 확인 후 반영
로그인: 검증 시점 버전을 세션·cv에, 다시 읽지 않음 한 것
회전: 세션 버전 승계, 토큰도 세션 버전 한 것
refresh 대조 불일치 → 폐기 + INVALID_REFRESH_TOKEN 한 것
블랙리스트 ms 비교 + 키·표식 이름 변경, DB 폴백 같은 기준 한 것
cv 없는 옛 토큰은 iat 규칙 한 것
정지·탈퇴 경로는 그대로, 고정 테스트 없으면 추가 한 것(추가함)
회귀: 로그인·회전 경쟁, 1초 창, 옛 토큰, DB 폴백, 변경 없음 한 것

@coderabbitai

coderabbitai Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: CaQuick/caquick-be/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 09f7b548-33d6-4612-8d54-219f981d65aa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

🧹 knip — dead-code 리포트

Unused exported types (1)
전체 리포트
Unused exported types (1)
RateLimitPolicy  type  src/global/rate-limit/index.ts:4:8

청소 후보(오탐 가능) · 기준 docs/guide/architecture-conventions.md

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

🩺 NestJS Doctor — 90/100 (Excellent)

진단 469건 (error 12).

Category error warning info
architecture 1 1 42
correctness 0 260 0
performance 0 30 28
schema 0 0 75
security 11 21 0
architecture / security 상위 항목
  • error architecture/architecture/no-manual-instantiation: Manual instantiation of 'OutboxRepository' detected. Use dependency injection instead.
  • info architecture/architecture/no-barrel-export-internals: Barrel file re-exports internal type 'IAuditLogRepository'.
  • warning security/security/no-exposed-env-vars: Direct 'process.env.NODE_ENV' access in 'AuthController'. Use ConfigService instead.
  • warning security/security/require-guards-on-endpoints: Endpoint 'start' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'callback' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'refresh' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'logout' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'sellerLogin' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'sellerRefresh' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'sellerLogout' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'devIssueToken' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'adminLogin' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'adminRefresh' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'adminLogout' has no @UseGuards() at class or method level.
  • warning security/security/require-guards-on-endpoints: Endpoint 'getJwks' has no @UseGuards() at class or method level.

오탐 포함 가능 · 기준 docs/guide/architecture-conventions.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1939dfa342

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +7 to +10
UPDATE `auth_refresh_session` s
JOIN `account_credential` c ON c.`account_id` = s.`account_id`
SET s.`credential_version` = c.`password_updated_at`
WHERE s.`revoked_at` IS NULL;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not bless raced sessions during backfill

When production already contains an unrevoked session created by the pre-fix race this commit addresses—an old password was verified, the password change committed, and then the session was inserted—this update assigns that session the current credential version. assertSessionUsable will consequently see matching versions and allow the compromised session to rotate indefinitely, so the migration preserves precisely the sessions the fix is intended to reject. Because the verification-time version cannot be inferred from these rows, existing credential-backed sessions need to be revoked or otherwise treated as untrusted instead of being backfilled to the current version.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: 백필 제거 — 버전 미기록(null) 세션은 비밀번호 변경 이력이 있는 계정이면 다음 refresh에서 폐기·거절, 이력 없는 계정(경쟁 불가)은 null=null로 유지. 배포 뒤 변경 이력 있는 판매자·관리자 1회 재로그인(사용자 확인).

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Coverage report

St.❔
Category Percentage Covered / Total
🟢 Statements 97.91% 10063/10278
🟢 Branches 92.43% 3797/4108
🟢 Functions 97.45% 2026/2079
🟢 Lines 98.51% 9154/9292

Test suite run success

3782 tests passing in 357 suites.

Report generated by 🧪jest coverage report action from 89d9133

@codecov

codecov Bot commented Oct 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/features/auth/services/token.service.ts 90.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

비밀번호 변경·초기화 트랜잭션(교체 + 전 세션 폐기)이 로그인의 비밀번호 검증 뒤·발급 전에, 또는
refresh의 세션 확인 뒤·회전 전에 커밋되면 그 뒤 만들어진 세션이 폐기를 비껴가 살아남았다.
블랙리스트는 iat(초)와 비교해 변경과 같은 초에 발급된 옛 토큰도 통과시켰다(1초 창).

- 자격증명 버전 = account_credential.password_updated_at(ms, 이력 없으면 null/0)
- auth_refresh_session.credential_version 추가 + 마이그레이션(살아 있는 세션은 현재 버전으로 backfill)
- 로그인: 검증한 credential 행의 버전을 issueAuthTokens에 넘겨 세션·토큰(cv 클레임)에 싣는다(발급 전 재조회 없음)
  - OIDC 로그인은 null(구매자는 자격증명 없음), dev 토큰은 현재 버전
- refresh: 세션 버전 ≠ 현재 버전이면 그 세션을 폐기하고 INVALID_REFRESH_TOKEN. 역할 불일치는 기존대로 폐기 없이 거절
  - 회전은 세션 버전을 새 세션·새 토큰에 그대로 복사 — 확인 뒤 커밋된 변경도 토큰은 블랙리스트에, 새 세션은 다음 refresh에서 막힌다
- 블랙리스트: 변경 시각을 ms로 저장, cv < cutoff면 거부. cv 없는 옛 토큰만 iat < floor(cutoff/1000)
  - 키 auth:blk:cr: → auth:blk:cv:, 표식 auth:blk:ready → auth:blk:ready:v2 — 배포 직후 옛 초 값을 ms로 읽지 않고 재구축 전까지 DB 폴백
  - credentialCutoffSec 제거, issuedBeforeCredentialChange로 Redis 경로·DB 폴백이 같은 규칙
- 정지·탈퇴 경로는 그대로

테스트
- credential-version.service.spec(신규, real DB+Redis): 본인 변경·관리자 초기화·판매자 초기화 × 로그인 경쟁·회전 경쟁 →
  토큰 거부(Redis·DB 폴백) + 세션 refresh 거절·폐기, 변경 뒤 새 비밀번호 세션 통과, 무변경 연속 회전·OIDC(null) 회전
- jwt-bearer.strategy.spec: cv·legacy iat 판정 표를 Redis 경로·DB 폴백 양쪽에
- token-blacklist.service.spec: ms 저장·같은 초 갱신·옛 키/표식 무시·판정 함수 전수 표
- refresh-session.repository.spec: 버전 저장·복사, 정지 트랜잭션과 겹친 발급이 잠금 대기 뒤 거절(기존 고정 없음 → 추가)
- 반증: 세션 버전 비교 제거·로그인 버전 재조회·회전 토큰/새 세션 버전 재조회·초 비교·FOR UPDATE 제거·옛 키/표식 이름에서 각각 실패 확인
- 리뷰 지적: 백필이 수정 전 경쟁으로 살아남은 세션에도 현재 버전을 채워 새 대조를 통과시킴, 행만으로는 가를 수 없음
- 백필 제거. null 세션은 비밀번호를 바꾼 적 있는 계정이면 다음 refresh에서 폐기·거절(1회 재로그인), 바꾼 적 없는 계정(경쟁 불가)은 null끼리 일치해 유지
- 회귀: 버전 기록 전 세션 2건(변경 이력 없음 → 유지, 있음 → 폐기·거절)
@chanwoo7
chanwoo7 force-pushed the fix/credential-version-sessions branch from 1939dfa to 89d9133 Compare October 4, 2026 16:20

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 89d9133416

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +48 to +50
return token.cv !== undefined
? token.cv < cutoffMs
: token.iat < Math.floor(cutoffMs / 1000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make credential versions follow database commit order

When two password changes/resets for the same account run concurrently and acquire the credential-row lock in the opposite order from their application-generated timestamps, the later commit can leave password_updated_at lower than the cutoff already retained by SET_IF_NEWER. A subsequent login uses that lower current value as cv, so this comparison rejects every newly issued access token on the normal Redis path until the stale cutoff expires, while DB fallback accepts it. Allocate the version while holding the row lock or otherwise use a monotonic value whose order matches the committed credential state.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

미반영(기록): #493 — 유효하나 같은 계정 ms 단위 동시 변경에서만, 막는 쪽으로 실패하고 액세스 TTL(15분) 뒤 회복. 행 잠금 안 버전 할당은 후속으로(사용자 확인).

@chanwoo7
chanwoo7 merged commit 746d6fa into develop Oct 4, 2026
12 checks passed
@chanwoo7
chanwoo7 deleted the fix/credential-version-sessions branch October 4, 2026 16:33
chanwoo7 added a commit that referenced this pull request Oct 4, 2026
- 96/86/92/96 → 97/92/97/98(실측 정수 내림, 사용자 결정)
- 근거: 이 PR 첫 CI(run 37221170062)의 샤드 병합 수치 97.90/92.42/97.45/98.51
  - 10063/10278·3797/4108·2026/2079·9154/9292 — 같은 트리를 단일 실행한 #492 리포트와 개수까지 일치
- 임계는 jest.config.js 한 곳(로컬 test:cov는 jest가, CI는 coverage-report의 merge-coverage가 같은 값으로 검사)
- README·README.en·가이드 §9·jest.scripts.config.js 주석의 수치 갱신
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant