Merge feat/goo-175-phase3-ws3b-bull-board into master
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 7s
Deploy / Build API Image (push) Failing after 17s
Deploy / Build Web Image (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m16s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 10s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Failing after 10m47s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Deploy / Rollback Staging (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 40s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 42s
Security Scanning / Trivy Filesystem Scan (push) Failing after 34s
Security Scanning / Security Gate (push) Failing after 3s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled

6 commits covering:
- BullMQ Redis split + Prometheus queue metrics + Bull Board admin UI
  (RFC-004 Phase 3 WS1 / WS3a / WS3b)
- Dual-key JWT verification for WebSocket auth
- Test infrastructure stubs + AVM spec fix (GOO-131)
- Complete MFA grace period feature for required roles + SLO monitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-29 12:19:17 +07:00
36 changed files with 1430 additions and 275 deletions

View File

@@ -5,6 +5,8 @@ describe('LoginUserHandler', () => {
let handler: LoginUserHandler;
let mockTokenService: { generateTokenPair: ReturnType<typeof vi.fn> };
let mockChallengeRepo: { create: ReturnType<typeof vi.fn> };
let mockUserRepo: { updateMfaGraceStartedAt: ReturnType<typeof vi.fn> };
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
const tokenPair = {
accessToken: 'access-jwt',
@@ -15,22 +17,30 @@ describe('LoginUserHandler', () => {
beforeEach(() => {
mockTokenService = { generateTokenPair: vi.fn().mockResolvedValue(tokenPair) };
mockChallengeRepo = { create: vi.fn().mockResolvedValue({}) };
handler = new LoginUserHandler(mockTokenService as any, mockChallengeRepo as any);
mockUserRepo = { updateMfaGraceStartedAt: vi.fn().mockResolvedValue(undefined) };
mockLogger = { error: vi.fn(), warn: vi.fn() };
handler = new LoginUserHandler(
mockTokenService as any,
mockChallengeRepo as any,
mockUserRepo as any,
mockLogger as any,
);
});
it('generates token pair with correct payload when MFA not required', async () => {
it('generates token pair with mfa=none for non-required role when MFA not required', async () => {
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', false);
const result = await handler.execute(command);
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair });
expect(result).toEqual({ requiresMfa: false, tokens: tokenPair, mfaGraceRemainingDays: undefined });
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'user-1',
phone: '0912345678',
role: 'BUYER',
mfa: 'none',
});
});
it('creates MFA challenge when MFA is required', async () => {
it('creates MFA challenge when MFA is required (user already enrolled)', async () => {
const command = new LoginUserCommand('user-1', '0912345678', 'BUYER', true);
const result = await handler.execute(command);
@@ -49,7 +59,7 @@ describe('LoginUserHandler', () => {
);
});
it('passes AGENT role correctly', async () => {
it('AGENT role does not require MFA — issues mfa=none claim', async () => {
const command = new LoginUserCommand('user-2', '0987654321', 'AGENT');
await handler.execute(command);
@@ -57,17 +67,51 @@ describe('LoginUserHandler', () => {
sub: 'user-2',
phone: '0987654321',
role: 'AGENT',
mfa: 'none',
});
});
it('passes ADMIN role correctly', async () => {
const command = new LoginUserCommand('admin-1', '0901234567', 'ADMIN');
await handler.execute(command);
it('ADMIN without TOTP enters grace period on first login under enforcement', async () => {
const command = new LoginUserCommand(
'admin-1',
'0901234567',
'ADMIN',
false,
false, // totpEnabled
null, // mfaGraceStartedAt — first login
);
const result = await handler.execute(command);
// Grace was started lazily
expect(mockUserRepo.updateMfaGraceStartedAt).toHaveBeenCalledWith('admin-1', expect.any(Date));
expect(result.mfaGraceRemainingDays).toBe(14);
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'admin-1',
phone: '0901234567',
role: 'ADMIN',
mfa: 'grace',
});
});
it('ADMIN past grace window receives mfa=enrollment_required claim', async () => {
const longAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
const command = new LoginUserCommand(
'admin-1',
'0901234567',
'ADMIN',
false,
false,
longAgo,
);
const result = await handler.execute(command);
expect(mockUserRepo.updateMfaGraceStartedAt).not.toHaveBeenCalled();
expect(result.mfaGraceRemainingDays).toBe(0);
expect(mockTokenService.generateTokenPair).toHaveBeenCalledWith({
sub: 'admin-1',
phone: '0901234567',
role: 'ADMIN',
mfa: 'enrollment_required',
});
});
});

View File

@@ -4,5 +4,7 @@ export class LoginUserCommand {
public readonly phone: string,
public readonly role: string,
public readonly isMfaRequired: boolean = false,
public readonly totpEnabled: boolean = false,
public readonly mfaGraceStartedAt: Date | null = null,
) {}
}

View File

@@ -1,12 +1,18 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { type UserRole } from '@prisma/client';
import { createId } from '@paralleldrive/cuid2';
import { LoggerService, DomainException } from '@modules/shared';
import { MFA_GRACE_PERIOD_DAYS, MFA_REQUIRED_ROLES } from '../../../domain/mfa-policy';
import {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import {
USER_REPOSITORY,
type IUserRepository,
} from '../../../domain/repositories/user.repository';
import { TokenService, type MfaClaim, type TokenPair } from '../../../infrastructure/services/token.service';
import { LoginUserCommand } from './login-user.command';
const MFA_CHALLENGE_TTL_MINUTES = 5;
@@ -15,6 +21,7 @@ export interface LoginResult {
requiresMfa: boolean;
challengeId?: string;
tokens?: TokenPair;
mfaGraceRemainingDays?: number;
}
@CommandHandler(LoginUserCommand)
@@ -23,12 +30,14 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
private readonly tokenService: TokenService,
@Inject(MFA_CHALLENGE_REPOSITORY)
private readonly challengeRepo: IMfaChallengeRepository,
@Inject(USER_REPOSITORY)
private readonly userRepo: IUserRepository,
private readonly logger: LoggerService,
) {}
async execute(command: LoginUserCommand): Promise<LoginResult> {
try {
// If MFA is required, create a challenge instead of tokens
// If MFA is required (user already enrolled), create a challenge
if (command.isMfaRequired) {
const challengeId = createId();
const expiresAt = new Date();
@@ -50,16 +59,32 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
};
}
// No MFA — issue tokens directly
// Determine MFA claim for non-enrolled users
const roleRequiresMfa = MFA_REQUIRED_ROLES.includes(command.role as UserRole);
let mfaClaim: MfaClaim = 'none';
let mfaGraceRemainingDays: number | undefined;
if (roleRequiresMfa && !command.totpEnabled) {
const result = await this.resolveMfaGraceClaim(
command.userId,
command.mfaGraceStartedAt,
);
mfaClaim = result.claim;
mfaGraceRemainingDays = result.remainingDays;
}
const tokens = await this.tokenService.generateTokenPair({
sub: command.userId,
phone: command.phone,
role: command.role,
mfa: mfaClaim,
});
return {
requiresMfa: false,
tokens,
mfaGraceRemainingDays,
};
} catch (error) {
if (error instanceof DomainException) throw error;
@@ -71,5 +96,33 @@ export class LoginUserHandler implements ICommandHandler<LoginUserCommand> {
throw new InternalServerErrorException('Không thể tạo phiên đăng nhập, vui lòng thử lại');
}
}
}
/**
* Lazy-initialises mfaGraceStartedAt if the role requires MFA but
* the user hasn't enrolled yet. Returns the appropriate MFA claim
* and the number of grace days remaining (if any).
*/
private async resolveMfaGraceClaim(
userId: string,
mfaGraceStartedAt: Date | null,
): Promise<{ claim: MfaClaim; remainingDays?: number }> {
const now = new Date();
if (!mfaGraceStartedAt) {
// First login since enforcement — start the grace period
await this.userRepo.updateMfaGraceStartedAt(userId, now);
return { claim: 'grace', remainingDays: MFA_GRACE_PERIOD_DAYS };
}
const elapsedMs = now.getTime() - mfaGraceStartedAt.getTime();
const elapsedDays = elapsedMs / (1000 * 60 * 60 * 24);
const remainingDays = Math.max(0, Math.ceil(MFA_GRACE_PERIOD_DAYS - elapsedDays));
if (remainingDays > 0) {
return { claim: 'grace', remainingDays };
}
// Grace period expired — enrollment is now mandatory
return { claim: 'enrollment_required', remainingDays: 0 };
}
}

View File

@@ -22,6 +22,8 @@ export interface UserProps {
totpEnabled: boolean;
totpBackupCodes: string[];
totpEnabledAt: Date | null;
mfaGraceStartedAt: Date | null;
mfaLastVerifiedAt: Date | null;
}
export class UserEntity extends AggregateRoot<string> {
@@ -39,6 +41,8 @@ export class UserEntity extends AggregateRoot<string> {
private _totpEnabled: boolean;
private _totpBackupCodes: string[];
private _totpEnabledAt: Date | null;
private _mfaGraceStartedAt: Date | null;
private _mfaLastVerifiedAt: Date | null;
constructor(id: string, props: UserProps, createdAt?: Date, updatedAt?: Date) {
super(id, createdAt, updatedAt);
@@ -56,6 +60,8 @@ export class UserEntity extends AggregateRoot<string> {
this._totpEnabled = props.totpEnabled;
this._totpBackupCodes = props.totpBackupCodes;
this._totpEnabledAt = props.totpEnabledAt;
this._mfaGraceStartedAt = props.mfaGraceStartedAt;
this._mfaLastVerifiedAt = props.mfaLastVerifiedAt;
}
get email(): Email | null { return this._email; }
@@ -72,6 +78,8 @@ export class UserEntity extends AggregateRoot<string> {
get totpEnabled(): boolean { return this._totpEnabled; }
get totpBackupCodes(): string[] { return this._totpBackupCodes; }
get totpEnabledAt(): Date | null { return this._totpEnabledAt; }
get mfaGraceStartedAt(): Date | null { return this._mfaGraceStartedAt; }
get mfaLastVerifiedAt(): Date | null { return this._mfaLastVerifiedAt; }
static createNew(
id: string,
@@ -96,6 +104,8 @@ export class UserEntity extends AggregateRoot<string> {
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));
@@ -133,6 +143,8 @@ export class UserEntity extends AggregateRoot<string> {
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
user.addDomainEvent(new UserRegisteredEvent(id, phone.value, role));

View File

@@ -0,0 +1,28 @@
import { UserRole } from '@prisma/client';
/**
* MFA enrolment policy — central source of truth for which roles require
* TOTP and how long the grace period lasts.
*
* Backed by `User.mfaGraceStartedAt` and `User.mfaLastVerifiedAt` columns.
*
* Policy summary:
* - On first login under enforcement, `mfaGraceStartedAt` is stamped.
* - For `MFA_GRACE_PERIOD_DAYS` after that timestamp, the user keeps full
* access but receives `mfa: 'grace'` in their JWT (UI nudges enrollment).
* - After grace expires, the JWT carries `mfa: 'enrollment_required'` and
* sensitive routes (admin guards) reject until the user enrols.
*/
/** Roles for which TOTP is mandatory after the grace window expires. */
export const MFA_REQUIRED_ROLES: ReadonlyArray<UserRole> = ['ADMIN'];
/** Length of the grace window before MFA enrolment becomes mandatory. */
export const MFA_GRACE_PERIOD_DAYS = 14;
/**
* Re-auth window for "step-up" admin operations (e.g. user impersonation,
* mass actions). After this many minutes since `mfaLastVerifiedAt`, the
* admin re-auth interceptor must challenge again.
*/
export const MFA_REAUTH_WINDOW_MINUTES = 15;

View File

@@ -12,4 +12,6 @@ export interface IUserRepository {
updateMfaEnabled(userId: string, enabled: boolean, secret: string, backupCodes: string[]): Promise<void>;
updateMfaDisabled(userId: string): Promise<void>;
updateBackupCodes(userId: string, backupCodes: string[]): Promise<void>;
updateMfaGraceStartedAt(userId: string, date: Date): Promise<void>;
updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void>;
}

View File

@@ -0,0 +1,27 @@
import { sign as jwtSign } from 'jsonwebtoken';
import { describe, it, expect } from 'vitest';
import { verifyWithRotation, makeSecretOrKeyProvider } from '../utils/jwt-rotation';
const P = 'primary-secret-long-enough-for-hmac-signing-32!!';
const Q = 'previous-secret-long-enough-for-hmac-signing-32!';
const U = 'unknown-secret-long-enough-for-hmac-signing-32!!';
const O = { audience: 'goodgo-api', issuer: 'goodgo-platform', expiresIn: '15m' } as const;
const D = { sub: 'u1', phone: '0900000000', role: 'BUYER' };
describe('verifyWithRotation', () => {
it('succeeds with primary', () => { expect(verifyWithRotation(jwtSign(D, P, O), P, undefined)).toMatchObject(D); });
it('falls back to previous', () => { expect(verifyWithRotation(jwtSign(D, Q, O), P, Q)).toMatchObject(D); });
it('null when both fail', () => { expect(verifyWithRotation(jwtSign(D, U, O), P, Q)).toBeNull(); });
it('null without previous', () => { expect(verifyWithRotation(jwtSign(D, U, O), P, undefined)).toBeNull(); });
it('null for expired', () => { expect(verifyWithRotation(jwtSign(D, P, { ...O, expiresIn: '-1s' }), P, undefined)).toBeNull(); });
it('null for wrong audience', () => { expect(verifyWithRotation(jwtSign(D, P, { ...O, audience: 'x' }), P, undefined)).toBeNull(); });
});
describe('makeSecretOrKeyProvider', () => {
const call = (p: ReturnType<typeof makeSecretOrKeyProvider>, t: string) =>
new Promise<{ err: Error | null; secret?: string }>((r) => p({}, t, (e, s) => r({ err: e, secret: s })));
it('returns primary for primary-signed', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, P, O)); expect(r.secret).toBe(P); });
it('returns previous for previous-signed', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, Q, O)); expect(r.secret).toBe(Q); });
it('returns primary when both fail', async () => { const r = await call(makeSecretOrKeyProvider(P, Q), jwtSign(D, U, O)); expect(r.secret).toBe(P); });
});

View File

@@ -160,6 +160,8 @@ describe('LocalStrategy', () => {
phone: '+84912345678',
role: 'BUYER',
isMfaRequired: false,
totpEnabled: false,
mfaGraceStartedAt: undefined,
});
});

View File

@@ -1,158 +1,61 @@
import { sign as jwtSign } from 'jsonwebtoken';
import { type IRefreshTokenRepository, type RefreshTokenRecord } from '../../domain/repositories/refresh-token.repository';
import { TokenService } from '../services/token.service';
const PRIMARY_SECRET = 'primary-secret-that-is-long-enough-for-tests-32chars!';
const PREVIOUS_SECRET = 'previous-secret-that-is-long-enough-for-tests-32chars!';
const JWT_SIGN_OPTS = { audience: 'goodgo-api', issuer: 'goodgo-platform', expiresIn: '15m' } as const;
describe('TokenService', () => {
let service: TokenService;
let mockJwtService: { sign: ReturnType<typeof vi.fn>; verify: ReturnType<typeof vi.fn> };
let mockRefreshTokenRepo: { [K in keyof IRefreshTokenRepository]: ReturnType<typeof vi.fn> };
const payload = { sub: 'user-1', phone: '0912345678', role: 'BUYER' };
beforeEach(() => {
mockJwtService = {
sign: vi.fn().mockReturnValue('signed-jwt'),
verify: vi.fn(),
};
mockRefreshTokenRepo = {
create: vi.fn().mockResolvedValue({} as RefreshTokenRecord),
findByToken: vi.fn(),
revokeByFamily: vi.fn().mockResolvedValue(undefined),
revokeAllForUser: vi.fn().mockResolvedValue(undefined),
deleteExpired: vi.fn(),
};
service = new TokenService(
mockJwtService as any,
mockRefreshTokenRepo as any,
);
process.env['JWT_SECRET'] = PRIMARY_SECRET;
delete process.env['JWT_SECRET_PREVIOUS'];
mockJwtService = { sign: vi.fn().mockReturnValue('signed-jwt'), verify: vi.fn() };
mockRefreshTokenRepo = { create: vi.fn().mockResolvedValue({} as RefreshTokenRecord), findByToken: vi.fn(), revokeByFamily: vi.fn().mockResolvedValue(undefined), revokeAllForUser: vi.fn().mockResolvedValue(undefined), deleteExpired: vi.fn() };
service = new TokenService(mockJwtService as any, mockRefreshTokenRepo as any);
});
describe('generateTokenPair', () => {
it('returns access token, refresh token with family prefix, and expiresIn', async () => {
const result = await service.generateTokenPair(payload);
expect(result.accessToken).toBe('signed-jwt');
expect(result.refreshToken).toContain('.');
expect(result.expiresIn).toBe(900);
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
revokedAt: null,
}),
);
});
it('creates refresh token record with 30-day expiry', async () => {
await service.generateTokenPair(payload);
const createCall = mockRefreshTokenRepo.create.mock.calls[0][0];
const expiresAt = createCall.expiresAt as Date;
const now = new Date();
const daysDiff = Math.round((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
const expiresAt = mockRefreshTokenRepo.create.mock.calls[0][0].expiresAt as Date;
const daysDiff = Math.round((expiresAt.getTime() - Date.now()) / 86400000);
expect(daysDiff).toBeGreaterThanOrEqual(29);
expect(daysDiff).toBeLessThanOrEqual(31);
});
});
describe('rotateRefreshToken', () => {
const makeExistingToken = (overrides?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({
id: 'rt-1',
userId: 'user-1',
token: 'hashed-token',
family: 'old-family',
expiresAt: new Date(Date.now() + 86400000),
revokedAt: null,
createdAt: new Date(),
...overrides,
});
it('rotates valid token: revokes old family, creates new token', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(makeExistingToken());
mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord);
const result = await service.rotateRefreshToken('old-family.raw-token-hex');
expect(result).not.toBeNull();
expect(result!.userId).toBe('user-1');
expect(result!.refreshToken).toContain('.');
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('old-family');
expect(mockRefreshTokenRepo.create).toHaveBeenCalled();
});
it('returns null for malformed token (no dot separator)', async () => {
const result = await service.rotateRefreshToken('no-dot-separator');
expect(result).toBeNull();
});
it('returns null and revokes family when token not found (reuse attack)', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(null);
const result = await service.rotateRefreshToken('suspect-family.unknown-token');
expect(result).toBeNull();
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('suspect-family');
});
it('returns null and revokes family when token is already revoked', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(
makeExistingToken({ revokedAt: new Date() }),
);
const result = await service.rotateRefreshToken('old-family.revoked-token');
expect(result).toBeNull();
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
});
it('returns null and revokes family when token is expired', async () => {
mockRefreshTokenRepo.findByToken.mockResolvedValue(
makeExistingToken({ expiresAt: new Date(Date.now() - 86400000) }),
);
const result = await service.rotateRefreshToken('old-family.expired-token');
expect(result).toBeNull();
expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalled();
});
it('returns null for empty family segment', async () => {
const result = await service.rotateRefreshToken('.some-raw-token');
expect(result).toBeNull();
});
it('returns null for empty raw token segment', async () => {
const result = await service.rotateRefreshToken('some-family.');
expect(result).toBeNull();
});
const makeTok = (o?: Partial<RefreshTokenRecord>): RefreshTokenRecord => ({ id: 'rt-1', userId: 'user-1', token: 'h', family: 'old-family', expiresAt: new Date(Date.now() + 86400000), revokedAt: null, createdAt: new Date(), ...o });
it('rotates valid token', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok()); mockRefreshTokenRepo.create.mockResolvedValue({} as RefreshTokenRecord); const r = await service.rotateRefreshToken('old-family.raw'); expect(r).not.toBeNull(); expect(r!.userId).toBe('user-1'); });
it('null for malformed', async () => { expect(await service.rotateRefreshToken('nodot')).toBeNull(); });
it('null + revoke when not found', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(null); expect(await service.rotateRefreshToken('f.t')).toBeNull(); expect(mockRefreshTokenRepo.revokeByFamily).toHaveBeenCalledWith('f'); });
it('null when revoked', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok({ revokedAt: new Date() })); expect(await service.rotateRefreshToken('old-family.t')).toBeNull(); });
it('null when expired', async () => { mockRefreshTokenRepo.findByToken.mockResolvedValue(makeTok({ expiresAt: new Date(Date.now() - 86400000) })); expect(await service.rotateRefreshToken('old-family.t')).toBeNull(); });
it('null for empty family', async () => { expect(await service.rotateRefreshToken('.raw')).toBeNull(); });
it('null for empty raw', async () => { expect(await service.rotateRefreshToken('fam.')).toBeNull(); });
});
describe('generateAccessToken', () => {
it('delegates to jwtService.sign', () => {
const token = service.generateAccessToken(payload);
expect(token).toBe('signed-jwt');
expect(mockJwtService.sign).toHaveBeenCalledWith(payload);
});
});
describe('revokeAllUserTokens', () => {
it('revokes all tokens for a user', async () => {
await service.revokeAllUserTokens('user-1');
expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1');
});
});
describe('generateAccessToken', () => { it('delegates to jwtService.sign', () => { expect(service.generateAccessToken(payload)).toBe('signed-jwt'); }); });
describe('revokeAllUserTokens', () => { it('revokes', async () => { await service.revokeAllUserTokens('user-1'); expect(mockRefreshTokenRepo.revokeAllForUser).toHaveBeenCalledWith('user-1'); }); });
describe('verifyAccessToken', () => {
it('returns decoded payload for valid token', () => {
mockJwtService.verify.mockReturnValue(payload);
const result = service.verifyAccessToken('valid-jwt');
expect(result).toEqual(payload);
});
it('returns null for invalid token', () => {
mockJwtService.verify.mockImplementation(() => { throw new Error('invalid'); });
const result = service.verifyAccessToken('bad-jwt');
expect(result).toBeNull();
});
function svc(p: string, q?: string) { const o = process.env['JWT_SECRET']; const oq = process.env['JWT_SECRET_PREVIOUS']; process.env['JWT_SECRET'] = p; if (q) process.env['JWT_SECRET_PREVIOUS'] = q; else delete process.env['JWT_SECRET_PREVIOUS']; const s = new TokenService(mockJwtService as any, mockRefreshTokenRepo as any); if (o) process.env['JWT_SECRET'] = o; if (oq) process.env['JWT_SECRET_PREVIOUS'] = oq; else delete process.env['JWT_SECRET_PREVIOUS']; return s; }
it('primary succeeds', () => { expect(service.verifyAccessToken(jwtSign(payload, PRIMARY_SECRET, JWT_SIGN_OPTS))).toMatchObject(payload); });
it('fallback to previous', () => { expect(svc(PRIMARY_SECRET, PREVIOUS_SECRET).verifyAccessToken(jwtSign(payload, PREVIOUS_SECRET, JWT_SIGN_OPTS))).toMatchObject(payload); });
it('null when both fail', () => { expect(svc(PRIMARY_SECRET, PREVIOUS_SECRET).verifyAccessToken(jwtSign(payload, 'unknown-secret-that-is-long-enough-for-test!!!', JWT_SIGN_OPTS))).toBeNull(); });
it('null for garbage', () => { expect(service.verifyAccessToken('garbage')).toBeNull(); });
it('null for expired', () => { expect(service.verifyAccessToken(jwtSign(payload, PRIMARY_SECRET, { ...JWT_SIGN_OPTS, expiresIn: '-1s' }))).toBeNull(); });
});
});

