Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions back-end/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ To setup the frontend application, Follow the complete setup process below..
python -m setuptools --version
```

- **Redis 7.0+**
- Required for IP-based rate limiting on sensitive endpoints. The `IpResetPasswordUniqueEmailGuard` uses the `EXPIRE ... NX` command introduced in Redis 7.0. Earlier versions will silently skip the expiry, causing rate limit keys to persist indefinitely.
Comment thread
jbair06 marked this conversation as resolved.
Outdated

- **Docker Desktop with Kubernetes enabled**
- Enable Kubernetes: Docker Desktop → Settings → Kubernetes → Enable Kubernetes → Apply & Restart.

Expand Down
7 changes: 5 additions & 2 deletions back-end/apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
EmailThrottlerGuard,
extractJwtAuth,
extractJwtOtp,
IpLoginThrottlerGuard,
IpResetPasswordThrottlerGuard,
IpResetPasswordUniqueEmailGuard,
JwtAuthGuard,
JwtBlackListAuthGuard,
JwtBlackListOtpGuard,
Expand Down Expand Up @@ -80,7 +83,7 @@ export class AuthController {
})
@Post('/login')
@HttpCode(200)
@UseGuards(LocalAuthGuard, EmailThrottlerGuard)
@UseGuards(IpLoginThrottlerGuard, LocalAuthGuard, EmailThrottlerGuard)
Comment thread
jbair06 marked this conversation as resolved.
Outdated
@Serialize(LoginResponseDto)
async login(@GetUser() user: User) {
const accessToken = await this.authService.login(user);
Expand Down Expand Up @@ -133,7 +136,7 @@ export class AuthController {
})
@Post('/reset-password')
@HttpCode(200)
@UseGuards(EmailThrottlerGuard)
@UseGuards(IpResetPasswordUniqueEmailGuard, IpResetPasswordThrottlerGuard, EmailThrottlerGuard)
async createOtp(@Body() { email }: OtpLocalDto) {
return this.authService.createOtp(email);
}
Expand Down
3 changes: 3 additions & 0 deletions back-end/apps/api/src/guards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ export * from './admin.guard';
export * from './email-throttler.guard';
export * from './frontend-version.guard';
export * from './has-key.guard';
export * from './ip-login-throttler.guard';
export * from './ip-reset-password-throttler.guard';
export * from './ip-reset-password-unique-email.guard';
export * from './ip-throttler.guard';
export * from './jwt-auth.guard';
export * from './jwt-blacklist.guard';
Expand Down
42 changes: 42 additions & 0 deletions back-end/apps/api/src/guards/ip-login-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { extractClientIp } from '@app/common';

@Injectable()
export class IpLoginThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(ConfigService) configService: ConfigService,
@Inject(ThrottlerStorage) storageService: ThrottlerStorage,
reflector: Reflector,
) {
super(
{
throttlers: [
{
name: 'login-ip-minute',
ttl: seconds(60),
limit: Number(configService.get('LOGIN_IP_MINUTE_LIMIT', 10)),
},
{
name: 'login-ip-ten-second',
ttl: seconds(10),
limit: Number(configService.get('LOGIN_IP_TEN_SECOND_LIMIT', 2)),
},
],
},
storageService,
reflector,
);
}

protected getTracker(req: Record<string, any>): Promise<string> {
const clientIp = extractClientIp(req);
if (!clientIp) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);
}
return Promise.resolve(clientIp);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { extractClientIp } from '@app/common';

@Injectable()
export class IpResetPasswordThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(ConfigService) configService: ConfigService,
@Inject(ThrottlerStorage) storageService: ThrottlerStorage,
reflector: Reflector,
) {
super(
{
throttlers: [
{
name: 'reset-ip-minute',
ttl: seconds(60),
limit: Number(configService.get('RESET_IP_MINUTE_LIMIT', 5)),
},
{
name: 'reset-ip-ten-second',
ttl: seconds(10),
limit: Number(configService.get('RESET_IP_TEN_SECOND_LIMIT', 1)),
},
],
},
storageService,
reflector,
);
}

protected getTracker(req: Record<string, any>): Promise<string> {
const clientIp = extractClientIp(req);
if (!clientIp) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);
}
return Promise.resolve(clientIp);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { extractClientIp } from '@app/common';
import Redis from 'ioredis';
Comment thread
Copilot marked this conversation as resolved.
Outdated

const TEN_MINUTES_SECONDS = 600;

@Injectable()
export class IpResetPasswordUniqueEmailGuard implements CanActivate {
private readonly redis: Redis;
private readonly limit: number;

constructor(@Inject(ConfigService) configService: ConfigService) {
this.redis = new Redis(configService.getOrThrow('REDIS_URL'));
this.limit = Number(configService.get('RESET_IP_UNIQUE_EMAIL_LIMIT', 3));
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();

const ip = extractClientIp(req);
if (!ip) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
}

const email: string = req.body?.email;
if (!email) return true; // let EmailThrottlerGuard handle the missing email case

const key = `reset:ip-unique-email:${ip}`;

// Check current unique email count before adding
const countBefore = await this.redis.scard(key);
if (countBefore >= this.limit) {
throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
}

// Add email to the set and set TTL on first entry.
// The 'NX' option sets the expiry only if one is not already present, preserving
// the fixed 10-minute window. Requires Redis 7.0+.
await this.redis.sadd(key, email);
await this.redis.expire(key, TEN_MINUTES_SECONDS, 'NX');

// Re-check after adding to handle concurrent requests
const countAfter = await this.redis.scard(key);
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
if (countAfter > this.limit) {
throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);
}

return true;
}
}
3 changes: 2 additions & 1 deletion back-end/apps/api/src/guards/ip-throttler.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { extractClientIp } from '@app/common';

@Injectable()
export class IpThrottlerGuard extends ThrottlerGuard {
Expand Down Expand Up @@ -32,7 +33,7 @@ export class IpThrottlerGuard extends ThrottlerGuard {
}

protected getTracker(req: Record<string, any>): Promise<string> {
const clientIp = req.headers['x-forwarded-for'] || req.ip;
const clientIp = extractClientIp(req);
if (!clientIp) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);
}
Expand Down
27 changes: 27 additions & 0 deletions back-end/libs/common/src/utils/extractClientIp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Extracts the real client IP from a request.
*
* Resolution order:
* 1. CF-Connecting-IP — set by Cloudflare; not client-controllable.
* 2. X-Forwarded-For — first (leftmost) value, which is the original client
* IP appended by the first proxy in the chain.
* 3. req.ip — Express fallback (direct connection address).
*
* Note: X-Forwarded-For is client-controllable when no trusted proxy is
* configured. CF-Connecting-IP takes precedence precisely because Cloudflare
* strips and rewrites it, making it safe to trust.
*/
export function extractClientIp(req: Record<string, any>): string | undefined {
Comment thread
jbair06 marked this conversation as resolved.
Outdated
const cf = req.headers?.['cf-connecting-ip'];
if (cf && typeof cf === 'string' && cf.trim()) {
return cf.trim();
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
}

const forwarded = req.headers?.['x-forwarded-for'];
if (forwarded && typeof forwarded === 'string') {
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
const first = forwarded.split(',')[0].trim();
if (first) return first;
}

return req.ip;
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
}
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions back-end/libs/common/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './buffer';
export * from './extractClientIp';
export * from './sdk';
export * from './mirrorNode';
export * from './typeORM';
Expand Down
Loading