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:
2026-09-17 21:51:18 -03:00
parent 20b114a0bf
commit 9c14671e19
30 changed files with 2946 additions and 13 deletions

View 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";
}
}