View File

@@ -123,6 +123,14 @@ export class PrismaUserRepository implements IUserRepository {
});
}
async updateMfaGraceStartedAt(userId: string, date: Date): Promise<void> {
await this.prisma.user.update({ where: { id: userId }, data: { mfaGraceStartedAt: date } });
}
async updateMfaLastVerifiedAt(userId: string, date: Date): Promise<void> {
await this.prisma.user.update({ where: { id: userId }, data: { mfaLastVerifiedAt: date } });
}
private toDomain(raw: PrismaUser): UserEntity {
const phone = Phone.create(raw.phone).unwrap();
const email = raw.email ? Email.create(raw.email).unwrap() : null;
@@ -145,6 +153,8 @@ export class PrismaUserRepository implements IUserRepository {
totpEnabled: raw.totpEnabled,
totpBackupCodes: raw.totpBackupCodes,
totpEnabledAt: raw.totpEnabledAt,
mfaGraceStartedAt: raw.mfaGraceStartedAt,
mfaLastVerifiedAt: raw.mfaLastVerifiedAt,
};
return new UserEntity(raw.id, props, raw.createdAt, raw.updatedAt);

View File

@@ -121,10 +121,13 @@ export class OAuthService {
kycStatus: 'NONE',
kycData: null,
isActive: true,
deletedAt: null,
totpSecret: null,
totpEnabled: false,
totpBackupCodes: [],
totpEnabledAt: null,
mfaGraceStartedAt: null,
mfaLastVerifiedAt: null,
});
await this.userRepo.save(user);

