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
13 changes: 8 additions & 5 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -208,4 +211,4 @@ export class AuthController {
const userAgent = request.headers['user-agent'];
return this.authService.resendEmailVerification(data.email, ipAddress, userAgent);
}
}
}
130 changes: 115 additions & 15 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<string>('JWT_SECRET') ?? 'propchain-access-secret';
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -189,15 +194,23 @@ export class AuthService {

// Track IP for re-registration prevention
if (ipAddress) {
const expiryMs =
const expirySeconds =
parseDuration(
this.configService.get<string>('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 {
Expand All @@ -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<boolean> {
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<void> {
// 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);
}
}

/**
Expand Down Expand Up @@ -1374,7 +1409,23 @@ export class AuthService {
return Array.from(new Set(permissions.map((permission) => permission.trim()).filter(Boolean)));
}

async requestPasswordReset(data: RequestPasswordResetDto): Promise<void> {
async requestPasswordReset(data: RequestPasswordResetDto, ipAddress?: string): Promise<void> {
// 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
Expand Down Expand Up @@ -1415,7 +1466,22 @@ export class AuthService {
await this.emailService.sendPasswordResetEmail(user.email, resetToken);
}

async resetPassword(data: ResetPasswordDto): Promise<void> {
async resetPassword(data: ResetPasswordDto, ipAddress?: string): Promise<void> {
// 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 },
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -1681,4 +1781,4 @@ export class AuthService {

this.logger.log(`Verification email resent for user ${user.id}`);
}
}
}
16 changes: 15 additions & 1 deletion src/auth/rate-limit.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ export const ENDPOINT_RATE_LIMITS: Record<string, { windowMs: number; max: numbe
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 resends per hour
},
'POST /auth/password-reset/request': {
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 requests per hour
},
'POST /auth/password-reset/reset': {
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 reset attempts per token
},
'POST /auth/verify-email': {
windowMs: 60 * 60 * 1000, // 1 hour
max: 5, // 5 verification attempts per token
},
'POST /auth/request-password-reset': {
windowMs: 60 * 60 * 1000, // 1 hour
max: 3, // 3 requests per hour
Expand Down Expand Up @@ -152,6 +164,8 @@ export const RATE_LIMIT_KEYS = {
IP: (ip: string) => `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}`,
};

/**
Expand Down Expand Up @@ -181,4 +195,4 @@ export function getEndpointRateLimit(endpoint: string): RateLimitConfig | null {
statusCode: 429,
message: `Too many requests to ${endpoint}. Please try again later.`,
};
}
}
30 changes: 29 additions & 1 deletion src/auth/rate-limit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RateLimitStatus> {
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<RateLimitStatus> {
const key = RATE_LIMIT_KEYS.TOKEN(endpoint, token);
return this.checkRateLimit(key, limit, windowMs);
}

/**
* Get rate limit status with headers
*/
Expand Down Expand Up @@ -212,4 +240,4 @@ export class RateLimitService {
reset: new Date(userLimit.reset * 1000),
};
}
}
}
Loading