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
1 change: 1 addition & 0 deletions docs/guide/architecture-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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로 다시 봄"이라 보안 후퇴가 없다. 대신 경보가 간다.

### 경보
Expand Down
Original file line number Diff line number Diff line change
@@ -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끼리 일치해 이어진다.
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/features/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ describe('AuthService', () => {
typ: 'access',
role: 'USER',
mustChangePassword: false,
cv: 0,
});
});

Expand Down
5 changes: 4 additions & 1 deletion src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthRefreshSession>;

findActiveRefreshSessionByHash(
Expand All @@ -24,6 +25,7 @@ export interface IRefreshSessionRepository {
userAgent?: string;
ipAddress?: string;
newExpiresAt: Date;
credentialVersion: Date | null;
}): Promise<AuthRefreshSession>;

revokeRefreshSession(sessionId: bigint): Promise<AuthRefreshSession>;
Expand Down
110 changes: 110 additions & 0 deletions src/features/auth/repositories/refresh-session.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand All @@ -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;
});

Expand All @@ -32,6 +41,10 @@ describe('RefreshSessionRepository (real DB)', () => {
await truncateAll();
});

afterEach(() => {
jest.restoreAllMocks();
});

describe('createRefreshSession', () => {
it('refresh session을 생성한다', async () => {
const account = await createAccount(prisma);
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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));
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -235,5 +286,64 @@ describe('RefreshSessionRepository (real DB)', () => {
});
expect(same.revoked_at).toBeNull();
});

/** 이 워커 DB에서 행 잠금을 기다리는 트랜잭션이 생길 때까지. */
async function waitForLockWait(): Promise<void> {
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<unknown> | 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);
});
});
});
4 changes: 4 additions & 0 deletions src/features/auth/repositories/refresh-session.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository {
userAgent?: string;
ipAddress?: string;
expiresAt: Date;
credentialVersion: Date | null;
}): Promise<AuthRefreshSession> {
return this.prisma.$transaction(async (tx) => {
await this.assertAccountActiveForUpdate(tx, args.accountId);
Expand All @@ -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,
},
});
});
Expand Down Expand Up @@ -72,6 +74,7 @@ export class RefreshSessionRepository implements IRefreshSessionRepository {
userAgent?: string;
ipAddress?: string;
newExpiresAt: Date;
credentialVersion: Date | null;
}): Promise<AuthRefreshSession> {
return this.prisma.$transaction(async (tx) => {
const now = this.clock.now();
Expand All @@ -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,
},
});

Expand Down
9 changes: 4 additions & 5 deletions src/features/auth/services/blacklist-rebuild.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -137,19 +136,19 @@ 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(
`DELETED:${IN_WINDOW.getTime() + 1}`,
);
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,
});
});

Expand Down Expand Up @@ -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,
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/features/auth/services/credential-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Loading
Loading