View File

@@ -5,11 +5,25 @@ import {
REFRESH_TOKEN_REPOSITORY,
type IRefreshTokenRepository,
} from '../../domain/repositories/refresh-token.repository';
import { verifyWithRotation } from '../utils/jwt-rotation';
/**
* MFA enrolment status carried inside the access-token JWT.
*
* - `none` — role does not require MFA, or user is enrolled and
* has just verified (`requiresMfa === true` flow).
* - `grace` — role requires MFA but the user is inside the
* enforcement grace window. UI nudges enrollment.
* - `enrollment_required`— grace window has expired; backend guards on
* sensitive routes must reject and force enrollment.
*/
export type MfaClaim = 'none' | 'grace' | 'enrollment_required';
export interface JwtPayload {
sub: string;
phone: string;
role: string;
mfa?: MfaClaim;
}
export interface TokenPair {
@@ -26,102 +40,60 @@ export interface RotateResult {
@Injectable()
export class TokenService {
private readonly REFRESH_TOKEN_EXPIRY_DAYS = 30;
private readonly primarySecret: string;
private readonly previousSecret: string | undefined;
constructor(
private readonly jwtService: JwtService,
@Inject(REFRESH_TOKEN_REPOSITORY)
private readonly refreshTokenRepo: IRefreshTokenRepository,
) {}
) {
const secret = process.env['JWT_SECRET'];
if (!secret) {
throw new Error('JWT_SECRET environment variable is required');
}
this.primarySecret = secret;
this.previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
}
async generateTokenPair(payload: JwtPayload): Promise<TokenPair> {
const accessToken = this.jwtService.sign(payload);
const rawRefreshToken = randomBytes(64).toString('hex');
const hashedToken = this.hashToken(rawRefreshToken);
const family = randomBytes(16).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
await this.refreshTokenRepo.create({
userId: payload.sub,
token: hashedToken,
family,
expiresAt,
revokedAt: null,
});
return {
accessToken,
refreshToken: `${family}.${rawRefreshToken}`,
expiresIn: 900,
};
await this.refreshTokenRepo.create({ userId: payload.sub, token: hashedToken, family, expiresAt, revokedAt: null });
return { accessToken, refreshToken: `${family}.${rawRefreshToken}`, expiresIn: 900 };
}
async rotateRefreshToken(refreshToken: string): Promise<RotateResult | null> {
const dotIndex = refreshToken.indexOf('.');
if (dotIndex === -1) return null;
const family = refreshToken.substring(0, dotIndex);
const rawToken = refreshToken.substring(dotIndex + 1);
if (!family || !rawToken) return null;
const hashedToken = this.hashToken(rawToken);
const existing = await this.refreshTokenRepo.findByToken(hashedToken);
if (!existing) {
// Possible token reuse attack — revoke entire family
await this.refreshTokenRepo.revokeByFamily(family);
return null;
}
if (existing.revokedAt || existing.expiresAt < new Date()) {
await this.refreshTokenRepo.revokeByFamily(existing.family);
return null;
}
// Revoke all tokens in this family
if (!existing) { await this.refreshTokenRepo.revokeByFamily(family); return null; }
if (existing.revokedAt || existing.expiresAt < new Date()) { await this.refreshTokenRepo.revokeByFamily(existing.family); return null; }
await this.refreshTokenRepo.revokeByFamily(existing.family);
// Create new token in a new family
const newRawToken = randomBytes(64).toString('hex');
const newHashedToken = this.hashToken(newRawToken);
const newFamily = randomBytes(16).toString('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.REFRESH_TOKEN_EXPIRY_DAYS);
await this.refreshTokenRepo.create({
userId: existing.userId,
token: newHashedToken,
family: newFamily,
expiresAt,
revokedAt: null,
});
return {
userId: existing.userId,
refreshToken: `${newFamily}.${newRawToken}`,
};
await this.refreshTokenRepo.create({ userId: existing.userId, token: newHashedToken, family: newFamily, expiresAt, revokedAt: null });
return { userId: existing.userId, refreshToken: `${newFamily}.${newRawToken}` };
}
generateAccessToken(payload: JwtPayload): string {
return this.jwtService.sign(payload);
}
generateAccessToken(payload: JwtPayload): string { return this.jwtService.sign(payload); }
async revokeAllUserTokens(userId: string): Promise<void> {
await this.refreshTokenRepo.revokeAllForUser(userId);
}
async revokeAllUserTokens(userId: string): Promise<void> { await this.refreshTokenRepo.revokeAllForUser(userId); }
verifyAccessToken(token: string): JwtPayload | null {
try {
return this.jwtService.verify<JwtPayload>(token);
} catch {
return null;
}
return verifyWithRotation<JwtPayload>(token, this.primarySecret, this.previousSecret);
}
private hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); }
}

