Skip to content

Commit dceee18

Browse files
authored
Feat/rate (#1000)
* implemnetd the rate limiter * implemnetd the rate limiter * implemnetd the rate limiter * implemnetd the rate limiter * implemnetd the rate limiter * implemnetd the rate limiter * implemnetd the replace in-memeory * implemnetd the replace in-memeory
1 parent f89ae80 commit dceee18

4 files changed

Lines changed: 167 additions & 22 deletions

File tree

src/auth/auth.controller.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
2020
import { ApiKeyAuthGuard } from './guards/api-key-auth.guard';
2121
import { GoogleAuthGuard } from './guards/google-auth.guard';
2222
import { RolesGuard } from './guards/roles.guard';
23+
import { RateLimitGuard } from './guards/rate-limit.guard';
2324
import { CurrentUser } from './decorators/current-user.decorator';
2425
import { Roles } from './decorators/roles.decorator';
2526
import { AuthUserPayload } from './types/auth-user.type';
@@ -174,13 +175,15 @@ export class AuthController {
174175
}
175176

176177
@Post('password-reset/request')
177-
requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto) {
178-
return this.authService.requestPasswordReset(requestPasswordResetDto);
178+
requestPasswordReset(@Body() requestPasswordResetDto: RequestPasswordResetDto, @Req() request: Request) {
179+
const ipAddress = request.ip || request.socket.remoteAddress;
180+
return this.authService.requestPasswordReset(requestPasswordResetDto, ipAddress);
179181
}
180182

181183
@Post('password-reset/reset')
182-
resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
183-
return this.authService.resetPassword(resetPasswordDto);
184+
resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Req() request: Request) {
185+
const ipAddress = request.ip || request.socket.remoteAddress;
186+
return this.authService.resetPassword(resetPasswordDto, ipAddress);
184187
}
185188

186189
@UseGuards(JwtAuthGuard, RolesGuard)
@@ -208,4 +211,4 @@ export class AuthController {
208211
const userAgent = request.headers['user-agent'];
209212
return this.authService.resendEmailVerification(data.email, ipAddress, userAgent);
210213
}
211-
}
214+
}

src/auth/auth.service.ts

Lines changed: 115 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,11 @@ import { AuthUserPayload } from './types/auth-user.type';
4747
import { GoogleProfile } from './strategies/google.strategy';
4848

4949
import { LoginRateLimitService } from './login-rate-limit.service';
50+
import { RateLimitService } from './rate-limit.service';
5051
import { UserRole } from '../types/prisma.types';
5152
import { FraudService } from '../fraud/fraud.service';
53+
import { ENDPOINT_RATE_LIMITS } from './rate-limit.config';
54+
import { CacheService } from '../cache/cache.service';
5255
import { ApiKeyAnalyticsService } from './api-key-analytics.service';
5356

