Add DB-backed rate limiting with guard and decorator
RateLimitService implements fixed-window counters as one atomic
PostgreSQL upsert (race-safe under concurrency, proven by test), with
opportunistic sweeping of expired windows and an explicit reset for
successful-login scenarios. The global RateLimitGuard applies
@RateLimit({scope, limit, windowSeconds}) per client IP and answers
429 with Retry-After; main.ts trusts the single Caddy hop so req.ip
is the real client.
Closes #11
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
36608177f6
commit
31f23c12a2
@ -7,12 +7,14 @@ import { AppConfig } from './config/app-config.service';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
RateLimitModule,
|
||||
UsersModule,
|
||||
LoggerModule.forRootAsync({
|
||||
inject: [AppConfig],
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { apiEnvSchema, parseEnv } from '@dorfteich/shared';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
|
||||
@ -27,8 +28,11 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
// bufferLogs holds early log lines until the pino logger is attached,
|
||||
// so even bootstrap errors come out as structured JSON.
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });
|
||||
app.useLogger(app.get(Logger));
|
||||
// One reverse-proxy hop (Caddy) in front of us: req.ip must reflect the
|
||||
// real client for rate limiting and audit logs.
|
||||
app.set('trust proxy', 1);
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableShutdownHooks();
|
||||
|
||||
|
||||
62
apps/api/src/rate-limit/rate-limit.guard.ts
Normal file
62
apps/api/src/rate-limit/rate-limit.guard.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
|
||||
export interface RateLimitOptions {
|
||||
scope: string;
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
const RATE_LIMIT_KEY = 'rateLimit';
|
||||
|
||||
/**
|
||||
* Per-client-IP rate limit for a route, e.g.
|
||||
* `@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })`.
|
||||
* Account-scoped limits (login backoff) are applied inside services where
|
||||
* the account is known.
|
||||
*/
|
||||
export const RateLimit = (options: RateLimitOptions): MethodDecorator =>
|
||||
SetMetadata(RATE_LIMIT_KEY, options);
|
||||
|
||||
@Injectable()
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly rateLimits: RateLimitService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const options = this.reflector.get<RateLimitOptions | undefined>(
|
||||
RATE_LIMIT_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!options) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
// req.ip honors X-Forwarded-For because main.ts sets `trust proxy`.
|
||||
const ip = request.ip ?? 'unknown';
|
||||
const result = await this.rateLimits.hit(
|
||||
options.scope,
|
||||
`ip:${ip}`,
|
||||
options.limit,
|
||||
options.windowSeconds,
|
||||
);
|
||||
if (result.allowed) return true;
|
||||
|
||||
context
|
||||
.switchToHttp()
|
||||
.getResponse<Response>()
|
||||
.setHeader('Retry-After', String(result.retryAfterSeconds));
|
||||
throw new HttpException('Too many requests', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/rate-limit/rate-limit.module.ts
Normal file
17
apps/api/src/rate-limit/rate-limit.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { RateLimitGuard } from './rate-limit.guard';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
|
||||
/**
|
||||
* Global: the guard runs for every route but only acts where a handler
|
||||
* carries @RateLimit metadata; the service is injectable everywhere for
|
||||
* account-scoped limits.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RateLimitService, { provide: APP_GUARD, useClass: RateLimitGuard }],
|
||||
exports: [RateLimitService],
|
||||
})
|
||||
export class RateLimitModule {}
|
||||
52
apps/api/src/rate-limit/rate-limit.service.db.test.ts
Normal file
52
apps/api/src/rate-limit/rate-limit.service.db.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RateLimitService } from './rate-limit.service';
|
||||
|
||||
describe.skipIf(!hasTestDb)('RateLimitService (database)', () => {
|
||||
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
|
||||
const service = hasTestDb ? new RateLimitService(prisma) : null!;
|
||||
const scope = `test-${uniqueSuffix()}`;
|
||||
|
||||
afterAll(async () => {
|
||||
if (!hasTestDb) return;
|
||||
await prisma.rateLimit.deleteMany({ where: { key: { startsWith: scope } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('allows up to the limit and blocks beyond it', async () => {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
expect((await service.hit(scope, 'ip:1', 3, 60)).allowed).toBe(true);
|
||||
}
|
||||
const blocked = await service.hit(scope, 'ip:1', 3, 60);
|
||||
expect(blocked.allowed).toBe(false);
|
||||
expect(blocked.retryAfterSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('counts concurrent hits without losing any', async () => {
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 20 }, () => service.hit(scope, 'ip:2', 10, 60)),
|
||||
);
|
||||
expect(results.filter((r) => r.allowed)).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('rolls the window over after it expires', async () => {
|
||||
expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(true);
|
||||
expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
expect((await service.hit(scope, 'ip:3', 1, 1)).allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('sweeps counters from long-ended windows', async () => {
|
||||
await prisma.rateLimit.create({
|
||||
data: {
|
||||
key: `${scope}:ip:old`,
|
||||
windowStart: new Date(Date.now() - 48 * 60 * 60 * 1000),
|
||||
count: 5,
|
||||
},
|
||||
});
|
||||
await service.cleanupExpired();
|
||||
expect(await prisma.rateLimit.findUnique({ where: { key: `${scope}:ip:old` } })).toBeNull();
|
||||
});
|
||||
});
|
||||
63
apps/api/src/rate-limit/rate-limit.service.ts
Normal file
63
apps/api/src/rate-limit/rate-limit.service.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-window rate limiting backed by PostgreSQL (ADR 0002: no Redis).
|
||||
* One atomic upsert per hit keeps concurrent requests race-safe; expired
|
||||
* rows are swept opportunistically on writes.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimitService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async hit(
|
||||
scope: string,
|
||||
key: string,
|
||||
limit: number,
|
||||
windowSeconds: number,
|
||||
): Promise<RateLimitResult> {
|
||||
const rowKey = `${scope}:${key}`;
|
||||
const rows = await this.prisma.$queryRaw<{ count: number; window_start: Date }[]>`
|
||||
INSERT INTO rate_limits (key, window_start, count)
|
||||
VALUES (${rowKey}, now(), 1)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
count = CASE
|
||||
WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds})
|
||||
THEN 1 ELSE rate_limits.count + 1 END,
|
||||
window_start = CASE
|
||||
WHEN rate_limits.window_start <= now() - make_interval(secs => ${windowSeconds})
|
||||
THEN now() ELSE rate_limits.window_start END
|
||||
RETURNING count, window_start
|
||||
`;
|
||||
const row = rows[0];
|
||||
if (!row) return { allowed: true, retryAfterSeconds: 0 };
|
||||
|
||||
const elapsedMs = Date.now() - row.window_start.getTime();
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil(windowSeconds - elapsedMs / 1000));
|
||||
|
||||
// Opportunistic sweep (~1% of hits) so the table stays small without
|
||||
// needing a scheduler this early.
|
||||
if (Math.random() < 0.01) void this.cleanupExpired().catch(() => undefined);
|
||||
|
||||
return { allowed: row.count <= limit, retryAfterSeconds };
|
||||
}
|
||||
|
||||
/** Deletes counters whose window ended more than a day ago. */
|
||||
async cleanupExpired(): Promise<number> {
|
||||
const result = await this.prisma.rateLimit.deleteMany({
|
||||
where: { windowStart: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/** Clears one counter — used when an action should reset it (e.g. successful login). */
|
||||
async reset(scope: string, key: string): Promise<void> {
|
||||
await this.prisma.rateLimit.deleteMany({ where: { key: `${scope}:${key}` } });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user