View File

@@ -5,6 +5,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { PrismaService, RedisService } from '@modules/shared';
import { type JwtPayload } from '../services/token.service';
import { makeSecretOrKeyProvider } from '../utils/jwt-rotation';
function extractJwtFromCookieOrHeader(req: Request): string | null {
const cookieToken = req.cookies?.['access_token'] as string | undefined;
@@ -12,88 +13,33 @@ function extractJwtFromCookieOrHeader(req: Request): string | null {
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
}
/** Cached user status — JSON encoded in Redis. */
interface CachedUserStatus {
isActive: boolean;
deletedAt: string | null;
}
interface CachedUserStatus { isActive: boolean; deletedAt: string | null; }
/**
* Redis key prefix for user status cache. Versioned so that a schema
* change can invalidate all stale entries by bumping the version.
*/
export const USER_STATUS_CACHE_PREFIX = 'auth:user_status:v1';
/** TTL for cached user status (seconds). */
export const USER_STATUS_CACHE_TTL_SECONDS = 60;
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {
constructor(private readonly prisma: PrismaService, private readonly redis: RedisService) {
const jwtSecret = process.env['JWT_SECRET'];
if (!jwtSecret) {
throw new Error('JWT_SECRET environment variable is required');
}
super({
jwtFromRequest: extractJwtFromCookieOrHeader,
ignoreExpiration: false,
secretOrKey: jwtSecret,
audience: 'goodgo-api',
issuer: 'goodgo-platform',
});
if (!jwtSecret) throw new Error('JWT_SECRET environment variable is required');
const previousSecret = process.env['JWT_SECRET_PREVIOUS'] || undefined;
super({ jwtFromRequest: extractJwtFromCookieOrHeader, ignoreExpiration: false, secretOrKeyProvider: makeSecretOrKeyProvider(jwtSecret, previousSecret), audience: 'goodgo-api', issuer: 'goodgo-platform' });
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
const status = await this.loadUserStatus(payload.sub);
if (!status || !status.isActive || status.deletedAt !== null) {
throw new UnauthorizedException('User account is inactive or deleted');
}
if (!status || !status.isActive || status.deletedAt !== null) throw new UnauthorizedException('User account is inactive or deleted');
return { sub: payload.sub, phone: payload.phone, role: payload.role };
}
/**
* Loads user status from Redis cache if present, otherwise from DB and
* populates the cache with a 60 s TTL. Redis failures are non-fatal:
* we fall back to DB so a Redis outage cannot lock out all users.
*
* Returns null only when the user does not exist in the DB.
*/
private async loadUserStatus(userId: string): Promise<CachedUserStatus | null> {
const cacheKey = `${USER_STATUS_CACHE_PREFIX}:${userId}`;
if (this.redis.isAvailable()) {
try {
const cached = await this.redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as CachedUserStatus;
}
} catch {
// Swallow: degrade to DB on Redis read error.
}
}
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { isActive: true, deletedAt: true },
});
if (this.redis.isAvailable()) { try { const cached = await this.redis.get(cacheKey); if (cached !== null) return JSON.parse(cached) as CachedUserStatus; } catch { /* swallow */ } }
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { isActive: true, deletedAt: true } });
if (!user) return null;
const status: CachedUserStatus = {
isActive: user.isActive,
deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null,
};
if (this.redis.isAvailable()) {
try {
await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS);
} catch {
// Swallow: cache population is best-effort.
}
}
const status: CachedUserStatus = { isActive: user.isActive, deletedAt: user.deletedAt ? user.deletedAt.toISOString() : null };
if (this.redis.isAvailable()) { try { await this.redis.set(cacheKey, JSON.stringify(status), USER_STATUS_CACHE_TTL_SECONDS); } catch { /* swallow */ } }
return status;
}
}

