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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<JwtPayload> {
|
||||
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<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 (!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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user