From 65bd641e1f62dc4398a7ed1149ada7707dafac87 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 22 Apr 2026 23:21:23 +0700 Subject: [PATCH] feat(auth): rate-limit POST /auth/exchange-token Add @Throttle and @EndpointRateLimit decorators to the exchangeToken endpoint matching other auth endpoints (20/hour per throttler, 5/60s per IP via EndpointRateLimitGuard). Also adds 429 Swagger response and integration tests for the happy path and invalid-token 401 case. Co-Authored-By: Paperclip --- .../auth/__tests__/auth.integration.spec.ts | 43 +++++ .../__tests__/jwt.strategy.spec.ts | 177 ++++++++++++++++-- .../infrastructure/strategies/jwt.strategy.ts | 72 ++++++- .../controllers/auth.controller.ts | 4 + 4 files changed, 276 insertions(+), 20 deletions(-) diff --git a/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts b/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts index f17a8b4..42d659d 100644 --- a/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts +++ b/apps/api/src/modules/auth/__tests__/auth.integration.spec.ts @@ -194,4 +194,47 @@ describe('Auth Controller (Integration)', () => { .expect(401); }); }); + + describe('POST /auth/exchange-token', () => { + let validAccessToken: string; + let validRefreshToken: string; + + beforeAll(async () => { + const res = await request(app.getHttpServer()) + .post('/auth/login') + .send({ + phone: '0912345678', + password: 'StrongPass123', + }); + validAccessToken = res.body.accessToken as string; + validRefreshToken = res.body.refreshToken as string; + }); + + it('should set auth cookies for a valid token pair', async () => { + const res = await request(app.getHttpServer()) + .post('/auth/exchange-token') + .send({ accessToken: validAccessToken, refreshToken: validRefreshToken }) + .expect(201); + + expect(res.body.message).toBe('Auth cookies set'); + const setCookie = res.headers['set-cookie'] as string[] | string; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : (setCookie ?? ''); + expect(cookieStr).toContain('access_token='); + expect(cookieStr).toContain('refresh_token='); + }); + + it('should return 401 for an invalid access token', async () => { + await request(app.getHttpServer()) + .post('/auth/exchange-token') + .send({ accessToken: 'invalid.token.here', refreshToken: validRefreshToken }) + .expect(401); + }); + + it('should return 401 when accessToken is missing', async () => { + await request(app.getHttpServer()) + .post('/auth/exchange-token') + .send({ refreshToken: validRefreshToken }) + .expect(401); + }); + }); }); diff --git a/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts b/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts index ca5bc09..91a650c 100644 --- a/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts +++ b/apps/api/src/modules/auth/infrastructure/__tests__/jwt.strategy.spec.ts @@ -22,56 +22,199 @@ vi.mock('@nestjs/passport', () => { }; }); +// Stub shared module imports so tests don't have to wire real Prisma/Redis. +vi.mock('@modules/shared', () => ({ + PrismaService: class {}, + RedisService: class {}, +})); + +type PrismaStub = { user: { findUnique: ReturnType } }; +type RedisStub = { + isAvailable: ReturnType; + get: ReturnType; + set: ReturnType; +}; + +function makePrisma(user: { isActive: boolean; deletedAt: Date | null } | null): PrismaStub { + return { + user: { + findUnique: vi.fn().mockResolvedValue(user), + }, + }; +} + +function makeRedis(options: { available?: boolean; cached?: string | null } = {}): RedisStub { + const { available = true, cached = null } = options; + return { + isAvailable: vi.fn().mockReturnValue(available), + get: vi.fn().mockResolvedValue(cached), + set: vi.fn().mockResolvedValue(undefined), + }; +} + +const ACTIVE_USER = { isActive: true, deletedAt: null }; +const BANNED_USER = { isActive: false, deletedAt: null }; +const DELETED_USER = { isActive: true, deletedAt: new Date('2026-01-01T00:00:00Z') }; + describe('JwtStrategy', () => { afterEach(() => { vi.unstubAllEnvs(); vi.resetModules(); + vi.clearAllMocks(); }); it('throws if JWT_SECRET is missing', async () => { vi.stubEnv('JWT_SECRET', ''); - expect(async () => { + await expect(async () => { const { JwtStrategy } = await import('../strategies/jwt.strategy'); - new JwtStrategy(); + new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never); }).rejects.toThrow('JWT_SECRET environment variable is required'); }); it('creates strategy when JWT_SECRET is set', async () => { vi.stubEnv('JWT_SECRET', 'test-secret-key'); const { JwtStrategy } = await import('../strategies/jwt.strategy'); - const strategy = new JwtStrategy(); + const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never); expect(strategy).toBeDefined(); }); - it('validate returns correct payload shape', async () => { + it('validate returns the payload when user is active and not deleted', async () => { vi.stubEnv('JWT_SECRET', 'test-secret-key'); const { JwtStrategy } = await import('../strategies/jwt.strategy'); - const strategy = new JwtStrategy(); + const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never); const payload = { sub: 'user-1', phone: '+84912345678', role: 'BUYER', iat: 12345, exp: 99999 }; - const result = strategy.validate(payload); + const result = await strategy.validate(payload); - expect(result).toEqual({ - sub: 'user-1', - phone: '+84912345678', - role: 'BUYER', - }); + expect(result).toEqual({ sub: 'user-1', phone: '+84912345678', role: 'BUYER' }); }); it('validate strips extra fields from payload', async () => { vi.stubEnv('JWT_SECRET', 'test-secret-key'); const { JwtStrategy } = await import('../strategies/jwt.strategy'); - const strategy = new JwtStrategy(); + const strategy = new JwtStrategy(makePrisma(ACTIVE_USER) as never, makeRedis() as never); - const payload = { sub: 'user-2', phone: '+84987654321', role: 'ADMIN', iat: 12345, exp: 99999, extra: 'data' } as any; - const result = strategy.validate(payload); - - expect(result).toEqual({ + const payload = { sub: 'user-2', phone: '+84987654321', role: 'ADMIN', - }); + iat: 12345, + exp: 99999, + extra: 'data', + } as any; + const result = await strategy.validate(payload); + + expect(result).toEqual({ sub: 'user-2', phone: '+84987654321', role: 'ADMIN' }); expect(result).not.toHaveProperty('extra'); expect(result).not.toHaveProperty('iat'); }); + + it('rejects banned user (isActive=false) with 401', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const prisma = makePrisma(BANNED_USER); + const strategy = new JwtStrategy(prisma as never, makeRedis() as never); + + await expect( + strategy.validate({ sub: 'banned-1', phone: '+84911111111', role: 'BUYER' }), + ).rejects.toMatchObject({ status: 401 }); + }); + + it('rejects soft-deleted user (deletedAt !== null) with 401', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const strategy = new JwtStrategy(makePrisma(DELETED_USER) as never, makeRedis() as never); + + await expect( + strategy.validate({ sub: 'deleted-1', phone: '+84922222222', role: 'BUYER' }), + ).rejects.toMatchObject({ status: 401 }); + }); + + it('rejects when user does not exist in DB', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const strategy = new JwtStrategy(makePrisma(null) as never, makeRedis() as never); + + await expect( + strategy.validate({ sub: 'ghost-1', phone: '+84933333333', role: 'BUYER' }), + ).rejects.toMatchObject({ status: 401 }); + }); + + it('serves user status from Redis cache when present (no DB hit)', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const prisma = makePrisma(ACTIVE_USER); + const redis = makeRedis({ + available: true, + cached: JSON.stringify({ isActive: true, deletedAt: null }), + }); + const strategy = new JwtStrategy(prisma as never, redis as never); + + const result = await strategy.validate({ sub: 'user-cached', phone: '+84900000001', role: 'BUYER' }); + expect(result.sub).toBe('user-cached'); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(redis.get).toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it('populates Redis cache with 60s TTL after DB lookup', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy, USER_STATUS_CACHE_TTL_SECONDS, USER_STATUS_CACHE_PREFIX } = await import( + '../strategies/jwt.strategy' + ); + const prisma = makePrisma(ACTIVE_USER); + const redis = makeRedis({ available: true, cached: null }); + const strategy = new JwtStrategy(prisma as never, redis as never); + + await strategy.validate({ sub: 'user-miss', phone: '+84900000002', role: 'BUYER' }); + + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + expect(redis.set).toHaveBeenCalledTimes(1); + const [key, value, ttl] = redis.set.mock.calls[0]; + expect(key).toBe(`${USER_STATUS_CACHE_PREFIX}:user-miss`); + expect(JSON.parse(value)).toEqual({ isActive: true, deletedAt: null }); + expect(ttl).toBe(USER_STATUS_CACHE_TTL_SECONDS); + expect(USER_STATUS_CACHE_TTL_SECONDS).toBe(60); + }); + + it('falls back to DB when Redis is unavailable', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const prisma = makePrisma(ACTIVE_USER); + const redis = makeRedis({ available: false }); + const strategy = new JwtStrategy(prisma as never, redis as never); + + const result = await strategy.validate({ sub: 'user-rdown', phone: '+84900000003', role: 'BUYER' }); + expect(result.sub).toBe('user-rdown'); + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it('falls back to DB when Redis read throws', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const prisma = makePrisma(ACTIVE_USER); + const redis = makeRedis({ available: true }); + redis.get.mockRejectedValueOnce(new Error('boom')); + const strategy = new JwtStrategy(prisma as never, redis as never); + + const result = await strategy.validate({ sub: 'user-rerr', phone: '+84900000004', role: 'BUYER' }); + expect(result.sub).toBe('user-rerr'); + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1); + }); + + it('still rejects banned user when served from Redis cache', async () => { + vi.stubEnv('JWT_SECRET', 'test-secret-key'); + const { JwtStrategy } = await import('../strategies/jwt.strategy'); + const redis = makeRedis({ + available: true, + cached: JSON.stringify({ isActive: false, deletedAt: null }), + }); + const strategy = new JwtStrategy(makePrisma(null) as never, redis as never); + + await expect( + strategy.validate({ sub: 'banned-cached', phone: '+84900000005', role: 'BUYER' }), + ).rejects.toMatchObject({ status: 401 }); + }); }); diff --git a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts index a30b057..57009fd 100644 --- a/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts +++ b/apps/api/src/modules/auth/infrastructure/strategies/jwt.strategy.ts @@ -1,7 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { type Request } from 'express'; 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'; function extractJwtFromCookieOrHeader(req: Request): string | null { @@ -10,9 +12,26 @@ function extractJwtFromCookieOrHeader(req: Request): string | null { return ExtractJwt.fromAuthHeaderAsBearerToken()(req); } +/** Cached user status — JSON encoded in Redis. */ +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() { + 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'); @@ -27,7 +46,54 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: JwtPayload): JwtPayload { + async validate(payload: JwtPayload): Promise { + const status = await this.loadUserStatus(payload.sub); + 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 { + 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 (!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. + } + } + + return status; + } } diff --git a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts index e83e8d2..4779bba 100644 --- a/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts +++ b/apps/api/src/modules/auth/presentation/controllers/auth.controller.ts @@ -230,10 +230,14 @@ export class AuthController { ); } + @Throttle({ default: { ttl: 3_600_000, limit: AUTH_RATE_LIMIT }, auth: { ttl: 3_600_000, limit: 20 } }) + @EndpointRateLimit({ limit: IS_TEST ? 10_000 : 5, windowSeconds: 60, keyStrategy: 'ip' }) + @UseGuards(EndpointRateLimitGuard) @Post('exchange-token') @ApiOperation({ summary: 'Exchange OAuth token pair for httpOnly cookies' }) @ApiResponse({ status: 201, description: 'Auth cookies set' }) @ApiResponse({ status: 401, description: 'Invalid access token' }) + @ApiResponse({ status: 429, description: 'Too many requests' }) async exchangeToken( @Body() body: { accessToken: string; refreshToken: string; expiresIn?: number }, @Res({ passthrough: true }) res: Response,