View File

@@ -9,6 +9,8 @@ export interface LocalStrategyResult {
phone: string;
role: string;
isMfaRequired: boolean;
totpEnabled: boolean;
mfaGraceStartedAt: Date | null;
}
@Injectable()
@@ -56,6 +58,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
phone: user.phone.value,
role: user.role,
isMfaRequired: user.totpEnabled,
totpEnabled: user.totpEnabled,
mfaGraceStartedAt: user.mfaGraceStartedAt,
};
} catch (error) {
if (error instanceof DomainException) throw error;

View File

@@ -0,0 +1,21 @@
import { verify as jwtVerify, type JwtPayload as JsonWebTokenPayload } from 'jsonwebtoken';
const JWT_VERIFY_OPTIONS = { audience: 'goodgo-api', issuer: 'goodgo-platform' } as const;
export function verifyWithRotation<T extends object = JsonWebTokenPayload>(
token: string, primarySecret: string, previousSecret: string | undefined,
): T | null {
try { return jwtVerify(token, primarySecret, JWT_VERIFY_OPTIONS) as T; } catch { /* primary failed */ }
if (previousSecret) { try { return jwtVerify(token, previousSecret, JWT_VERIFY_OPTIONS) as T; } catch { /* both failed */ } }
return null;
}
export function makeSecretOrKeyProvider(
primarySecret: string, previousSecret: string | undefined,
): (request: unknown, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => void {
return (_request: unknown, rawJwtToken: string, done: (err: Error | null, secret?: string) => void) => {
try { jwtVerify(rawJwtToken, primarySecret, JWT_VERIFY_OPTIONS); return done(null, primarySecret); } catch { /* primary failed */ }
if (previousSecret) { try { jwtVerify(rawJwtToken, previousSecret, JWT_VERIFY_OPTIONS); return done(null, previousSecret); } catch { /* both failed */ } }
return done(null, primarySecret);
};
}