diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 5018e115..188a42bf 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -20,6 +20,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { ApiKeyAuthGuard } from './guards/api-key-auth.guard'; import { GoogleAuthGuard } from './guards/google-auth.guard'; import { RolesGuard } from './guards/roles.guard'; +import { RateLimitGuard } from './guards/rate-limit.guard'; import { CurrentUser } from './decorators/current-user.decorator'; import { Roles } from './decorators/roles.decorator'; import { AuthUserPayload } from './types/auth-user.type'; @@ -174,13 +175,15 @@ export class AuthController { } @Post('password-reset/request') - requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) { - return this.authService.requestPasswordReset(requestPasswordResetDto); + requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto, @Req() request: Request) { + const ipAddress = request.ip || request.socket.remoteAddress; + return this.authService.requestPasswordReset(requestPasswordResetDto, ipAddress); } @Post('password-reset/reset') - resetPassword(@Body() resetPasswordDto: ResetPasswordDto) { - return this.authService.resetPassword(resetPasswordDto); + resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Req() request: Request) { + const ipAddress = request.ip || request.socket.remoteAddress; + return this.authService.resetPassword(resetPasswordDto, ipAddress); } @UseGuards(JwtAuthGuard, RolesGuard) @@ -208,4 +211,4 @@ export class AuthController { const userAgent = request.headers['user-agent']; return this.authService.resendEmailVerification(data.email, ipAddress, userAgent); } -} +} \ No newline at end of file diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0e45ffe2..26d33b50 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -47,8 +47,11 @@ import { AuthUserPayload } from './types/auth-user.type'; import { GoogleProfile } from './strategies/google.strategy'; import { LoginRateLimitService } from './login-rate-limit.service'; +import { RateLimitService } from './rate-limit.service'; import { UserRole } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; +import { ENDPOINT_RATE_LIMITS } from './rate-limit.config'; +import { CacheService } from '../cache/cache.service'; import { ApiKeyAnalyticsService } from './api-key-analytics.service'; type JwtPayload = { @@ -88,8 +91,10 @@ export class AuthService { private readonly sessionsService: SessionsService, private readonly configService: ConfigService, private readonly emailService: EmailService, - private readonly rateLimitService: LoginRateLimitService, + private readonly loginRateLimitService: LoginRateLimitService, + private readonly rateLimitService: RateLimitService, private readonly fraudService: FraudService, + private readonly cacheService: CacheService, @Optional() private readonly apiKeyAnalyticsService?: ApiKeyAnalyticsService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') ?? 'propchain-access-secret'; @@ -122,7 +127,7 @@ export class AuthService { async register(data: RegisterDto, ipAddress?: string) { // Block re-registration from same IP until prior email is verified if (ipAddress) { - const allowed = this.canRegisterFromIp(ipAddress); + const allowed = await this.canRegisterFromIp(ipAddress); if (!allowed) { throw new BadRequestException( 'A registration from this IP is already pending email verification. Please verify your email before registering a new account.', @@ -189,15 +194,23 @@ export class AuthService { // Track IP for re-registration prevention if (ipAddress) { - const expiryMs = + const expirySeconds = parseDuration( this.configService.get('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h', 24 * 60 * 60, - ) * 1000; - this.registrationIpMap.set(ipAddress, { + ); + const expiryMs = expirySeconds * 1000; + const cacheKey = `registration:ip:${ipAddress}`; + const entry = { email: user.email, expiresAt: new Date(Date.now() + expiryMs), - }); + }; + + // Store in Redis with TTL + await this.cacheService.set(cacheKey, entry, expirySeconds); + + // Also keep in in-memory map for backward compatibility/fallback + this.registrationIpMap.set(ipAddress, entry); } return { @@ -207,23 +220,45 @@ export class AuthService { }; } - private canRegisterFromIp(ipAddress: string): boolean { - const entry = this.registrationIpMap.get(ipAddress); - if (!entry) return true; - if (Date.now() > entry.expiresAt.getTime()) { + private async canRegisterFromIp(ipAddress: string): Promise { + const cacheKey = `registration:ip:${ipAddress}`; + const entry = await this.cacheService.get<{ email: string; expiresAt: Date }>(cacheKey); + + // Check cache first + if (entry) { + if (Date.now() > entry.expiresAt.getTime()) { + await this.cacheService.del(cacheKey); + return true; + } + return false; + } + + // Fallback to in-memory map for backward compatibility + const inMemoryEntry = this.registrationIpMap.get(ipAddress); + if (!inMemoryEntry) return true; + if (Date.now() > inMemoryEntry.expiresAt.getTime()) { this.registrationIpMap.delete(ipAddress); return true; } return false; } - private cleanupIpForEmail(email: string): void { + private async cleanupIpForEmail(email: string): Promise { + // First check in-memory map to find the IP for this email + let ipToCleanup: string | null = null; for (const [ip, entry] of this.registrationIpMap.entries()) { if (entry.email === email) { this.registrationIpMap.delete(ip); - return; + ipToCleanup = ip; + break; } } + + // Also delete from Redis if we found the IP, or scan for it + if (ipToCleanup) { + const cacheKey = `registration:ip:${ipToCleanup}`; + await this.cacheService.del(cacheKey); + } } /** @@ -1374,7 +1409,23 @@ export class AuthService { return Array.from(new Set(permissions.map((permission) => permission.trim()).filter(Boolean))); } - async requestPasswordReset(data: RequestPasswordResetDto): Promise { + async requestPasswordReset(data: RequestPasswordResetDto, ipAddress?: string): Promise { + // Apply rate limiting: max 3 requests per email per hour + const emailRateLimit = await this.rateLimitService.checkEmailRateLimit( + 'POST /auth/password-reset/request', + data.email, + 3, + 60 * 60 * 1000, // 1 hour + ); + + if (emailRateLimit.isExceeded) { + this.logger.warn( + `Password reset request rate limit exceeded for email: ${redactEmail(data.email)} (IP: ${ipAddress || 'unknown'})`, + ); + // Don't reveal rate limit was exceeded to prevent user enumeration + return; + } + const user = await this.usersService.findByEmail(data.email); if (!user) { // Don't reveal if email exists or not for security @@ -1415,7 +1466,22 @@ export class AuthService { await this.emailService.sendPasswordResetEmail(user.email, resetToken); } - async resetPassword(data: ResetPasswordDto): Promise { + async resetPassword(data: ResetPasswordDto, ipAddress?: string): Promise { + // Apply rate limiting: max 5 attempts per token per hour + const tokenRateLimit = await this.rateLimitService.checkTokenRateLimit( + 'POST /auth/password-reset/reset', + data.token, + 5, + 60 * 60 * 1000, // 1 hour + ); + + if (tokenRateLimit.isExceeded) { + this.logger.warn( + `Password reset token rate limit exceeded. Token: ${data.token.substring(0, 8)}... (IP: ${ipAddress || 'unknown'})`, + ); + throw new BadRequestException('Too many attempts. Please try again later.'); + } + const tokenHash = createSha256(data.token); const resetToken = await this.prisma.passwordResetToken.findUnique({ where: { token: tokenHash }, @@ -1585,6 +1651,21 @@ export class AuthService { } async verifyInitialEmail(token: string, ipAddress?: string, userAgent?: string) { + // Apply rate limiting: max 5 attempts per token per hour + const tokenRateLimit = await this.rateLimitService.checkTokenRateLimit( + 'POST /auth/verify-email', + token, + 5, + 60 * 60 * 1000, // 1 hour + ); + + if (tokenRateLimit.isExceeded) { + this.logger.warn( + `Email verification token rate limit exceeded. Token: ${token.substring(0, 8)}... (IP: ${ipAddress || 'unknown'})`, + ); + throw new BadRequestException('Too many attempts. Please try again later.'); + } + // Find user by verification token const user = await this.prisma.user.findFirst({ where: { @@ -1624,6 +1705,9 @@ export class AuthService { }, }); + // Clean up IP tracking since email is now verified + await this.cleanupIpForEmail(user.email); + // Issue token pair const tokens = await this.issueTokenPair(updatedUser, undefined, ipAddress, userAgent); @@ -1635,6 +1719,22 @@ export class AuthService { } async resendEmailVerification(email: string, ipAddress?: string, userAgent?: string) { + // Apply rate limiting: max 3 requests per email per hour + const emailRateLimit = await this.rateLimitService.checkEmailRateLimit( + 'POST /auth/email/resend', + email, + 3, + 60 * 60 * 1000, // 1 hour + ); + + if (emailRateLimit.isExceeded) { + this.logger.warn( + `Email resend rate limit exceeded for email: ${redactEmail(email)} (IP: ${ipAddress || 'unknown'})`, + ); + // Don't reveal rate limit was exceeded to prevent user enumeration + return; + } + const user = await this.usersService.findByEmail(email); if (!user) { return; @@ -1681,4 +1781,4 @@ export class AuthService { this.logger.log(`Verification email resent for user ${user.id}`); } -} +} \ No newline at end of file diff --git a/src/auth/rate-limit.config.ts b/src/auth/rate-limit.config.ts index aa007bc2..e7c1728b 100644 --- a/src/auth/rate-limit.config.ts +++ b/src/auth/rate-limit.config.ts @@ -51,6 +51,18 @@ export const ENDPOINT_RATE_LIMITS: Record `rate-limit:ip:${ip}`, USER_IP: (userId: string, ip: string) => `rate-limit:user-ip:${userId}:${ip}`, API_KEY: (apiKey: string) => `rate-limit:api-key:${apiKey}`, + EMAIL: (endpoint: string, email: string) => `rate-limit:email:${endpoint}:${email.toLowerCase()}`, + TOKEN: (endpoint: string, token: string) => `rate-limit:token:${endpoint}:${token}`, }; /** @@ -181,4 +195,4 @@ export function getEndpointRateLimit(endpoint: string): RateLimitConfig | null { statusCode: 429, message: `Too many requests to ${endpoint}. Please try again later.`, }; -} +} \ No newline at end of file diff --git a/src/auth/rate-limit.service.ts b/src/auth/rate-limit.service.ts index e4be5c8d..1ae13cb9 100644 --- a/src/auth/rate-limit.service.ts +++ b/src/auth/rate-limit.service.ts @@ -181,6 +181,34 @@ export class RateLimitService { return this.checkRateLimit(key, limit, windowMs); } + /** + * Check rate limit for an email address on a specific endpoint + * Used for endpoints like password reset request and email resend that are email-specific + */ + async checkEmailRateLimit( + endpoint: string, + email: string, + limit: number, + windowMs: number, + ): Promise { + const key = RATE_LIMIT_KEYS.EMAIL(endpoint, email); + return this.checkRateLimit(key, limit, windowMs); + } + + /** + * Check rate limit for a token on a specific endpoint + * Used for endpoints like password reset and email verification that are token-specific + */ + async checkTokenRateLimit( + endpoint: string, + token: string, + limit: number, + windowMs: number, + ): Promise { + const key = RATE_LIMIT_KEYS.TOKEN(endpoint, token); + return this.checkRateLimit(key, limit, windowMs); + } + /** * Get rate limit status with headers */ @@ -212,4 +240,4 @@ export class RateLimitService { reset: new Date(userLimit.reset * 1000), }; } -} +} \ No newline at end of file