5457
type JwtPayload = {
@@ -88,8 +91,10 @@ export class AuthService {
8891
private readonly sessionsService: SessionsService,
8992
private readonly configService: ConfigService,
9093
private readonly emailService: EmailService,
91-
private readonly rateLimitService: LoginRateLimitService,
94+
private readonly loginRateLimitService: LoginRateLimitService,
95+
private readonly rateLimitService: RateLimitService,
9296
private readonly fraudService: FraudService,
97+
private readonly cacheService: CacheService,
9398
@Optional() private readonly apiKeyAnalyticsService?: ApiKeyAnalyticsService,
9499
) {
95100
this.jwtSecret = this.configService.get<string>('JWT_SECRET') ?? 'propchain-access-secret';
@@ -122,7 +127,7 @@ export class AuthService {
122127
async register(data: RegisterDto, ipAddress?: string) {
123128
// Block re-registration from same IP until prior email is verified
124129
if (ipAddress) {
125-
const allowed = this.canRegisterFromIp(ipAddress);
130+
const allowed = await this.canRegisterFromIp(ipAddress);
126131
if (!allowed) {
127132
throw new BadRequestException(
128133
'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 {
189194

190195
// Track IP for re-registration prevention
191196
if (ipAddress) {
192-
const expiryMs =
197+
const expirySeconds =
193198
parseDuration(
194199
this.configService.get<string>('EMAIL_VERIFICATION_EXPIRES_IN') ?? '24h',
195200
24 * 60 * 60,
196-
) * 1000;
197-
this.registrationIpMap.set(ipAddress, {
201+
);
202+
const expiryMs = expirySeconds * 1000;
203+
const cacheKey = `registration:ip:${ipAddress}`;
204+
const entry = {
198205
email: user.email,
199206
expiresAt: new Date(Date.now() + expiryMs),
200-
});
207+
};
208+
209+
// Store in Redis with TTL
210+
await this.cacheService.set(cacheKey, entry, expirySeconds);
211+
212+
// Also keep in in-memory map for backward compatibility/fallback
213+
this.registrationIpMap.set(ipAddress, entry);
201214
}
202215

203216
return {
@@ -207,23 +220,45 @@ export class AuthService {
207220
};
208221
}
209222

210-
private canRegisterFromIp(ipAddress: string): boolean {
211-
const entry = this.registrationIpMap.get(ipAddress);
212-
if (!entry) return true;
213-
if (Date.now() > entry.expiresAt.getTime()) {
223+
private async canRegisterFromIp(ipAddress: string): Promise<boolean> {
224+
const cacheKey = `registration:ip:${ipAddress}`;
225+
const entry = await this.cacheService.get<{ email: string; expiresAt: Date }>(cacheKey);
226+
227+
// Check cache first
228+
if (entry) {
229+
if (Date.now() > entry.expiresAt.getTime()) {
230+
await this.cacheService.del(cacheKey);
231+
return true;
232+
}
233+
return false;
234+
}
235+
236+
// Fallback to in-memory map for backward compatibility
237+
const inMemoryEntry = this.registrationIpMap.get(ipAddress);
238+
if (!inMemoryEntry) return true;
239+
if (Date.now() > inMemoryEntry.expiresAt.getTime()) {
214240
this.registrationIpMap.delete(ipAddress);
215241
return true;
216242
}
217243
return false;
218244
}
219245

220-
private cleanupIpForEmail(email: string): void {
246+
private async cleanupIpForEmail(email: string): Promise<void> {
247+
// First check in-memory map to find the IP for this email
248+
let ipToCleanup: string | null = null;
221249
for (const [ip, entry] of this.registrationIpMap.entries()) {
222250
if (entry.email === email) {
223251
this.registrationIpMap.delete(ip);
224-
return;
252+
ipToCleanup = ip;
253+
break;
225254
}
226255
}
256+
257+
// Also delete from Redis if we found the IP, or scan for it
258+
if (ipToCleanup) {
259+
const cacheKey = `registration:ip:${ipToCleanup}`;
260+
await this.cacheService.del(cacheKey);
261+
}
227262
}
228263

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

1377-
async requestPasswordReset(data: RequestPasswordResetDto): Promise<void> {
1412+
async requestPasswordReset(data: RequestPasswordResetDto, ipAddress?: string): Promise<void> {
1413+
// Apply rate limiting: max 3 requests per email per hour
1414+
const emailRateLimit = await this.rateLimitService.checkEmailRateLimit(
1415+
'POST /auth/password-reset/request',
1416+
data.email,
1417+
3,
1418+
60 * 60 * 1000, // 1 hour
1419+
);
1420+
1421+
if (emailRateLimit.isExceeded) {
1422+
this.logger.warn(
1423+
`Password reset request rate limit exceeded for email: ${redactEmail(data.email)} (IP: ${ipAddress || 'unknown'})`,
1424+
);
1425+
// Don't reveal rate limit was exceeded to prevent user enumeration
1426+
return;
1427+
}
1428+
13781429
const user = await this.usersService.findByEmail(data.email);
13791430
if (!user) {
13801431
// Don't reveal if email exists or not for security
@@ -1415,7 +1466,22 @@ export class AuthService {
14151466
await this.emailService.sendPasswordResetEmail(user.email, resetToken);
14161467
}
14171468

1418-
async resetPassword(data: ResetPasswordDto): Promise<void> {
1469+
async resetPassword(data: ResetPasswordDto, ipAddress?: string): Promise<void> {
1470+
// Apply rate limiting: max 5 attempts per token per hour
1471+
const tokenRateLimit = await this.rateLimitService.checkTokenRateLimit(
1472+
'POST /auth/password-reset/reset',
1473+
data.token,
1474+
5,
1475+
60 * 60 * 1000, // 1 hour
1476+
);
1477+
1478+
if (tokenRateLimit.isExceeded) {
1479+
this.logger.warn(
1480+
`Password reset token rate limit exceeded. Token: ${data.token.substring(0, 8)}... (IP: ${ipAddress || 'unknown'})`,
1481+
);
1482+
throw new BadRequestException('Too many attempts. Please try again later.');
1483+
}
1484+
14191485
const tokenHash = createSha256(data.token);
14201486
const resetToken = await this.prisma.passwordResetToken.findUnique({
14211487
where: { token: tokenHash },
@@ -1585,6 +1651,21 @@ export class AuthService {
15851651
}
15861652

15871653
async verifyInitialEmail(token: string, ipAddress?: string, userAgent?: string) {
1654+
// Apply rate limiting: max 5 attempts per token per hour
1655+
const tokenRateLimit = await this.rateLimitService.checkTokenRateLimit(
1656+
'POST /auth/verify-email',
1657+
token,
1658+
5,
1659+
60 * 60 * 1000, // 1 hour
1660+
);
1661+
1662+
if (tokenRateLimit.isExceeded) {
1663+
this.logger.warn(
1664+
`Email verification token rate limit exceeded. Token: ${token.substring(0, 8)}... (IP: ${ipAddress || 'unknown'})`,
1665+
);
1666+
throw new BadRequestException('Too many attempts. Please try again later.');
1667+
}
1668+
15881669
// Find user by verification token
15891670
const user = await this.prisma.user.findFirst({
15901671
where: {
@@ -1624,6 +1705,9 @@ export class AuthService {
16241705
},
16251706
});
16261707

1708+
// Clean up IP tracking since email is now verified
1709+
await this.cleanupIpForEmail(user.email);
1710+
16271711
// Issue token pair
16281712
const tokens = await this.issueTokenPair(updatedUser, undefined, ipAddress, userAgent);
16291713

@@ -1635,6 +1719,22 @@ export class AuthService {
16351719
}
16361720

16371721
async resendEmailVerification(email: string, ipAddress?: string, userAgent?: string) {
1722+
// Apply rate limiting: max 3 requests per email per hour
1723+
const emailRateLimit = await this.rateLimitService.checkEmailRateLimit(
1724+
'POST /auth/email/resend',
1725+
email,
1726+
3,
1727+
60 * 60 * 1000, // 1 hour
1728+
);
1729+
1730+
if (emailRateLimit.isExceeded) {
1731+
this.logger.warn(
1732+
`Email resend rate limit exceeded for email: ${redactEmail(email)} (IP: ${ipAddress || 'unknown'})`,
1733+
);
1734+
// Don't reveal rate limit was exceeded to prevent user enumeration
1735+
return;
1736+
}
1737+
16381738
const user = await this.usersService.findByEmail(email);
16391739
if (!user) {
16401740
return;
@@ -1681,4 +1781,4 @@ export class AuthService {
16811781

16821782
this.logger.log(`Verification email resent for user ${user.id}`);
16831783
}
1684-
}
1784+
}

src/auth/rate-limit.config.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ export const ENDPOINT_RATE_LIMITS: Record<string, { windowMs: number; max: numbe
5151
windowMs: 60 * 60 * 1000, // 1 hour
5252
max: 3, // 3 resends per hour
5353
},
54+
'POST /auth/password-reset/request': {
55+
windowMs: 60 * 60 * 1000, // 1 hour
56+
max: 3, // 3 requests per hour
57+
},
58+
'POST /auth/password-reset/reset': {
59+
windowMs: 60 * 60 * 1000, // 1 hour
60+
max: 5, // 5 reset attempts per token
61+
},
62+
'POST /auth/verify-email': {
63+
windowMs: 60 * 60 * 1000, // 1 hour
64+
max: 5, // 5 verification attempts per token
65+
},
5466
'POST /auth/request-password-reset': {
5567
windowMs: 60 * 60 * 1000, // 1 hour
5668
max: 3, // 3 requests per hour
@@ -152,6 +164,8 @@ export const RATE_LIMIT_KEYS = {
152164
IP: (ip: string) => `rate-limit:ip:${ip}`,
153165
USER_IP: (userId: string, ip: string) => `rate-limit:user-ip:${userId}:${ip}`,
154166
API_KEY: (apiKey: string) => `rate-limit:api-key:${apiKey}`,
167+
EMAIL: (endpoint: string, email: string) => `rate-limit:email:${endpoint}:${email.toLowerCase()}`,
168+
TOKEN: (endpoint: string, token: string) => `rate-limit:token:${endpoint}:${token}`,
155169
};
156170

157171
/**
@@ -181,4 +195,4 @@ export function getEndpointRateLimit(endpoint: string): RateLimitConfig | null {
181195
statusCode: 429,
182196
message: `Too many requests to ${endpoint}. Please try again later.`,
183197
};
184-
}
198+
}

src/auth/rate-limit.service.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,34 @@ export class RateLimitService {
181181
return this.checkRateLimit(key, limit, windowMs);
182182
}
183183

184+
/**
185+
* Check rate limit for an email address on a specific endpoint
186+
* Used for endpoints like password reset request and email resend that are email-specific
187+
*/
188+
async checkEmailRateLimit(
189+
endpoint: string,
190+
email: string,
191+
limit: number,
192+
windowMs: number,
193+
): Promise<RateLimitStatus> {
194+
const key = RATE_LIMIT_KEYS.EMAIL(endpoint, email);
195+
return this.checkRateLimit(key, limit, windowMs);
196+
}
197+
198+
/**
199+
* Check rate limit for a token on a specific endpoint
200+
* Used for endpoints like password reset and email verification that are token-specific
201+
*/
202+
async checkTokenRateLimit(
203+
endpoint: string,
204+
token: string,
205+
limit: number,
206+
windowMs: number,
207+
): Promise<RateLimitStatus> {
208+
const key = RATE_LIMIT_KEYS.TOKEN(endpoint, token);
209+
return this.checkRateLimit(key, limit, windowMs);
210+
}
211+
184212
/**
185213
* Get rate limit status with headers
186214
*/
@@ -212,4 +240,4 @@ export class RateLimitService {
212240
reset: new Date(userLimit.reset * 1000),
213241
};
214242
}
215-
}
243+
}

0 commit comments

Comments
 (0)