diff --git a/docs/guide/architecture-conventions.md b/docs/guide/architecture-conventions.md index 7824e227..8518dc1e 100644 --- a/docs/guide/architecture-conventions.md +++ b/docs/guide/architecture-conventions.md @@ -105,6 +105,7 @@ - 액세스 토큰은 **RS256**, 공개키는 `/.well-known/jwks.json`(kid = RFC 7638 썸프린트). 서명·검증 키는 `authConfig` 하나가 해석한다(raw env 재파싱 금지). - **정상 경로는 DB를 읽지 않는다.** 전략은 서명이 유효한 토큰의 클레임(role·mustChangePassword)을 신뢰한다. - 정지·탈퇴·비밀번호 변경(관리자 초기화 포함)은 **트랜잭션 커밋 뒤** Redis 블랙리스트에 등록한다(`auth:blk:*`, 액세스 TTL만큼). 등록 실패는 던지지 않고 경보 + 완전성 표식 삭제 → 전략이 DB 폴백. worker가 60초마다 재구축·조정하고 표식을 세운다. Redis `maxmemory`가 있으면 `noeviction`이어야 한다(표식만 살아남는 축출 정책은 위험). +- 비밀번호 변경은 **자격증명 버전**(`password_updated_at`, ms)으로 가른다. 로그인은 비밀번호를 검증할 때 읽은 버전을 세션(`credential_version`)과 액세스 토큰(`cv`)에 싣고, 회전은 확인한 세션의 버전을 그대로 넘긴다. refresh는 세션 버전이 현재와 다르면 그 세션을 폐기하고 거절하며, 블랙리스트·DB 폴백은 `cv`가 변경 시각보다 작은 토큰을 막는다(`cv`가 없는 옛 토큰만 `iat` 초 비교). 그래서 변경 트랜잭션과 겹쳐 전 세션 폐기를 비껴간 로그인·회전도 살아남지 못한다. - **왜 fail-open이 아닌가.** Redis 장애 때 "막지 못함"이 아니라 "DB로 다시 봄"이라 보안 후퇴가 없다. 대신 경보가 간다. ### 경보 diff --git a/prisma/migrations/20261004144805_refresh_session_credential_version/migration.sql b/prisma/migrations/20261004144805_refresh_session_credential_version/migration.sql new file mode 100644 index 00000000..6456ab79 --- /dev/null +++ b/prisma/migrations/20261004144805_refresh_session_credential_version/migration.sql @@ -0,0 +1,7 @@ +-- 세션이 발급될 때의 자격증명 버전(password_updated_at). refresh가 현재 값과 비교해, 비밀번호 변경과 겹쳐 +-- 발급된 세션(변경 트랜잭션의 전 세션 폐기를 비껴간 로그인·회전)을 거절한다. +-- AlterTable +ALTER TABLE `auth_refresh_session` ADD COLUMN `credential_version` DATETIME(3) NULL; + +-- 백필하지 않는다: 이미 경쟁으로 살아남은 세션이 있어도 행만으로는 가를 수 없다. null 세션은 비밀번호를 바꾼 적이 있는 +-- 계정이면 다음 refresh에서 폐기되고(1회 재로그인), 바꾼 적이 없는 계정(경쟁 불가)은 null끼리 일치해 이어진다. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5b739c3e..d37abe2b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -302,6 +302,9 @@ model AuthRefreshSession { replaced_by_session_id BigInt? @db.UnsignedBigInt + // 발급 근거가 된 자격증명 버전(account_credential.password_updated_at). 현재 값과 다르면 refresh를 거부한다. + credential_version DateTime? @db.DateTime(3) + created_at DateTime @default(now()) @db.DateTime(3) updated_at DateTime @updatedAt @db.DateTime(3) deleted_at DateTime? @db.DateTime(3) diff --git a/src/features/auth/auth.service.spec.ts b/src/features/auth/auth.service.spec.ts index 185e0e79..c12f3759 100644 --- a/src/features/auth/auth.service.spec.ts +++ b/src/features/auth/auth.service.spec.ts @@ -255,6 +255,7 @@ describe('AuthService', () => { typ: 'access', role: 'USER', mustChangePassword: false, + cv: 0, }); }); diff --git a/src/features/auth/auth.service.ts b/src/features/auth/auth.service.ts index 045fb733..a48c5c33 100644 --- a/src/features/auth/auth.service.ts +++ b/src/features/auth/auth.service.ts @@ -42,7 +42,10 @@ export class AuthService { throw new DomainException('ACCOUNT_NOT_ACTIVE'); } - const accessToken = this.tokens.signAccessToken(account); + const accessToken = this.tokens.signAccessToken( + account, + account.credential?.password_updated_at ?? null, + ); return { accessToken, tokenType: 'Bearer', diff --git a/src/features/auth/repositories/refresh-session.repository.interface.ts b/src/features/auth/repositories/refresh-session.repository.interface.ts index df4feb23..8825ea8c 100644 --- a/src/features/auth/repositories/refresh-session.repository.interface.ts +++ b/src/features/auth/repositories/refresh-session.repository.interface.ts @@ -4,13 +4,14 @@ import type { AuthRefreshSession } from '@/generated/prisma/client'; export const REFRESH_SESSION_REPOSITORY = Symbol('REFRESH_SESSION_REPOSITORY'); export interface IRefreshSessionRepository { - /** 토큰은 hash만 저장한다. */ + /** 토큰은 hash만 저장한다. credentialVersion = 발급 근거가 된 password_updated_at(refresh가 현재 값과 비교한다). */ createRefreshSession(args: { accountId: bigint; tokenHash: string; userAgent?: string; ipAddress?: string; expiresAt: Date; + credentialVersion: Date | null; }): Promise; findActiveRefreshSessionByHash( @@ -24,6 +25,7 @@ export interface IRefreshSessionRepository { userAgent?: string; ipAddress?: string; newExpiresAt: Date; + credentialVersion: Date | null; }): Promise; revokeRefreshSession(sessionId: bigint): Promise; diff --git a/src/features/auth/repositories/refresh-session.repository.spec.ts b/src/features/auth/repositories/refresh-session.repository.spec.ts index a5656559..4d11db64 100644 --- a/src/features/auth/repositories/refresh-session.repository.spec.ts +++ b/src/features/auth/repositories/refresh-session.repository.spec.ts @@ -1,4 +1,7 @@ import { ClockService } from '@/common/providers/clock.service'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; import { RefreshSessionRepository } from '@/features/auth/repositories/refresh-session.repository'; import type { PrismaClient } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -8,6 +11,8 @@ import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.bui describe('RefreshSessionRepository (real DB)', () => { let repo: RefreshSessionRepository; + let admins: AccountAdminRepository; + let auditLogs: AuditLogRepository; let prisma: PrismaClient; let clock: ClockService; @@ -16,10 +21,14 @@ describe('RefreshSessionRepository (real DB)', () => { const { module, prisma: p } = await createTestingModuleWithRealDb({ providers: [ RefreshSessionRepository, + AccountAdminRepository, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, { provide: ClockService, useValue: clock }, ], }); repo = module.get(RefreshSessionRepository); + admins = module.get(AccountAdminRepository); + auditLogs = module.get(AUDIT_LOG_REPOSITORY); prisma = p; }); @@ -32,6 +41,10 @@ describe('RefreshSessionRepository (real DB)', () => { await truncateAll(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('createRefreshSession', () => { it('refresh session을 생성한다', async () => { const account = await createAccount(prisma); @@ -43,6 +56,7 @@ describe('RefreshSessionRepository (real DB)', () => { userAgent: 'test-agent', ipAddress: '1.2.3.4', expiresAt, + credentialVersion: null, }); expect(session.account_id).toBe(account.id); @@ -57,11 +71,26 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, tokenHash: 'b'.repeat(64), expiresAt: new Date(Date.now() + 3600_000), + credentialVersion: null, }); expect(session.user_agent).toBeNull(); expect(session.ip_address).toBeNull(); }); + + it('넘겨받은 자격증명 버전을 저장한다', async () => { + const account = await createAccount(prisma); + const version = new Date('2026-10-04T00:00:00.123Z'); + + const session = await repo.createRefreshSession({ + accountId: account.id, + tokenHash: 'v'.repeat(64), + expiresAt: new Date(Date.now() + 3600_000), + credentialVersion: version, + }); + + expect(session.credential_version).toEqual(version); + }); }); describe('findActiveRefreshSessionByHash', () => { @@ -119,6 +148,7 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, newTokenHash: 'f'.repeat(64), newExpiresAt: new Date(Date.now() + 3600_000), + credentialVersion: null, }); expect(newSession.token_hash).toBe('f'.repeat(64)); @@ -142,11 +172,30 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, newTokenHash: 'k'.repeat(64), newExpiresAt: new Date(Date.now() + 3600_000), + credentialVersion: null, }); expect(newSession.user_agent).toBeNull(); expect(newSession.ip_address).toBeNull(); }); + + it('새 세션에 넘겨받은 자격증명 버전을 저장한다', async () => { + const account = await createAccount(prisma); + const oldSession = await createRefreshSession(prisma, { + account_id: account.id, + }); + const version = new Date('2026-10-04T00:00:00.123Z'); + + const newSession = await repo.rotateRefreshSession({ + currentSessionId: oldSession.id, + accountId: account.id, + newTokenHash: 'w'.repeat(64), + newExpiresAt: new Date(Date.now() + 3600_000), + credentialVersion: version, + }); + + expect(newSession.credential_version).toEqual(version); + }); }); describe('revokeRefreshSession', () => { @@ -197,6 +246,7 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, tokenHash: 'h'.repeat(64), expiresAt: new Date(Date.now() + 60_000), + credentialVersion: null, }), ).rejects.toThrowDomain(403); expect( @@ -223,6 +273,7 @@ describe('RefreshSessionRepository (real DB)', () => { accountId: account.id, newTokenHash: 'n'.repeat(64), newExpiresAt: new Date(Date.now() + 60_000), + credentialVersion: null, }), ).rejects.toThrowDomain(403); expect( @@ -235,5 +286,64 @@ describe('RefreshSessionRepository (real DB)', () => { }); expect(same.revoked_at).toBeNull(); }); + + /** 이 워커 DB에서 행 잠금을 기다리는 트랜잭션이 생길 때까지. */ + async function waitForLockWait(): Promise { + for (let i = 0; i < 250; i++) { + const [{ n }] = await prisma.$queryRaw<{ n: bigint }[]>` + SELECT COUNT(*) AS n FROM information_schema.innodb_trx t + JOIN information_schema.processlist p ON p.id = t.trx_mysql_thread_id + WHERE t.trx_state = 'LOCK WAIT' AND p.db = DATABASE()`; + if (n > 0) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error('잠금 대기가 관측되지 않았다'); + } + + // 정지 트랜잭션이 계정 행을 잡은 채(전 세션 폐기 뒤, 커밋 전) 발급이 끼어든다 + it('정지 트랜잭션과 겹친 발급은 잠금을 기다렸다가 거절된다 — 정지된 계정에 살아 있는 세션이 남지 않는다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + let issuing: Promise | undefined; + const recordAudit = auditLogs.recordAudit.bind(auditLogs); + jest + .spyOn(auditLogs, 'recordAudit') + .mockImplementationOnce(async (tx, entry) => { + issuing = repo + .createRefreshSession({ + accountId: account.id, + tokenHash: 'r'.repeat(64), + expiresAt: new Date(Date.now() + 60_000), + credentialVersion: null, + }) + .then( + () => null, + (error: unknown) => error, + ); + await waitForLockWait(); + return recordAudit(tx, entry); + }); + + await admins.updateAccountStatus({ + accountId: account.id, + from: 'ACTIVE', + to: 'SUSPENDED', + revokeSessions: true, + invalidTransitionCode: 'ONLY_ACTIVE_CAN_BE_SUSPENDED', + audit: { + actorAccountId: account.id, + storeId: null, + targetType: 'ACCOUNT', + targetId: account.id, + action: 'STATUS_CHANGE', + }, + }); + + expect(await issuing).toThrowDomain('ACCOUNT_NOT_ACTIVE'); + expect( + await prisma.authRefreshSession.count({ + where: { account_id: account.id, revoked_at: null }, + }), + ).toBe(0); + }); }); }); diff --git a/src/features/auth/repositories/refresh-session.repository.ts b/src/features/auth/repositories/refresh-session.repository.ts index dd331bb5..0f76bc09 100644 --- a/src/features/auth/repositories/refresh-session.repository.ts +++ b/src/features/auth/repositories/refresh-session.repository.ts @@ -19,6 +19,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { userAgent?: string; ipAddress?: string; expiresAt: Date; + credentialVersion: Date | null; }): Promise { return this.prisma.$transaction(async (tx) => { await this.assertAccountActiveForUpdate(tx, args.accountId); @@ -29,6 +30,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { user_agent: args.userAgent ?? null, ip_address: args.ipAddress ?? null, expires_at: args.expiresAt, + credential_version: args.credentialVersion, }, }); }); @@ -72,6 +74,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { userAgent?: string; ipAddress?: string; newExpiresAt: Date; + credentialVersion: Date | null; }): Promise { return this.prisma.$transaction(async (tx) => { const now = this.clock.now(); @@ -84,6 +87,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository { user_agent: args.userAgent ?? null, ip_address: args.ipAddress ?? null, expires_at: args.newExpiresAt, + credential_version: args.credentialVersion, }, }); diff --git a/src/features/auth/services/blacklist-rebuild.service.spec.ts b/src/features/auth/services/blacklist-rebuild.service.spec.ts index 437e4103..aa9dd117 100644 --- a/src/features/auth/services/blacklist-rebuild.service.spec.ts +++ b/src/features/auth/services/blacklist-rebuild.service.spec.ts @@ -11,7 +11,6 @@ import type { PrismaClient } from '@/generated/prisma/client'; import { AlertService } from '@/global/alerting'; import { BLACKLIST_READY_TTL_SECONDS, - credentialCutoffSec, TokenBlacklistService, } from '@/global/auth/blacklist'; import { TEST_AUTH_CONFIG } from '@/test/auth-config'; @@ -137,7 +136,7 @@ describe('BlacklistRebuildService (real DB + real Redis)', () => { await expect(blacklist.lookup(suspended)).resolves.toEqual({ ready: true, status: 'SUSPENDED', - credentialCutoffSec: null, + credentialCutoffMs: null, }); // 탈퇴 버전도 status_changed_at(단조) — deleted_at이 아니다 expect(await statusValue(deleted.id)).toBe( @@ -145,11 +144,11 @@ describe('BlacklistRebuildService (real DB + real Redis)', () => { ); await expect(blacklist.lookup(changed)).resolves.toMatchObject({ status: null, - credentialCutoffSec: credentialCutoffSec(IN_WINDOW), + credentialCutoffMs: IN_WINDOW.getTime(), }); expect(await statusOf(oldSuspended)).toBeNull(); await expect(blacklist.lookup(oldChanged)).resolves.toMatchObject({ - credentialCutoffSec: null, + credentialCutoffMs: null, }); }); @@ -335,7 +334,7 @@ describe('BlacklistRebuildService (real DB + real Redis)', () => { await expect(blacklist.lookup(BigInt(1))).resolves.toEqual({ ready: true, status: null, - credentialCutoffSec: null, + credentialCutoffMs: null, }); }); diff --git a/src/features/auth/services/credential-auth.service.ts b/src/features/auth/services/credential-auth.service.ts index 9d057262..ce34a354 100644 --- a/src/features/auth/services/credential-auth.service.ts +++ b/src/features/auth/services/credential-auth.service.ts @@ -87,8 +87,10 @@ export class CredentialAuthService { const now = this.clock.now(); await this.credentials.updateLastLogin(credential.account_id, now); + // 버전은 검증한 행의 것 — 검증 뒤 커밋된 변경이 있으면 이 세션·토큰은 버전이 낡아 막힌다 const { accessToken } = await this.tokens.issueAuthTokens({ accountId: credential.account_id, + credentialVersion: credential.password_updated_at, req: args.req, res: args.res, }); diff --git a/src/features/auth/services/credential-version.service.spec.ts b/src/features/auth/services/credential-version.service.spec.ts new file mode 100644 index 00000000..ccb5d078 --- /dev/null +++ b/src/features/auth/services/credential-version.service.spec.ts @@ -0,0 +1,405 @@ +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import argon2 from 'argon2'; +import type { Request, Response } from 'express'; +import type Redis from 'ioredis'; + +import { ClockService } from '@/common/providers/clock.service'; +import { sha256Hex } from '@/common/utils/crypto'; +import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; +import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; +import { AuthService } from '@/features/auth/auth.service'; +import { AccountAdminRepository } from '@/features/auth/repositories/account-admin.repository'; +import { AccountCredentialRepository } from '@/features/auth/repositories/account-credential.repository'; +import { ACCOUNT_CREDENTIAL_REPOSITORY } from '@/features/auth/repositories/account-credential.repository.interface'; +import { AccountRepository } from '@/features/auth/repositories/account.repository'; +import { ACCOUNT_REPOSITORY } from '@/features/auth/repositories/account.repository.interface'; +import { RefreshSessionRepository } from '@/features/auth/repositories/refresh-session.repository'; +import { REFRESH_SESSION_REPOSITORY } from '@/features/auth/repositories/refresh-session.repository.interface'; +import { AdminAccountService } from '@/features/auth/services/auth-admin-account.service'; +import { + CredentialAuthService, + type CredentialRole, +} from '@/features/auth/services/credential-auth.service'; +import { TokenService } from '@/features/auth/services/token.service'; +import { JwtBearerStrategy } from '@/features/auth/strategies/jwt-bearer.strategy'; +import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; +import { AdminSellerService } from '@/features/store/services/store-admin-seller.service'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { AlertService } from '@/global/alerting'; +import type { AccessTokenPayload } from '@/global/auth'; +import { + BLACKLIST_READY_KEY, + TokenBlacklistService, +} from '@/global/auth/blacklist/token-blacklist.service'; +import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; +import { TEST_AUTH_CONFIG } from '@/test/auth-config'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { connectTestRedis } from '@/test/db/redis-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createAccountCredential } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; +import { redisTestProviders } from '@/test/redis'; + +const PASSWORD = 'Current!Pass1'; +const NEW_PASSWORD = 'Changed!Pass2'; + +const jwt = new JwtService({ + privateKey: TEST_AUTH_CONFIG.jwtKeys.privateKeyPem, + publicKey: TEST_AUTH_CONFIG.jwtKeys.publicKeyPem, + signOptions: { + algorithm: 'RS256', + issuer: TEST_AUTH_CONFIG.jwtIssuer, + audience: TEST_AUTH_CONFIG.jwtAudience, + keyid: TEST_AUTH_CONFIG.jwtKeys.kid, + expiresIn: TEST_AUTH_CONFIG.jwtAccessExpiresSeconds, + }, +}); + +// 비밀번호 변경과 겹친 발급: 변경 트랜잭션(교체 + 전 세션 폐기)이 커밋된 뒤에 만들어진 세션·토큰은 폐기를 비껴간다 +describe('자격증명 버전 — 비밀번호 변경과 겹친 로그인·회전 (real DB + real Redis)', () => { + let prisma: PrismaClient; + let redis: Redis; + let credentialAuth: CredentialAuthService; + let auth: AuthService; + let tokens: TokenService; + let credentials: AccountCredentialRepository; + let refreshSessions: RefreshSessionRepository; + let blacklist: TokenBlacklistService; + let adminAccounts: AdminAccountService; + let adminSellers: AdminSellerService; + let strategy: JwtBearerStrategy; + let passwordHash: string; + const alerts = { notify: jest.fn().mockResolvedValue('sent') }; + + beforeAll(async () => { + redis = await connectTestRedis(); + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + CredentialAuthService, + AuthService, + TokenService, + AdminAccountService, + AdminSellerService, + StoreSellerRepository, + AccountAdminRepository, + TokenBlacklistService, + ClockService, + { provide: ACCOUNT_REPOSITORY, useClass: AccountRepository }, + { + provide: ACCOUNT_CREDENTIAL_REPOSITORY, + useClass: AccountCredentialRepository, + }, + { + provide: REFRESH_SESSION_REPOSITORY, + useClass: RefreshSessionRepository, + }, + { provide: AUDIT_LOG_REPOSITORY, useClass: AuditLogRepository }, + { provide: AlertService, useValue: alerts }, + { provide: JwtService, useValue: jwt }, + { + provide: ConfigService, + useValue: { getOrThrow: () => TEST_AUTH_CONFIG }, + }, + ...redisTestProviders(redis), + ], + }); + prisma = p; + credentialAuth = module.get(CredentialAuthService); + auth = module.get(AuthService); + tokens = module.get(TokenService); + credentials = module.get(ACCOUNT_CREDENTIAL_REPOSITORY); + refreshSessions = module.get(REFRESH_SESSION_REPOSITORY); + blacklist = module.get(TokenBlacklistService); + adminAccounts = module.get(AdminAccountService); + adminSellers = module.get(AdminSellerService); + // Passport Strategy는 생성자에서 super()를 부르므로 validate만 쓰기 위해 prototype에서 만든다 + strategy = Object.assign(Object.create(JwtBearerStrategy.prototype), { + accounts: module.get(ACCOUNT_REPOSITORY), + blacklist, + alerts, + }) as JwtBearerStrategy; + passwordHash = await argon2.hash(PASSWORD, { type: argon2.argon2id }); + }); + + afterAll(async () => { + await redis.quit(); + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + await redis.flushdb(); + await blacklist.markReady(await blacklist.generation()); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function jar() { + const set: Record = {}; + const res = { + cookie: (name: string, value: string) => { + set[name] = value; + }, + } as unknown as Response; + return { res, set }; + } + + function reqWith(cookies: Record = {}): Request { + return { cookies, headers: {}, ip: '127.0.0.1' } as unknown as Request; + } + + async function login(role: CredentialRole, username: string) { + const { res, set } = jar(); + const { accessToken } = await credentialAuth.login({ + role, + username, + password: PASSWORD, + req: reqWith(), + res, + }); + return { accessToken, refreshToken: set[REFRESH_COOKIE[role]] }; + } + + async function refresh(role: CredentialRole, refreshToken: string) { + const { res, set } = jar(); + const { accessToken } = await credentialAuth.refresh({ + role, + req: reqWith({ [REFRESH_COOKIE[role]]: refreshToken }), + res, + }); + return { accessToken, refreshToken: set[REFRESH_COOKIE[role]] }; + } + + function sessionOf(refreshToken: string) { + return prisma.authRefreshSession.findFirstOrThrow({ + where: { token_hash: sha256Hex(refreshToken) }, + }); + } + + /** Redis 경로(표식 있음)와 DB 폴백(표식 없음) 둘 다에서 같은 판정이어야 한다. */ + async function expectAccessToken(accessToken: string, blocked: boolean) { + const payload = jwt.verify(accessToken); + for (const path of ['redis', 'db'] as const) { + if (path === 'db') await redis.del(BLACKLIST_READY_KEY); + const result = strategy.validate(payload); + if (blocked) { + await expect(result).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); + } else { + await expect(result).resolves.toMatchObject({ accountId: payload.sub }); + } + } + await blacklist.markReady(await blacklist.generation()); + } + + async function makeCredential(role: CredentialRole) { + return createAccountCredential(prisma, { + account_type: role, + password_hash: passwordHash, + }); + } + + async function adminActor(): Promise { + return (await createAccount(prisma, { account_type: 'ADMIN' })).id; + } + + // 비밀번호를 바꾸는 세 경로 — 모두 교체·전 세션 폐기를 한 트랜잭션으로 하고 커밋 뒤 블랙리스트에 등록한다 + const CHANGES: Array< + [string, CredentialRole, (accountId: bigint) => Promise] + > = [ + [ + '본인 변경', + 'SELLER', + (accountId) => + credentialAuth.changePassword({ + role: 'SELLER', + accountId, + currentPassword: PASSWORD, + newPassword: NEW_PASSWORD, + req: reqWith(), + }), + ], + [ + '관리자 비밀번호 초기화', + 'ADMIN', + async (accountId) => + adminAccounts.adminResetAdminPassword(await adminActor(), { + accountId: accountId.toString(), + newPassword: NEW_PASSWORD, + }), + ], + [ + '판매자 비밀번호 초기화', + 'SELLER', + async (accountId) => + adminSellers.adminResetSellerPassword(await adminActor(), { + accountId: accountId.toString(), + newPassword: NEW_PASSWORD, + }), + ], + ]; + + describe('로그인 — 비밀번호 검증과 발급 사이에 변경이 커밋된다', () => { + it.each(CHANGES)( + '%s: 그 로그인의 액세스 토큰은 막히고 세션은 refresh에서 폐기된다', + async (_, role, change) => { + const credential = await makeCredential(role); + const updateLastLogin = credentials.updateLastLogin.bind(credentials); + jest + .spyOn(credentials, 'updateLastLogin') + .mockImplementationOnce(async (accountId, now) => { + await change(accountId); + return updateLastLogin(accountId, now); + }); + + const issued = await login(role, credential.username); + + // 변경의 전 세션 폐기를 비껴간 세션이 생겼다 — 경쟁이 재현됐다 + expect((await sessionOf(issued.refreshToken)).revoked_at).toBeNull(); + await expectAccessToken(issued.accessToken, true); + await expect(refresh(role, issued.refreshToken)).rejects.toThrowDomain( + 'INVALID_REFRESH_TOKEN', + ); + expect( + (await sessionOf(issued.refreshToken)).revoked_at, + ).not.toBeNull(); + }, + ); + }); + + describe('refresh 회전 — 세션 확인과 회전 사이에 변경이 커밋된다', () => { + it.each(CHANGES)( + '%s: 회전으로 받은 액세스 토큰은 막히고 새 세션은 다음 refresh에서 폐기된다', + async (_, role, change) => { + const credential = await makeCredential(role); + const first = await login(role, credential.username); + const rotate = + refreshSessions.rotateRefreshSession.bind(refreshSessions); + jest + .spyOn(refreshSessions, 'rotateRefreshSession') + .mockImplementationOnce(async (args) => { + await change(credential.account_id); + return rotate(args); + }); + + const rotated = await refresh(role, first.refreshToken); + + expect((await sessionOf(rotated.refreshToken)).revoked_at).toBeNull(); + await expectAccessToken(rotated.accessToken, true); + await expect(refresh(role, rotated.refreshToken)).rejects.toThrowDomain( + 'INVALID_REFRESH_TOKEN', + ); + expect( + (await sessionOf(rotated.refreshToken)).revoked_at, + ).not.toBeNull(); + }, + ); + }); + + describe('변경과 겹치지 않으면 그대로', () => { + it('변경 이력이 있는 계정도 로그인·연속 회전이 통과하고 세션이 같은 버전을 이어받는다', async () => { + const credential = await makeCredential('SELLER'); + const changedAt = new Date('2026-10-01T00:00:00.123Z'); + await prisma.accountCredential.update({ + where: { account_id: credential.account_id }, + data: { password_updated_at: changedAt }, + }); + await blacklist.blockCredentials(credential.account_id, changedAt); + + const first = await login('SELLER', credential.username); + const second = await refresh('SELLER', first.refreshToken); + const third = await refresh('SELLER', second.refreshToken); + + expect((await sessionOf(third.refreshToken)).credential_version).toEqual( + changedAt, + ); + for (const { accessToken } of [first, second, third]) { + await expectAccessToken(accessToken, false); + } + }); + + it.each(CHANGES)( + '%s 뒤 새 비밀번호로 로그인한 세션·토큰은 통과한다', + async (_, role, change) => { + const credential = await makeCredential(role); + await change(credential.account_id); + + const { res, set } = jar(); + const { accessToken } = await credentialAuth.login({ + role, + username: credential.username, + password: NEW_PASSWORD, + req: reqWith(), + res, + }); + const rotated = await refresh(role, set[REFRESH_COOKIE[role]]); + + await expectAccessToken(accessToken, false); + await expectAccessToken(rotated.accessToken, false); + }, + ); + + it('구매자(OIDC) 세션은 자격증명이 없어(버전 null) 회전이 이어지고 토큰 cv는 0이다', async () => { + const buyer = await createAccount(prisma, { account_type: 'USER' }); + const { res, set } = jar(); + // OIDC 콜백이 부르는 발급과 같은 인자 + await tokens.issueAuthTokens({ + accountId: buyer.id, + credentialVersion: null, + req: reqWith(), + res, + }); + + let refreshToken = set[REFRESH_COOKIE.USER]; + for (let i = 0; i < 2; i++) { + const next = jar(); + const { accessToken } = await auth.refresh( + reqWith({ [REFRESH_COOKIE.USER]: refreshToken }), + next.res, + ); + refreshToken = next.set[REFRESH_COOKIE.USER]; + expect(jwt.verify(accessToken).cv).toBe(0); + await expectAccessToken(accessToken, false); + } + expect((await sessionOf(refreshToken)).credential_version).toBeNull(); + }); + }); + + // 마이그레이션은 버전을 백필하지 않는다 — 기존 세션은 null로 남는다 + describe('버전 기록 전에 발급된 세션', () => { + it.each([ + ['비밀번호를 바꾼 적 없는 계정은 이어진다', null, false], + [ + '비밀번호를 바꾼 적 있는 계정은 폐기·거절된다', + new Date('2026-10-01T00:00:00.123Z'), + true, + ], + ])('%s', async (_, changedAt, rejected) => { + const credential = await makeCredential('SELLER'); + const issued = await login('SELLER', credential.username); + await prisma.authRefreshSession.updateMany({ + data: { credential_version: null }, + }); + await prisma.accountCredential.update({ + where: { account_id: credential.account_id }, + data: { password_updated_at: changedAt }, + }); + + const result = refresh('SELLER', issued.refreshToken); + + if (rejected) { + await expect(result).rejects.toThrowDomain('INVALID_REFRESH_TOKEN'); + expect( + (await sessionOf(issued.refreshToken)).revoked_at, + ).not.toBeNull(); + } else { + await expect(result).resolves.toMatchObject({ + accessToken: expect.any(String), + }); + } + }); + }); +}); diff --git a/src/features/auth/services/oidc-login.service.spec.ts b/src/features/auth/services/oidc-login.service.spec.ts index 35f99ef1..60e09041 100644 --- a/src/features/auth/services/oidc-login.service.spec.ts +++ b/src/features/auth/services/oidc-login.service.spec.ts @@ -254,6 +254,10 @@ describe('OidcLoginService', () => { providerDisplayName: 'Test User', providerProfileImageUrl: 'https://example.com/photo.jpg', }); + // 구매자는 자격증명이 없다 — 세션 버전 null + expect(mockRefreshSessions.createRefreshSession).toHaveBeenCalledWith( + expect.objectContaining({ credentialVersion: null }), + ); expect(mockRes.cookie).toHaveBeenCalledWith( REFRESH_COOKIE.USER, expect.any(String), diff --git a/src/features/auth/services/oidc-login.service.ts b/src/features/auth/services/oidc-login.service.ts index 7632bff9..53bd0ec5 100644 --- a/src/features/auth/services/oidc-login.service.ts +++ b/src/features/auth/services/oidc-login.service.ts @@ -78,8 +78,10 @@ export class OidcLoginService { const account = await this.upsertAccountFromOidc(provider, userInfo); + // OIDC 계정은 구매자라 자격증명(비밀번호)이 없다 const { accessToken } = await this.tokens.issueAuthTokens({ accountId: account.id, + credentialVersion: null, req, res, }); diff --git a/src/features/auth/services/refresh-cookie.service.spec.ts b/src/features/auth/services/refresh-cookie.service.spec.ts index 30e86a50..9953e9f1 100644 --- a/src/features/auth/services/refresh-cookie.service.spec.ts +++ b/src/features/auth/services/refresh-cookie.service.spec.ts @@ -109,7 +109,13 @@ describe('역할별 refresh 쿠키 (real DB)', () => { : (await createAccountCredential(prisma, { account_type: role })) .account_id; const { res, set } = jar(); - await tokens.issueAuthTokens({ accountId, req: reqWith({}), res }); + // 팩토리 자격증명은 변경 이력이 없다(버전 null) + await tokens.issueAuthTokens({ + accountId, + credentialVersion: null, + req: reqWith({}), + res, + }); const raw = Object.values(set)[0]; const session = await prisma.authRefreshSession.findFirstOrThrow({ where: { token_hash: sha256Hex(raw) }, diff --git a/src/features/auth/services/token.service.spec.ts b/src/features/auth/services/token.service.spec.ts index b0b3c8b7..b0735807 100644 --- a/src/features/auth/services/token.service.spec.ts +++ b/src/features/auth/services/token.service.spec.ts @@ -77,14 +77,17 @@ describe('TokenService', () => { }); describe('signAccessToken', () => { - it('신원 클레임(sub·typ·role·mustChangePassword)만 서명한다 — 시간·발급자는 서명 옵션 몫', () => { - const result = service.signAccessToken({ - id: BigInt(42), - status: 'ACTIVE', - account_type: 'USER', - credential: null, - store: null, - }); + it('신원 클레임(sub·typ·role·mustChangePassword·cv)만 서명한다 — 시간·발급자는 서명 옵션 몫', () => { + const result = service.signAccessToken( + { + id: BigInt(42), + status: 'ACTIVE', + account_type: 'USER', + credential: null, + store: null, + }, + null, + ); expect(result).toBe('signed-token'); expect(jwt.sign).toHaveBeenCalledTimes(1); @@ -93,17 +96,21 @@ describe('TokenService', () => { typ: 'access', role: 'USER', mustChangePassword: false, + cv: 0, }); }); it('판매자는 storeId를, 비밀번호 변경 대상은 플래그를 클레임에 담는다', () => { - service.signAccessToken({ - id: BigInt(7), - status: 'ACTIVE', - account_type: 'SELLER', - credential: { must_change_password: true, password_updated_at: null }, - store: { id: BigInt(3) }, - }); + service.signAccessToken( + { + id: BigInt(7), + status: 'ACTIVE', + account_type: 'SELLER', + credential: { must_change_password: true, password_updated_at: null }, + store: { id: BigInt(3) }, + }, + null, + ); expect(jwt.sign).toHaveBeenCalledWith({ sub: '7', @@ -111,8 +118,31 @@ describe('TokenService', () => { role: 'SELLER', mustChangePassword: true, storeId: '3', + cv: 0, }); }); + + it('cv는 넘겨받은 버전(ms)이다 — 계정 행의 password_updated_at을 다시 쓰지 않는다', () => { + const verified = new Date('2026-10-04T00:00:00.123Z'); + + service.signAccessToken( + { + id: BigInt(7), + status: 'ACTIVE', + account_type: 'SELLER', + credential: { + must_change_password: false, + password_updated_at: new Date('2026-10-04T00:00:01.000Z'), + }, + store: null, + }, + verified, + ); + + expect(jwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ cv: verified.getTime() }), + ); + }); }); describe('getAccessExpiresSeconds', () => { @@ -150,6 +180,7 @@ describe('TokenService', () => { const result = await service.issueAuthTokens({ accountId: BigInt(1), + credentialVersion: null, req: mockReq, res: mockRes, }); @@ -160,6 +191,7 @@ describe('TokenService', () => { accountId: BigInt(1), userAgent: 'Mozilla/5.0 TokenSpec', ipAddress: '127.0.0.1', + credentialVersion: null, }), ); expect(mockRes.cookie).toHaveBeenCalledTimes(1); @@ -206,6 +238,7 @@ describe('TokenService', () => { refreshSessions.findActiveRefreshSessionByHash.mockResolvedValue({ id: BigInt(7), account_id: BigInt(10), + credential_version: null, } as never); refreshSessions.rotateRefreshSession.mockResolvedValue({} as never); @@ -222,6 +255,7 @@ describe('TokenService', () => { expect.objectContaining({ currentSessionId: BigInt(7), accountId: BigInt(10), + credentialVersion: null, }), ); expect(mockRes.cookie).toHaveBeenCalledTimes(1); diff --git a/src/features/auth/services/token.service.ts b/src/features/auth/services/token.service.ts index aa6a0a50..cdf70cc6 100644 --- a/src/features/auth/services/token.service.ts +++ b/src/features/auth/services/token.service.ts @@ -21,6 +21,7 @@ import { REFRESH_SESSION_REPOSITORY, type IRefreshSessionRepository, } from '@/features/auth/repositories/refresh-session.repository.interface'; +import type { AuthRefreshSession } from '@/generated/prisma/client'; import { REFRESH_COOKIE } from '@/global/auth/constants/auth-cookie.constants'; import type { AccessTokenClaims, @@ -38,24 +39,26 @@ export class TokenService { private readonly accounts: IAccountRepository, ) {} - /** iat·exp·iss·aud·kid는 서명 옵션(JwtModule)이 붙인다 — 여기서는 신원 클레임만 만든다. */ - signAccessToken(account: AccountForJwt): string { + /** + * iat·exp·iss·aud·kid는 서명 옵션(JwtModule)이 붙인다 — 여기서는 신원 클레임만 만든다. + * credentialVersion(cv)은 계정 행에서 다시 꺼내지 않는다 — 자격을 확인한 시점의 값이어야 그 뒤 커밋된 변경이 이 토큰을 막는다. + */ + signAccessToken( + account: AccountForJwt, + credentialVersion: Date | null, + ): string { const claims: AccessTokenClaims = { sub: account.id.toString(), typ: 'access', role: account.account_type, mustChangePassword: account.credential?.must_change_password ?? false, ...(account.store ? { storeId: account.store.id.toString() } : {}), + cv: versionMs(credentialVersion), }; return this.jwt.sign(claims); } - /** 발급 시점의 계정 상태를 클레임에 담기 위해 매번 조회한다(재발급 포함). */ - async signAccessTokenFor(accountId: bigint): Promise { - return this.signAccessToken(await this.requireActiveAccount(accountId)); - } - private async requireActiveAccount( accountId: bigint, ): Promise { @@ -75,13 +78,15 @@ export class TokenService { return this.config.getOrThrow('auth'); } + /** credentialVersion = 호출자가 자격을 확인한 시점의 password_updated_at(로그인은 비밀번호 검증 때 읽은 값). 나머지 클레임은 다시 읽는다. */ async issueAuthTokens(args: { accountId: bigint; + credentialVersion: Date | null; req: Request; res: Response; }): Promise<{ accessToken: string }> { const account = await this.requireActiveAccount(args.accountId); - const accessToken = this.signAccessToken(account); + const accessToken = this.signAccessToken(account, args.credentialVersion); const refreshToken = this.generateRefreshToken(); const refreshHash = this.sha256Hex(refreshToken); @@ -95,6 +100,7 @@ export class TokenService { userAgent: tryUserAgent(args.req), ipAddress: tryClientIp(args.req), expiresAt, + credentialVersion: args.credentialVersion, }); AuthCookie.setRefreshCookie(args.res, account.account_type, { @@ -119,8 +125,25 @@ export class TokenService { return !account || account.account_type === role; } - async assertSessionRole(role: AccountRole, accountId: bigint): Promise { - if (!(await this.hasSessionRole(role, accountId))) { + /** + * 회전 전 확인. 역할이 다르면 세션을 건드리지 않고 거절한다. 자격증명 버전이 다르면 발급 뒤 비밀번호가 바뀐 것이다 — + * 변경 트랜잭션의 전 세션 폐기를 비껴간 세션(확인 뒤 커밋된 변경과 겹친 로그인·회전)이라 폐기하고 거절한다. + * 계정이 없으면(탈퇴) 회전의 상태 확인이 거절한다. + */ + private async assertSessionUsable( + role: AccountRole, + session: AuthRefreshSession, + ): Promise { + const account = await this.accounts.findAccountForJwt(session.account_id); + if (!account) return; + if (account.account_type !== role) { + throw new DomainException('INVALID_REFRESH_TOKEN'); + } + if ( + versionMs(session.credential_version) !== + versionMs(account.credential?.password_updated_at) + ) { + await this.refreshSessions.revokeRefreshSession(session.id); throw new DomainException('INVALID_REFRESH_TOKEN'); } } @@ -141,7 +164,7 @@ export class TokenService { await this.refreshSessions.findActiveRefreshSessionByHash(tokenHash); if (!session) throw new DomainException('INVALID_REFRESH_TOKEN'); - await this.assertSessionRole(role, session.account_id); + await this.assertSessionUsable(role, session); const newRefreshToken = this.generateRefreshToken(); const newTokenHash = this.sha256Hex(newRefreshToken); @@ -156,9 +179,14 @@ export class TokenService { userAgent: tryUserAgent(req), ipAddress: tryClientIp(req), newExpiresAt, + credentialVersion: session.credential_version, }); - const accessToken = await this.signAccessTokenFor(session.account_id); + // 버전은 확인한 세션의 것 — 확인 뒤 커밋된 변경이 있으면 이 토큰은 블랙리스트에, 새 세션은 다음 refresh에서 막힌다 + const accessToken = this.signAccessToken( + await this.requireActiveAccount(session.account_id), + session.credential_version, + ); AuthCookie.setRefreshCookie(res, role, { refreshToken: newRefreshToken, @@ -196,3 +224,8 @@ export class TokenService { return this.authConfig().refreshExpiresInDays; } } + +/** 자격증명 버전의 비교·클레임 값. 변경 이력이 없으면(null) 0. */ +function versionMs(version: Date | null | undefined): number { + return version?.getTime() ?? 0; +} diff --git a/src/features/auth/strategies/jwt-bearer.strategy.spec.ts b/src/features/auth/strategies/jwt-bearer.strategy.spec.ts index c83f0a95..57c1981c 100644 --- a/src/features/auth/strategies/jwt-bearer.strategy.spec.ts +++ b/src/features/auth/strategies/jwt-bearer.strategy.spec.ts @@ -1,6 +1,7 @@ import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; +import { DomainException } from '@/common/errors/error-catalog'; import { ClockService } from '@/common/providers/clock.service'; import { AccountRepository } from '@/features/auth/repositories/account.repository'; import { ACCOUNT_REPOSITORY } from '@/features/auth/repositories/account.repository.interface'; @@ -51,6 +52,33 @@ function buildStrategy(deps: { const clock = new ClockService(); +/** 통과면 '통과', 거절이면 도메인 코드 — 표 한 줄을 단언 하나로. */ +function verdict(result: Promise): Promise { + return result.then( + () => '통과', + (e: unknown) => (e instanceof DomainException ? e.code : String(e)), + ); +} + +/** 자격증명 변경 시각(초 안쪽 ms)과 토큰 표 — Redis 경로와 DB 폴백이 같은 판정을 내야 한다. */ +const CHANGED_AT_MS = NOW * 1000 + 500; +const CREDENTIAL_CASES = [ + [ + 'cv가 변경 1ms 전(같은 초)', + { iat: NOW, cv: CHANGED_AT_MS - 1 }, + 'INVALID_ACCESS_TOKEN', + ], + ['cv가 변경 시각', { iat: NOW, cv: CHANGED_AT_MS }, '통과'], + [ + 'cv 0(변경 이력 없이 발급) — 변경 뒤 초', + { iat: NOW + 1, cv: 0 }, + 'INVALID_ACCESS_TOKEN', + ], + ['cv 없는 옛 토큰 — 변경 전 초', { iat: NOW - 1 }, 'INVALID_ACCESS_TOKEN'], + ['cv 없는 옛 토큰 — 변경과 같은 초(1초 창)', { iat: NOW }, '통과'], + ['cv 없는 옛 토큰 — 변경 뒤 초', { iat: NOW + 1 }, '통과'], +] as const; + describe('JwtBearerStrategy (real DB + real Redis)', () => { let strategy: JwtBearerStrategy; let blacklist: TokenBlacklistService; @@ -164,20 +192,17 @@ describe('JwtBearerStrategy (real DB + real Redis)', () => { }); }); - it('자격증명 변경은 cutoff 전에 발급된 토큰만 막는다 — 같은 초·그 뒤에 받은 새 토큰은 통과', async () => { - await blacklist.blockCredentials(BigInt(42), new Date(NOW * 1000 + 999)); + // cv(발급 근거 버전, ms)로 같은 초 안의 변경 전·후 토큰을 가른다. cv 없는 옛 토큰만 iat(초) 비교 + it.each(CREDENTIAL_CASES)( + '자격증명 변경 뒤 %s %j → %s', + async (_, token, expected) => { + await blacklist.blockCredentials(BigInt(42), new Date(CHANGED_AT_MS)); - await expect( - strategy.validate(payload('42', { iat: NOW - 1 })), - ).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); - // 변경과 같은 초에 발급된 토큰 — iat가 초 단위라 ms 비교로 밀어내면 새 토큰까지 막는다 - await expect( - strategy.validate(payload('42', { iat: NOW })), - ).resolves.toMatchObject({ accountId: '42' }); - await expect( - strategy.validate(payload('42', { iat: NOW + 1 })), - ).resolves.toMatchObject({ accountId: '42' }); - }); + expect(await verdict(strategy.validate(payload('42', token)))).toBe( + expected, + ); + }, + ); it('반증: 정지 → 복구를 거쳐도 자격증명 cutoff는 살아 옛 토큰을 계속 막는다', async () => { await blacklist.blockCredentials(BigInt(42), AT); @@ -185,10 +210,10 @@ describe('JwtBearerStrategy (real DB + real Redis)', () => { await blacklist.clearStatus(BigInt(42), LATER); await expect( - strategy.validate(payload('42', { iat: NOW - 60 })), + strategy.validate(payload('42', { iat: NOW, cv: AT.getTime() - 1 })), ).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); await expect( - strategy.validate(payload('42', { iat: NOW })), + strategy.validate(payload('42', { iat: NOW, cv: AT.getTime() })), ).resolves.toMatchObject({ accountId: '42' }); }); @@ -281,23 +306,24 @@ describe('JwtBearerStrategy (real DB + real Redis)', () => { ); }); - it('반증: 비밀번호 변경 전에 발급된 토큰은 DB의 password_updated_at으로도 막힌다 — Redis 없이도 자격증명 변경이 새지 않는다', async () => { - const credential = await createAccountCredential(prisma, { - account_type: 'SELLER', - }); - await prisma.accountCredential.update({ - where: { account_id: credential.account_id }, - data: { password_updated_at: new Date(NOW * 1000) }, - }); - const sub = credential.account_id.toString(); + // Redis 경로와 같은 표 — Redis 없이도 자격증명 변경이 새지 않고, 판정도 같다 + it.each(CREDENTIAL_CASES)( + 'DB의 password_updated_at으로 판정한다: %s %j → %s', + async (_, token, expected) => { + const credential = await createAccountCredential(prisma, { + account_type: 'SELLER', + }); + await prisma.accountCredential.update({ + where: { account_id: credential.account_id }, + data: { password_updated_at: new Date(CHANGED_AT_MS) }, + }); + const sub = credential.account_id.toString(); - await expect( - fallbackStrategy.validate(payload(sub, { iat: NOW - 60 })), - ).rejects.toThrowDomain('INVALID_ACCESS_TOKEN'); - await expect( - fallbackStrategy.validate(payload(sub, { iat: NOW })), - ).resolves.toMatchObject({ accountId: sub }); - }); + expect( + await verdict(fallbackStrategy.validate(payload(sub, token))), + ).toBe(expected); + }, + ); // Redis 경로와 같은 입력 표 — 어느 경로를 타든 응답 코드가 같아야 한다 it.each([ diff --git a/src/features/auth/strategies/jwt-bearer.strategy.ts b/src/features/auth/strategies/jwt-bearer.strategy.ts index fad6bc01..6a506dc5 100644 --- a/src/features/auth/strategies/jwt-bearer.strategy.ts +++ b/src/features/auth/strategies/jwt-bearer.strategy.ts @@ -13,7 +13,7 @@ import { AlertService } from '@/global/alerting'; import type { AccessTokenPayload, JwtUser } from '@/global/auth'; import { type BlacklistLookup, - credentialCutoffSec, + issuedBeforeCredentialChange, TokenBlacklistService, } from '@/global/auth/blacklist'; @@ -67,7 +67,7 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { key: 'auth-blacklist-fallback', detail: error instanceof Error ? error.message : String(error), }); - return this.validateAgainstDb(accountId, payload.iat); + return this.validateAgainstDb(accountId, payload); } if (!lookup.ready) { // Redis가 비었다(초기화·flush). worker 재구축이 표식을 다시 세울 때까지 DB가 정본이다. @@ -77,7 +77,7 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { title: 'Redis 블랙리스트 미구축 — 계정 재조회로 폴백', key: 'auth-blacklist-not-ready', }); - return this.validateAgainstDb(accountId, payload.iat); + return this.validateAgainstDb(accountId, payload); } if (lookup.status !== null) { @@ -88,10 +88,10 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { : 'ACCOUNT_NOT_ACTIVE', ); } - // iat와 cutoff 둘 다 초 단위 — 변경 전에 발급된 토큰만 무효 + // 변경 전 버전으로 발급된 토큰만 무효 if ( - lookup.credentialCutoffSec !== null && - payload.iat < lookup.credentialCutoffSec + lookup.credentialCutoffMs !== null && + issuedBeforeCredentialChange(payload, lookup.credentialCutoffMs) ) { throw new DomainException('INVALID_ACCESS_TOKEN'); } @@ -106,7 +106,7 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { /** 폴백 경로 — 블랙리스트 도입 전 판정(존재·ACTIVE·must_change_password)에 자격증명 변경 시각 비교를 더한다. */ private async validateAgainstDb( accountId: bigint, - issuedAtSec: number, + payload: AccessTokenPayload, ): Promise { const account = await this.accounts.findAccountForJwt(accountId); @@ -119,7 +119,10 @@ export class JwtBearerStrategy extends PassportStrategy(Strategy, 'jwt') { } const changedAt = account.credential?.password_updated_at; - if (changedAt && issuedAtSec < credentialCutoffSec(changedAt)) { + if ( + changedAt && + issuedBeforeCredentialChange(payload, changedAt.getTime()) + ) { throw new DomainException('INVALID_ACCESS_TOKEN'); } diff --git a/src/global/auth/blacklist/index.ts b/src/global/auth/blacklist/index.ts index be554466..2851032f 100644 --- a/src/global/auth/blacklist/index.ts +++ b/src/global/auth/blacklist/index.ts @@ -1,7 +1,7 @@ export { BLACKLIST_READY_TTL_SECONDS, type BlacklistLookup, - credentialCutoffSec, + issuedBeforeCredentialChange, TokenBlacklistService, } from '@/global/auth/blacklist/token-blacklist.service'; export { BlacklistModule } from '@/global/auth/blacklist/blacklist.module'; diff --git a/src/global/auth/blacklist/token-blacklist.service.spec.ts b/src/global/auth/blacklist/token-blacklist.service.spec.ts index 3d2f49b1..801ca05e 100644 --- a/src/global/auth/blacklist/token-blacklist.service.spec.ts +++ b/src/global/auth/blacklist/token-blacklist.service.spec.ts @@ -7,7 +7,7 @@ import { AlertService } from '@/global/alerting'; import { BLACKLIST_READY_KEY, BLACKLIST_READY_TTL_SECONDS, - credentialCutoffSec, + issuedBeforeCredentialChange, STATUS_SCAN_PAGE, TokenBlacklistService, } from '@/global/auth/blacklist/token-blacklist.service'; @@ -75,10 +75,6 @@ describe('TokenBlacklistService (real Redis)', () => { alerts.notify.mockClear(); }); - it('credentialCutoffSec는 초 단위 내림 — iat와 같은 정밀도로 비교하기 위해', () => { - expect(credentialCutoffSec(T1)).toBe(1_790_337_600); - }); - // 쓰기는 전부 "버전(변경 시각)이 더 새로울 때만" — 훅·재구축·조정이 어떤 순서로 겹쳐도 최근 변경이 이긴다 describe('상태 키(정지·탈퇴·복구)', () => { it('blockStatus하면 사유가 조회되고 더 새 버전의 clearStatus로 풀린다', async () => { @@ -89,7 +85,7 @@ describe('TokenBlacklistService (real Redis)', () => { await expect(service.lookup(BigInt(7))).resolves.toEqual({ ready: true, status: 'SUSPENDED', - credentialCutoffSec: null, + credentialCutoffMs: null, }); await expect(service.lookup(BigInt(8))).resolves.toMatchObject({ status: null, @@ -137,7 +133,7 @@ describe('TokenBlacklistService (real Redis)', () => { await expect(service.lookup(BigInt(7))).resolves.toEqual({ ready: true, status: null, - credentialCutoffSec: credentialCutoffSec(T1), + credentialCutoffMs: T1.getTime(), }); }); @@ -163,13 +159,14 @@ describe('TokenBlacklistService (real Redis)', () => { }); describe('자격증명 cutoff', () => { - it('변경 시각을 초 단위 cutoff로 저장한다', async () => { + it('변경 시각을 ms 그대로 cutoff로 저장한다', async () => { await service.blockCredentials(BigInt(7), T1); await expect(service.lookup(BigInt(7))).resolves.toEqual({ ready: true, status: null, - credentialCutoffSec: credentialCutoffSec(T1), + credentialCutoffMs: T1.getTime(), }); + expect(await redis.get('auth:blk:cv:7')).toBe(String(T1.getTime())); }); it('반증: cutoff는 낮아지지 않는다 — 재구축의 옛 스냅샷이 최신 변경을 덮지 않게', async () => { @@ -177,14 +174,53 @@ describe('TokenBlacklistService (real Redis)', () => { await service.blockCredentials(BigInt(7), T1); await expect(service.lookup(BigInt(7))).resolves.toMatchObject({ - credentialCutoffSec: credentialCutoffSec(T2), + credentialCutoffMs: T2.getTime(), }); await service.blockCredentials(BigInt(7), T3); await expect(service.lookup(BigInt(7))).resolves.toMatchObject({ - credentialCutoffSec: credentialCutoffSec(T3), + credentialCutoffMs: T3.getTime(), }); }); + + it('같은 초 안의 더 늦은 변경도 cutoff를 올린다(ms 버전)', async () => { + await service.blockCredentials(BigInt(7), T1); + await service.blockCredentials(BigInt(7), new Date(T1.getTime() + 1)); + + await expect(service.lookup(BigInt(7))).resolves.toMatchObject({ + credentialCutoffMs: T1.getTime() + 1, + }); + }); + + // 배포 직후 Redis에는 옛 코드가 쓴 초 단위 키·표식이 남아 있다 — ms로 잘못 읽으면 모든 토큰이 통과한다 + it('반증: 옛 초 단위 키(auth:blk:cr:)와 옛 표식(auth:blk:ready)은 읽지 않는다 — 재구축 전까지 DB 폴백', async () => { + await redis.flushdb(); + await redis.set('auth:blk:ready', '1'); + await redis.set('auth:blk:cr:7', String(Math.floor(T1.getTime() / 1000))); + + await expect(service.lookup(BigInt(7))).resolves.toEqual({ + ready: false, + status: null, + credentialCutoffMs: null, + }); + }); + }); + + describe('issuedBeforeCredentialChange', () => { + const X = T1.getTime(); // 초 안쪽(.500)의 변경 시각 + const SEC = Math.floor(X / 1000); + + it.each([ + ['cv가 변경 1ms 전(같은 초)', { iat: SEC, cv: X - 1 }, true], + ['cv가 변경 시각', { iat: SEC, cv: X }, false], + ['cv가 변경 뒤', { iat: SEC, cv: X + 1 }, false], + ['cv 0(변경 이력 없이 발급)', { iat: SEC + 1, cv: 0 }, true], + ['cv 없는 옛 토큰 — 변경 전 초', { iat: SEC - 1 }, true], + ['cv 없는 옛 토큰 — 변경과 같은 초(1초 창)', { iat: SEC }, false], + ['cv 없는 옛 토큰 — 변경 뒤 초', { iat: SEC + 1 }, false], + ] as const)('%s %j → 막힘 %s', (_, token, blocked) => { + expect(issuedBeforeCredentialChange(token, X)).toBe(blocked); + }); }); // 표식은 임대다 — 재구축이 주기마다 갱신하고, 스냅샷 뒤 쓰기 실패가 끼어들었으면(세대 변화) 세우지 않는다 @@ -275,7 +311,7 @@ describe('TokenBlacklistService (real Redis)', () => { it('두 키 다 액세스 토큰 수명만큼 산다 — 그 뒤엔 토큰 자체가 만료라 볼 필요가 없다', async () => { await service.blockStatus(BigInt(7), 'DELETED', T1); await service.blockCredentials(BigInt(7), T1); - for (const key of ['auth:blk:st:7', 'auth:blk:cr:7']) { + for (const key of ['auth:blk:st:7', 'auth:blk:cv:7']) { const ttl = await redis.ttl(key); expect(ttl).toBeGreaterThan(TTL - 5); expect(ttl).toBeLessThanOrEqual(TTL); @@ -373,7 +409,7 @@ describe('TokenBlacklistService (real Redis)', () => { await expect(broken.lookup(BigInt(1))).resolves.toEqual({ ready: false, status: null, - credentialCutoffSec: null, + credentialCutoffMs: null, }); expect(mget).not.toHaveBeenCalled(); diff --git a/src/global/auth/blacklist/token-blacklist.service.ts b/src/global/auth/blacklist/token-blacklist.service.ts index 1add188a..2888c972 100644 --- a/src/global/auth/blacklist/token-blacklist.service.ts +++ b/src/global/auth/blacklist/token-blacklist.service.ts @@ -16,26 +16,38 @@ export interface BlacklistLookup { /** 재구축 표식이 있는가. 없으면(Redis 초기화·flush·쓰기 실패 직후) 목록이 불완전하므로 호출자는 DB로 폴백해야 한다. */ ready: boolean; status: StatusBlockReason | null; - /** 이 시각(초, JWT iat와 같은 정밀도) 전에 발급된 토큰은 무효. 없으면 null. */ - credentialCutoffSec: number | null; + /** 마지막 자격증명 변경 시각(ms). 그보다 옛 버전으로 발급된 토큰은 무효(issuedBeforeCredentialChange). 없으면 null. */ + credentialCutoffMs: number | null; } /** 두 축을 키로 나눈다 — 정지 등록·복구가 자격증명 cutoff를 덮거나 지우면 안 된다. */ const STATUS_PREFIX = 'auth:blk:st:'; -const CREDENTIAL_PREFIX = 'auth:blk:cr:'; +/** 값은 ms — 초 단위였던 옛 키(`auth:blk:cr:`)와 이름을 달리해 배포 직후 옛 값을 ms로 읽지 않는다. */ +const CREDENTIAL_PREFIX = 'auth:blk:cv:'; export const STATUS_KEY_PATTERN = `${STATUS_PREFIX}*`; /** SCAN 한 페이지 크기(힌트). spec이 이보다 많은 키로 커서 순회를 고정한다. */ export const STATUS_SCAN_PAGE = 100; -/** 목록이 완전하다는 표식. worker의 재구축이 임대처럼 갱신한다 — worker가 죽거나 Redis가 비면 만료돼 전략이 DB로 폴백한다. */ -export const BLACKLIST_READY_KEY = 'auth:blk:ready'; +/** + * 목록이 완전하다는 표식. worker의 재구축이 임대처럼 갱신한다 — worker가 죽거나 Redis가 비면 만료돼 전략이 DB로 폴백한다. + * 자격증명 키와 함께 이름이 바뀌었다 — 새 키가 재구축으로 채워지기 전에는 옛 표식이 남아 있어도 DB로 폴백한다. + */ +export const BLACKLIST_READY_KEY = 'auth:blk:ready:v2'; /** 표식 임대 시간. 재구축 주기(60초)의 3배 — 재구축을 연속으로 놓쳐야 폴백으로 돌아간다(주기와의 관계는 재구축 spec이 고정). */ export const BLACKLIST_READY_TTL_SECONDS = 180; /** 쓰기 실패 세대. 실패마다 올라가고, 재구축은 스냅샷 시점의 세대가 그대로일 때만 표식을 세운다(스냅샷 뒤 실패한 훅과의 경쟁). */ const DIRTY_KEY = 'auth:blk:dirty'; -/** JWT iat는 초 단위다. 내림 + `iat < cutoff` 비교라 변경과 같은 초에 새로 받은 토큰은 통과한다(같은 초의 옛 토큰도 — 1초 창). */ -export function credentialCutoffSec(changedAt: Date): number { - return Math.floor(changedAt.getTime() / 1000); +/** + * 토큰이 자격증명 변경(cutoffMs) 전 버전으로 발급됐는가. cv(발급 근거가 된 password_updated_at, ms)로 비교해 + * 변경과 같은 초에 발급된 옛 토큰도 가른다. cv가 없는 토큰(도입 전 발급분)은 iat(초)로 — 같은 초의 옛 토큰은 통과한다. + */ +export function issuedBeforeCredentialChange( + token: { iat: number; cv?: number }, + cutoffMs: number, +): boolean { + return token.cv !== undefined + ? token.cv < cutoffMs + : token.iat < Math.floor(cutoffMs / 1000); } /** @@ -92,9 +104,9 @@ export class TokenBlacklistService { return this.writeStatus(accountId, 'ACTIVE', changedAt); } - /** 비밀번호 변경·초기화. cutoff 전에 발급된 토큰만 막는다 — 새 비밀번호로 받은 새 토큰은 통과. false = 쓰기 실패. */ + /** 비밀번호 변경·초기화. 변경 전 버전으로 발급된 토큰만 막는다 — 새 비밀번호로 받은 새 토큰은 통과. false = 쓰기 실패. */ async blockCredentials(accountId: bigint, changedAt: Date): Promise { - const cutoff = credentialCutoffSec(changedAt); + const cutoff = changedAt.getTime(); return this.write('credentials', (ttl) => this.redis.eval( SET_IF_NEWER, @@ -118,7 +130,7 @@ export class TokenBlacklistService { if (await this.clearReady()) { this.degradedUntilMs = 0; } else { - return { ready: false, status: null, credentialCutoffSec: null }; + return { ready: false, status: null, credentialCutoffMs: null }; } } const [ready, status, cutoff] = await this.redis.mget( @@ -129,7 +141,7 @@ export class TokenBlacklistService { return { ready: ready !== null, status: parseBlockedStatus(status), - credentialCutoffSec: cutoff === null ? null : Number(cutoff), + credentialCutoffMs: cutoff === null ? null : Number(cutoff), }; } diff --git a/src/global/auth/types/jwt-payload.type.ts b/src/global/auth/types/jwt-payload.type.ts index dcab2258..363efa43 100644 --- a/src/global/auth/types/jwt-payload.type.ts +++ b/src/global/auth/types/jwt-payload.type.ts @@ -11,6 +11,9 @@ export interface AccessTokenClaims { /** 판매자만. 매장이 아직 없으면 없다. */ storeId?: string; + + /** 발급 근거가 된 자격증명 버전(password_updated_at, epoch ms, 없으면 0). 이 클레임 도입 전에 발급된 토큰에는 없다. */ + cv?: number; } /** 검증 후 전략이 받는 payload — 서명 옵션이 채운 시간 클레임이 더해진다. */ diff --git a/src/test/redis.ts b/src/test/redis.ts index 093025a9..94997f5e 100644 --- a/src/test/redis.ts +++ b/src/test/redis.ts @@ -18,7 +18,7 @@ export const NOOP_BLACKLIST_PROVIDER: Provider = { blockCredentials: () => Promise.resolve(true), blockedStatusAccountIds: () => Promise.resolve([]), lookup: () => - Promise.resolve({ ready: true, status: null, credentialCutoffSec: null }), + Promise.resolve({ ready: true, status: null, credentialCutoffMs: null }), generation: () => Promise.resolve('0'), markReady: () => Promise.resolve(true), evictionRisk: () => Promise.resolve(null),