feat(signatures): UI completa do módulo + rate limiting em rotas públicas
Fecha as duas pendências da leva anterior (módulo de Assinatura Eletrônica): tela de backoffice para gerenciar envelopes (criação, envio, cancelamento, checkpoint/finalização, auditoria) e as duas páginas públicas (/assinar/:token, /verificar/:verificationId). Backend: novos endpoints de streaming de PDF (documento congelado e PDF final consolidado, autenticados e via token público) que faltavam para a UI funcionar — o signatário não tinha como ver o próprio documento antes de confirmar leitura. Rate limiting (guard próprio, em memória, chaves por IP e por token) em todas as rotas sem sessão: auth (login/mfa/forgot-password/reset-password/refresh/logout) e todo o fluxo público de assinatura/verificação. Suíte: 77/77 verdes. pnpm turbo run typecheck build: 28/28. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
75
apps/api/src/common/rate-limit/rate-limit.guard.ts
Normal file
75
apps/api/src/common/rate-limit/rate-limit.guard.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, Logger } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import { RATE_LIMIT_KEY, type RateLimitRule } from "./rate-limit.decorator.js";
|
||||
import { RateLimiterService } from "./rate-limiter.service.js";
|
||||
|
||||
/**
|
||||
* Enforces every `@RateLimit(...)` rule declared on a handler (public
|
||||
* routes only — see auth.controller.ts / public-signature.controller.ts /
|
||||
* public-verify.controller.ts). Handlers without the decorator are left
|
||||
* alone (returns true immediately), so this guard is safe to attach at
|
||||
* controller level without affecting authenticated routes.
|
||||
*
|
||||
* All rules on a handler must pass; the first one that doesn't wins the
|
||||
* `Retry-After` value and gets logged. A hit here is a signal worth
|
||||
* operator attention (repeated 429s from one source usually means brute
|
||||
* force or scraping, not an accident), hence `warn`, not `debug`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
private readonly logger = new Logger(RateLimitGuard.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly limiter: RateLimiterService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const rules = this.reflector.get<RateLimitRule[] | undefined>(RATE_LIMIT_KEY, context.getHandler());
|
||||
if (!rules || rules.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
for (const rule of rules) {
|
||||
const keyValue = this.resolveKeyValue(rule, request);
|
||||
const bucketKey = `${rule.name}:${keyValue}`;
|
||||
const verdict = this.limiter.consume(bucketKey, rule.limit, rule.windowMs);
|
||||
|
||||
if (!verdict.allowed) {
|
||||
this.logger.warn(
|
||||
`rate limit exceeded: rule=${rule.name} keyBy=${rule.keyBy} key=${keyValue} ip=${request.ip ?? "unknown"} route=${request.method} ${request.originalUrl}`,
|
||||
);
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
response.set("Retry-After", String(verdict.retryAfterSeconds));
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
message: "Muitas requisições. Tente novamente mais tarde.",
|
||||
retryAfterSeconds: verdict.retryAfterSeconds,
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveKeyValue(rule: RateLimitRule, request: Request): string {
|
||||
switch (rule.keyBy) {
|
||||
case "ip":
|
||||
return request.ip ?? "unknown";
|
||||
case "token":
|
||||
return this.stringParam(request.params.token);
|
||||
case "verificationId":
|
||||
return this.stringParam(request.params.verificationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** @types/express v5's ParamsDictionary types values as `string | string[]` — every route here declares a single `:param`, so this is always a scalar in practice. */
|
||||
private stringParam(value: string | string[] | undefined): string {
|
||||
if (Array.isArray(value)) return value[0] ?? "unknown";
|
||||
return value ?? "unknown";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user