diff --git a/backend/.env.example b/backend/.env.example index 09f99ca..4391e10 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,6 +19,17 @@ REDIS_HOST=localhost REDIS_PORT=6379 USE_REDIS_CACHE=true +# ─── Rate Limiting ────────────────────────────────────────────────────────────── +# TTL values are in milliseconds. +THROTTLE_TTL_MS=60000 +THROTTLE_LIMIT=100 +AUTH_LOGIN_THROTTLE_TTL_MS=60000 +AUTH_LOGIN_THROTTLE_LIMIT=10 +AUTH_REGISTER_THROTTLE_TTL_MS=60000 +AUTH_REGISTER_THROTTLE_LIMIT=5 +AUTH_FORGOT_THROTTLE_TTL_MS=60000 +AUTH_FORGOT_THROTTLE_LIMIT=5 + # ─── CORS / Frontend ──────────────────────────────────────────────────────────── # Origin allowed by CORS (frontend URL). Must be a valid URI. ALLOWED_ORIGIN=http://localhost:5173 diff --git a/backend/package.json b/backend/package.json index 72d1c09..674dc7f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,6 +42,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^7.4.2", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@types/bcrypt": "^6.0.0", "axios": "^1.13.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 606e040..c42646a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,8 +1,11 @@ import { Module, DynamicModule } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { CacheModule } from '@nestjs/cache-manager'; import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { CustomThrottlerGuard } from './common/guards/throttler.guard'; import { redisStore } from 'cache-manager-redis-yet'; import { UsersModule } from './modules/users/users.module'; import { AuthModule } from './modules/auth/auth.module'; @@ -39,6 +42,27 @@ if (!isTest) { validationSchema: envValidationSchema, validationOptions: { abortEarly: false }, }), + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + // ConfigService may return a string when the value comes from an env + // file; Number() handles both string and numeric inputs, and + // isFinite guards against NaN / Infinity from invalid entries. + const toInt = (val: string | number | undefined, fallback: number) => { + const n = Number(val); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; + }; + + return [ + { + name: 'default', + ttl: toInt(configService.get('THROTTLE_TTL_MS'), 60_000), + limit: toInt(configService.get('THROTTLE_LIMIT'), 100), + }, + ]; + }, + }), ...conditionalImports, CacheModule.registerAsync({ isGlobal: true, @@ -121,6 +145,12 @@ if (!isTest) { OrgInventoryModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: CustomThrottlerGuard, + }, + ], }) export class AppModule {} diff --git a/backend/src/common/guards/throttler.guard.ts b/backend/src/common/guards/throttler.guard.ts new file mode 100644 index 0000000..5570d4a --- /dev/null +++ b/backend/src/common/guards/throttler.guard.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Request } from 'express'; + +/** + * Extends the default ThrottlerGuard to derive the client IP from the + * IP address resolved by Express. When the app is deployed behind a trusted + * reverse proxy or load balancer, Express populates `req.ips`; otherwise this + * falls back to `req.ip` for direct connections or local development. + * + * Production note: configure proxy trust correctly on the underlying Express + * adapter (for example, `app.set('trust proxy', 1)`) so forwarded addresses + * are only used when supplied by trusted infrastructure. Reading the raw + * X-Forwarded-For header directly is avoided here because it can be spoofed + * by any client that reaches the app without passing through the proxy. + */ +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + const request = req as Request; + // req.ips is populated by Express only when trust proxy is configured; + // it contains the full chain of forwarded IPs with spoofed entries stripped. + // Fall back to req.ip (the direct connection address) when not behind a proxy. + const clientIp = request.ips?.length ? request.ips[0] : request.ip; + return clientIp ?? 'unknown'; + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 32ae89d..da7015c 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -16,6 +16,7 @@ import { ApiBody, ApiBearerAuth, } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { LocalAuthGuard } from './local-auth.guard'; @@ -30,6 +31,37 @@ import { ResetPasswordDto, } from './dto/password-reset.dto'; +// Parse throttle config once at module load time. +// Number() handles numeric strings and NaN from non-numeric input; the +// isFinite guard ensures an invalid env var falls back to the safe default +// rather than silently producing 0 or NaN-driven throttle windows. +const toThrottleInt = (value: string | undefined, fallback: number): number => { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +}; + +const LOGIN_TTL = toThrottleInt( + process.env['AUTH_LOGIN_THROTTLE_TTL_MS'], + 60_000, +); +const LOGIN_LIMIT = toThrottleInt(process.env['AUTH_LOGIN_THROTTLE_LIMIT'], 10); +const REGISTER_TTL = toThrottleInt( + process.env['AUTH_REGISTER_THROTTLE_TTL_MS'], + 60_000, +); +const REGISTER_LIMIT = toThrottleInt( + process.env['AUTH_REGISTER_THROTTLE_LIMIT'], + 5, +); +const FORGOT_TTL = toThrottleInt( + process.env['AUTH_FORGOT_THROTTLE_TTL_MS'], + 60_000, +); +const FORGOT_LIMIT = toThrottleInt( + process.env['AUTH_FORGOT_THROTTLE_LIMIT'], + 5, +); + @ApiTags('auth') @Controller('auth') export class AuthController { @@ -60,6 +92,7 @@ export class AuthController { }) @ApiResponse({ status: 200, description: 'Successfully logged in' }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) + @Throttle({ default: { ttl: LOGIN_TTL, limit: LOGIN_LIMIT } }) @UseGuards(LocalAuthGuard) @HttpCode(HttpStatus.OK) @Post('login') @@ -85,7 +118,7 @@ export class AuthController { @ApiOperation({ summary: 'Register new user' }) @ApiResponse({ status: 201, description: 'User successfully registered' }) @ApiResponse({ status: 400, description: 'Invalid input data' }) - // Registration Route: No guards required + @Throttle({ default: { ttl: REGISTER_TTL, limit: REGISTER_LIMIT } }) @Post('register') async register(@Body() userDto: UserDto) { return this.authService.register(userDto); @@ -148,6 +181,7 @@ export class AuthController { description: 'If an account with that email exists, a password reset link has been sent', }) + @Throttle({ default: { ttl: FORGOT_TTL, limit: FORGOT_LIMIT } }) @HttpCode(HttpStatus.OK) @Post('forgot-password') async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) { diff --git a/backend/test/auth-rate-limit.e2e-spec.ts b/backend/test/auth-rate-limit.e2e-spec.ts new file mode 100644 index 0000000..a4898ab --- /dev/null +++ b/backend/test/auth-rate-limit.e2e-spec.ts @@ -0,0 +1,96 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import { AppModule } from '../src/app.module'; +import { DataSource } from 'typeorm'; +import { seedSystemUser } from './helpers/seed-system-user'; + +/** + * Exercises the rate-limiting behaviour on auth endpoints. + * + * The default login limit is 10 requests per TTL window. This suite makes + * (limit + 1) requests with invalid credentials so it can assert a 429 + * response without needing real user credentials. Because each test file + * spins up its own application instance the throttle state is isolated and + * will not bleed into other e2e suites. + */ + +// Mirror the same parsing logic used by auth.controller.ts so the test stays +// in sync with production behaviour (floats are floored, non-finite values +// fall back to the default, matching toThrottleInt in the controller). +const toThrottleInt = (value: string | undefined, fallback: number): number => { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +}; + +describe('Auth - Rate Limiting (e2e)', () => { + let app: INestApplication; + + // LOGIN_LIMIT and LOGIN_TTL_MS must match the AUTH_LOGIN_THROTTLE_* defaults + // (10 requests / 60 s) or the env vars set in the test environment. Using + // toThrottleInt() ensures we mirror the exact same rounding/validation as the + // production controller, so a misconfigured env value (e.g. 'Infinity') will + // produce the same safe fallback in both places rather than hanging the loop. + const loginLimit = toThrottleInt( + process.env['AUTH_LOGIN_THROTTLE_LIMIT'], + 10, + ); + const loginTtlMs = toThrottleInt( + process.env['AUTH_LOGIN_THROTTLE_TTL_MS'], + 60_000, + ); + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.use(cookieParser()); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + + const dataSource = moduleFixture.get(DataSource); + await seedSystemUser(dataSource); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should return 429 after exceeding the login rate limit', async () => { + const payload = { username: '__rate_limit_test__', password: 'x' }; + + // Exhaust the limit — each request returns 401 (wrong creds) but still + // counts against the throttle bucket. + for (let i = 0; i < loginLimit; i++) { + await request(app.getHttpServer()).post('/auth/login').send(payload); + } + + // The next request must be rejected by the throttler before reaching auth. + const response = await request(app.getHttpServer()) + .post('/auth/login') + .send(payload); + + expect(response.status).toBe(429); + + // Throttler must include a Retry-After header so clients know when to retry. + expect(response.headers['retry-after']).toBeDefined(); + const retryAfter = Number(response.headers['retry-after']); + expect(Number.isInteger(retryAfter)).toBe(true); + expect(retryAfter).toBeGreaterThan(0); + + // Retry-After must not exceed the configured TTL window. A value larger + // than the window indicates a misconfiguration or unit mismatch (e.g. ms + // instead of seconds) in the throttler setup. + const loginTtlSeconds = Math.ceil(loginTtlMs / 1000); + expect(retryAfter).toBeLessThanOrEqual(loginTtlSeconds); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c4aae6..353f3ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@nestjs/swagger': specifier: ^7.4.2 version: 7.4.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^10.0.2 version: 10.0.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(babel-plugin-macros@3.1.0)(pg@8.16.3)(redis@5.9.0)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3))) @@ -1079,6 +1082,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@nestjs/typeorm@10.0.2': resolution: {integrity: sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==} peerDependencies: @@ -5466,6 +5476,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20) + '@nestjs/throttler@6.5.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.27(babel-plugin-macros@3.1.0)(pg@8.16.3)(redis@5.9.0)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@20.19.25)(typescript@5.9.3)))': dependencies: '@nestjs/common': 10.4.20(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)