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:
@@ -12,6 +12,16 @@ import { ResetPasswordDto } from "./dto/reset-password.dto.js";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { CurrentUser } from "./decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
|
||||
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
|
||||
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
|
||||
import {
|
||||
AUTH_FORGOT_PASSWORD_RULE,
|
||||
AUTH_LOGIN_RULE,
|
||||
AUTH_MFA_CHALLENGE_RULE,
|
||||
AUTH_LOGOUT_RULE,
|
||||
AUTH_REFRESH_RULE,
|
||||
AUTH_RESET_PASSWORD_RULE,
|
||||
} from "../common/rate-limit/rate-limit-rules.js";
|
||||
|
||||
function requestContext(req: Request) {
|
||||
return {
|
||||
@@ -22,6 +32,7 @@ function requestContext(req: Request) {
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("v1/auth")
|
||||
@UseGuards(RateLimitGuard)
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
@@ -30,12 +41,14 @@ export class AuthController {
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_LOGIN_RULE)
|
||||
async login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("mfa/challenge")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_MFA_CHALLENGE_RULE)
|
||||
async mfaChallenge(@Body() dto: MfaChallengeDto, @Req() req: Request) {
|
||||
return this.auth.completeMfaLogin(dto.mfaChallenge, dto.code, requestContext(req));
|
||||
}
|
||||
@@ -65,6 +78,7 @@ export class AuthController {
|
||||
|
||||
@Post("forgot-password")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_FORGOT_PASSWORD_RULE)
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) {
|
||||
await this.auth.forgotPassword(dto.email, requestContext(req));
|
||||
// Always the same response regardless of whether the email is
|
||||
@@ -74,18 +88,21 @@ export class AuthController {
|
||||
|
||||
@Post("reset-password")
|
||||
@HttpCode(204)
|
||||
@RateLimit(AUTH_RESET_PASSWORD_RULE)
|
||||
async resetPassword(@Body() dto: ResetPasswordDto, @Req() req: Request) {
|
||||
await this.auth.resetPassword(dto.token, dto.newPassword, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
@RateLimit(AUTH_REFRESH_RULE)
|
||||
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
return this.auth.refresh(dto.refreshToken, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
@RateLimit(AUTH_LOGOUT_RULE)
|
||||
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
await this.auth.logout(dto.refreshToken, requestContext(req));
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "./permission.guard.js";
|
||||
import { SuperAdminGuard } from "./super-admin.guard.js";
|
||||
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
|
||||
import { RateLimitModule } from "../common/rate-limit/rate-limit.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [RateLimitModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
||||
120
apps/api/src/common/rate-limit/rate-limit-rules.ts
Normal file
120
apps/api/src/common/rate-limit/rate-limit-rules.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { RateLimitRule } from "./rate-limit.decorator.js";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/**
|
||||
* Concrete limits for every public (no-session) route in this API.
|
||||
* Numbers are deliberately round, chosen for two constraints at once:
|
||||
* - generous enough that this project's own real-app integration test
|
||||
* suite (apps/api/test/*.spec.ts — real login/refresh/signature flows,
|
||||
* all from the same loopback IP) never trips them by accident;
|
||||
* - tight enough to turn an unthrottled endpoint (previously: unlimited
|
||||
* requests/second) into a hard, auditable ceiling per IP or per link.
|
||||
* None of these claim to fully stop a determined, IP-rotating attacker —
|
||||
* that's a documented residual risk in docs/security/threat-model.md,
|
||||
* not a gap this change pretends to close.
|
||||
*/
|
||||
|
||||
// --- apps/api/src/auth/auth.controller.ts -----------------------------
|
||||
|
||||
/** Credential guessing target — argon2 already raises the per-guess cost; this caps guess *rate*. */
|
||||
export const AUTH_LOGIN_RULE: RateLimitRule = { name: "auth-login", keyBy: "ip", limit: 10, windowMs: MINUTE };
|
||||
|
||||
/** Same order of magnitude as login: this is the second half of an MFA login, equally sensitive. */
|
||||
export const AUTH_MFA_CHALLENGE_RULE: RateLimitRule = {
|
||||
name: "auth-mfa-challenge",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Doesn't leak account existence (anti-enumeration response either way), but still caps inbox-spam/abuse potential. */
|
||||
export const AUTH_FORGOT_PASSWORD_RULE: RateLimitRule = {
|
||||
name: "auth-forgot-password",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Token is 256-bit random (auth.service.ts) — not brute-forceable; this is generic abuse/DoS hygiene. */
|
||||
export const AUTH_RESET_PASSWORD_RULE: RateLimitRule = {
|
||||
name: "auth-reset-password",
|
||||
keyBy: "ip",
|
||||
limit: 10,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/** Refresh tokens are 256-bit random too; higher ceiling because legitimate multi-tab/background refresh is frequent by design. */
|
||||
export const AUTH_REFRESH_RULE: RateLimitRule = { name: "auth-refresh", keyBy: "ip", limit: 30, windowMs: MINUTE };
|
||||
|
||||
/**
|
||||
* Not in the task's original enumerated list, but POST /v1/auth/logout has
|
||||
* no JwtAuthGuard either (auth.controller.ts) — genuinely public. No
|
||||
* guessing target (needs an already-known refresh token), so this is
|
||||
* generic abuse/DoS hygiene only, same ceiling as refresh.
|
||||
*/
|
||||
export const AUTH_LOGOUT_RULE: RateLimitRule = { name: "auth-logout", keyBy: "ip", limit: 30, windowMs: MINUTE };
|
||||
|
||||
// --- apps/api/src/signatures/public-signature.controller.ts -----------
|
||||
|
||||
/**
|
||||
* Shared across every /v1/public-signature/:token/* handler, keyed by IP —
|
||||
* catches one source scanning/hammering many DIFFERENT tokens (link
|
||||
* enumeration or scraping), which a per-token limit alone can't see.
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_IP_RULE: RateLimitRule = {
|
||||
name: "pubsig-ip",
|
||||
keyBy: "ip",
|
||||
limit: 60,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-token ceiling for the ordinary read/ack steps of the flow — a real
|
||||
* signer hits each of these once or twice (reload, double-click); this
|
||||
* only bites automated hammering of one leaked link, from any number of
|
||||
* source IPs (that's the point of keying by token, not IP, here).
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_TOKEN_ACTION_RULE: RateLimitRule = {
|
||||
name: "pubsig-token-action",
|
||||
keyBy: "token",
|
||||
limit: 20,
|
||||
windowMs: 5 * MINUTE,
|
||||
};
|
||||
|
||||
/** Sends an email — tighter, since abuse here has a real cost (spamming the signer's inbox), not just a wasted read. */
|
||||
export const PUBLIC_SIGNATURE_OTP_REQUEST_RULE: RateLimitRule = {
|
||||
name: "pubsig-otp-request",
|
||||
keyBy: "token",
|
||||
limit: 5,
|
||||
windowMs: 15 * MINUTE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Complementary to the DB-persisted per-challenge lockout already in
|
||||
* public-signature.service.ts::verifyOtp (blocks after N wrong codes,
|
||||
* survives across source IPs) — this caps raw request *rate* against the
|
||||
* endpoint itself, independent of whether attempts are right or wrong.
|
||||
*/
|
||||
export const PUBLIC_SIGNATURE_OTP_VERIFY_RULE: RateLimitRule = {
|
||||
name: "pubsig-otp-verify",
|
||||
keyBy: "token",
|
||||
limit: 15,
|
||||
windowMs: 5 * MINUTE,
|
||||
};
|
||||
|
||||
// --- apps/api/src/signatures/public-verify.controller.ts --------------
|
||||
|
||||
export const PUBLIC_VERIFY_IP_RULE: RateLimitRule = {
|
||||
name: "pubverify-ip",
|
||||
keyBy: "ip",
|
||||
limit: 60,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
|
||||
export const PUBLIC_VERIFY_ID_RULE: RateLimitRule = {
|
||||
name: "pubverify-id",
|
||||
keyBy: "verificationId",
|
||||
limit: 30,
|
||||
windowMs: MINUTE,
|
||||
};
|
||||
16
apps/api/src/common/rate-limit/rate-limit.decorator.ts
Normal file
16
apps/api/src/common/rate-limit/rate-limit.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export type RateLimitKeyBy = "ip" | "token" | "verificationId";
|
||||
|
||||
export interface RateLimitRule {
|
||||
/** Bucket namespace — must be unique per logical limit, shared across handlers on purpose (e.g. one IP-wide budget for a whole controller). */
|
||||
name: string;
|
||||
keyBy: RateLimitKeyBy;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export const RATE_LIMIT_KEY = "rateLimit";
|
||||
|
||||
/** One or more independent limits checked for a route — ALL must pass. */
|
||||
export const RateLimit = (...rules: RateLimitRule[]) => SetMetadata(RATE_LIMIT_KEY, rules);
|
||||
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";
|
||||
}
|
||||
}
|
||||
16
apps/api/src/common/rate-limit/rate-limit.module.ts
Normal file
16
apps/api/src/common/rate-limit/rate-limit.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RateLimiterService } from "./rate-limiter.service.js";
|
||||
import { RateLimitGuard } from "./rate-limit.guard.js";
|
||||
|
||||
/**
|
||||
* Imported by every module that has public (no-session) routes. Nest
|
||||
* dedupes module imports within one application graph, so AuthModule and
|
||||
* SignaturesModule importing this both resolve to the SAME
|
||||
* RateLimiterService instance — one shared in-memory store, bucket names
|
||||
* are what keep routes from colliding (see rate-limit.decorator.ts).
|
||||
*/
|
||||
@Module({
|
||||
providers: [RateLimiterService, RateLimitGuard],
|
||||
exports: [RateLimiterService, RateLimitGuard],
|
||||
})
|
||||
export class RateLimitModule {}
|
||||
55
apps/api/src/common/rate-limit/rate-limiter.service.ts
Normal file
55
apps/api/src/common/rate-limit/rate-limiter.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
export interface RateLimitVerdict {
|
||||
allowed: boolean;
|
||||
/** Whole seconds until the current window resets — for the `Retry-After` header. */
|
||||
retryAfterSeconds: number;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
windowStartedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fixed-window counter — no Redis in this environment yet
|
||||
* (compose.yaml/docs: "BullMQ quando Redis existir"), and this API runs as
|
||||
* a single instance (ADR-0002), so a process-local Map is both sufficient
|
||||
* and correct for this stage. Fixed-window (not sliding-window/token-
|
||||
* bucket) is a deliberate simplicity choice: it lets a caller burst up to
|
||||
* 2x the nominal limit right at a window boundary, which is an acceptable
|
||||
* trade-off for closing "no rate limiting at all" — revisit only if real
|
||||
* abuse patterns exploit that boundary.
|
||||
*
|
||||
* One instance of this service is shared (via RateLimitModule, imported by
|
||||
* both AuthModule and SignaturesModule) across every route this API
|
||||
* protects — buckets are namespaced by the caller-supplied `key`, which
|
||||
* always embeds a rule name so different routes never collide.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RateLimiterService {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
consume(key: string, limit: number, windowMs: number): RateLimitVerdict {
|
||||
const now = Date.now();
|
||||
const existing = this.buckets.get(key);
|
||||
|
||||
if (!existing || now - existing.windowStartedAt >= windowMs) {
|
||||
this.buckets.set(key, { count: 1, windowStartedAt: now });
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
if (existing.count >= limit) {
|
||||
const retryAfterSeconds = Math.ceil((existing.windowStartedAt + windowMs - now) / 1000);
|
||||
return { allowed: false, retryAfterSeconds: Math.max(retryAfterSeconds, 1) };
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return { allowed: true, retryAfterSeconds: 0 };
|
||||
}
|
||||
|
||||
/** Test-only escape hatch — never called from production code paths. */
|
||||
reset(): void {
|
||||
this.buckets.clear();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type SignatureEnvelope,
|
||||
} from "@eden/database";
|
||||
import { getObjectStream, getPdfPageCount, sendMail, uploadObject } from "@eden/integrations";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
@@ -231,4 +232,23 @@ export class EnvelopeService {
|
||||
await this.get(id, user);
|
||||
return signaturesRepo.listAuditEvents(id);
|
||||
}
|
||||
|
||||
/** Streaming autenticado do PDF congelado de um documento do envelope — mesmo padrão de acesso a S3 do resto do projeto (eden.md linha ~4442: nunca URL assinada, sempre proxy pelo backend com a rota já validando permissão). */
|
||||
async getDocumentPdf(id: string, documentId: string, user: EdenAuthenticatedUser): Promise<{ documentTitle: string; stream: Readable }> {
|
||||
const envelope = await this.get(id, user);
|
||||
const document = envelope.documents.find((d) => d.id === documentId);
|
||||
if (!document) throw new NotFoundException("Documento não encontrado neste envelope");
|
||||
const stream = await getObjectStream(document.source_s3_key);
|
||||
return { documentTitle: document.title, stream };
|
||||
}
|
||||
|
||||
/** Streaming autenticado do PDF final consolidado (documentos + certificado) — só existe a partir de COMPLETED. */
|
||||
async getFinalPdf(id: string, user: EdenAuthenticatedUser): Promise<{ envelope: EnvelopeWithDetails; stream: Readable }> {
|
||||
const envelope = await this.get(id, user);
|
||||
if (!envelope.final_s3_key) {
|
||||
throw new NotFoundException("Este envelope ainda não tem um PDF final consolidado");
|
||||
}
|
||||
const stream = await getObjectStream(envelope.final_s3_key);
|
||||
return { envelope, stream };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Header, Param, Post, Req, Res, StreamableFile, UseGuards } from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import type { Request } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { PublicSignatureService } from "./public-signature.service.js";
|
||||
import { VerifyOtpDto } from "./dto/verify-otp.dto.js";
|
||||
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
|
||||
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
|
||||
import {
|
||||
PUBLIC_SIGNATURE_IP_RULE,
|
||||
PUBLIC_SIGNATURE_OTP_REQUEST_RULE,
|
||||
PUBLIC_SIGNATURE_OTP_VERIFY_RULE,
|
||||
PUBLIC_SIGNATURE_TOKEN_ACTION_RULE,
|
||||
} from "../common/rate-limit/rate-limit-rules.js";
|
||||
|
||||
function requestContext(req: Request) {
|
||||
return {
|
||||
@@ -14,47 +22,70 @@ function requestContext(req: Request) {
|
||||
/**
|
||||
* Fluxo público de assinatura — eden.md §10. Sem autenticação de sessão:
|
||||
* autorização é inteiramente pelo token do link (`:token`), nunca por
|
||||
* `envelopeId`/`signerId` de corpo de requisição. Rate limiting (por IP e
|
||||
* por token, eden.md §10.3/§6.8) é uma lacuna conhecida — ver
|
||||
* docs/progress.md, mesmo padrão de outros endpoints públicos deste
|
||||
* projeto (ex. forgot-password) que também não têm rate limit ainda.
|
||||
* `envelopeId`/`signerId` de corpo de requisição. Rate limiting is by IP
|
||||
* (PUBLIC_SIGNATURE_IP_RULE, shared across every handler below — catches
|
||||
* one source hammering many different tokens) AND by token
|
||||
* (PUBLIC_SIGNATURE_TOKEN_ACTION_RULE and friends — catches many sources
|
||||
* hammering one leaked link), per eden.md §10.3/§6.8. See
|
||||
* rate-limit-rules.ts for the concrete numbers and reasoning.
|
||||
*/
|
||||
@ApiTags("public-signature")
|
||||
@Controller("v1/public-signature")
|
||||
@UseGuards(RateLimitGuard)
|
||||
export class PublicSignatureController {
|
||||
constructor(private readonly publicSignature: PublicSignatureService) {}
|
||||
|
||||
@Get(":token")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
|
||||
getState(@Param("token") token: string) {
|
||||
return this.publicSignature.getPublicState(token);
|
||||
}
|
||||
|
||||
@Get(":token/documents/:documentId/pdf")
|
||||
@Header("Content-Type", "application/pdf")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
|
||||
async getDocumentPdf(
|
||||
@Param("token") token: string,
|
||||
@Param("documentId") documentId: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { documentTitle, stream } = await this.publicSignature.getDocumentPdf(token, documentId);
|
||||
res.set({ "Content-Disposition": `inline; filename="${documentTitle}.pdf"` });
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Post(":token/documents/:documentId/confirm-read")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
|
||||
confirmDocumentRead(@Param("token") token: string, @Param("documentId") documentId: string, @Req() req: Request) {
|
||||
return this.publicSignature.confirmDocumentRead(token, documentId, requestContext(req));
|
||||
}
|
||||
|
||||
@Post(":token/confirm-data")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
|
||||
confirmData(@Param("token") token: string, @Req() req: Request) {
|
||||
return this.publicSignature.confirmData(token, requestContext(req));
|
||||
}
|
||||
|
||||
@Post(":token/consent")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
|
||||
consent(@Param("token") token: string, @Req() req: Request) {
|
||||
return this.publicSignature.acceptConsent(token, requestContext(req));
|
||||
}
|
||||
|
||||
@Post(":token/otp/request")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_OTP_REQUEST_RULE)
|
||||
requestOtp(@Param("token") token: string, @Req() req: Request) {
|
||||
return this.publicSignature.requestOtp(token, requestContext(req));
|
||||
}
|
||||
|
||||
@Post(":token/otp/verify")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_OTP_VERIFY_RULE)
|
||||
verifyOtp(@Param("token") token: string, @Body() dto: VerifyOtpDto, @Req() req: Request) {
|
||||
return this.publicSignature.verifyOtp(token, dto.code, requestContext(req));
|
||||
}
|
||||
|
||||
@Post(":token/sign")
|
||||
@RateLimit(PUBLIC_SIGNATURE_IP_RULE, PUBLIC_SIGNATURE_TOKEN_ACTION_RULE)
|
||||
sign(@Param("token") token: string, @Req() req: Request) {
|
||||
return this.publicSignature.commitSignature(token, requestContext(req));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { signaturesRepo, type EnvelopeWithDetails, type SignatureSigner } from "@eden/database";
|
||||
import { sendMail } from "@eden/integrations";
|
||||
import { getObjectStream, sendMail } from "@eden/integrations";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
import { hashSignerToken } from "./token.js";
|
||||
@@ -80,6 +81,15 @@ export class PublicSignatureService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Mesma checagem de posse do documento que `confirmDocumentRead` — o signatário só pode baixar bytes de um documento do próprio envelope, nunca de outro (autorização inteiramente pelo token, eden.md §10.1). Não registra evento por si só: a confirmação de leitura em si é uma ação explícita separada (`confirmDocumentRead`). */
|
||||
async getDocumentPdf(token: string, documentId: string): Promise<{ documentTitle: string; stream: Readable }> {
|
||||
const { envelope } = await this.resolveSigner(token);
|
||||
const document = envelope.documents.find((d) => d.id === documentId);
|
||||
if (!document) throw new BadRequestException("Documento não pertence a este envelope.");
|
||||
const stream = await getObjectStream(document.source_s3_key);
|
||||
return { documentTitle: document.title, stream };
|
||||
}
|
||||
|
||||
async confirmDocumentRead(token: string, documentId: string, ctx: PublicRequestContext): Promise<void> {
|
||||
const { signer, envelope } = await this.resolveSigner(token);
|
||||
if (!envelope.documents.some((d) => d.id === documentId)) {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Controller, Get, Param } from "@nestjs/common";
|
||||
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { PublicVerifyService } from "./public-verify.service.js";
|
||||
import { RateLimitGuard } from "../common/rate-limit/rate-limit.guard.js";
|
||||
import { RateLimit } from "../common/rate-limit/rate-limit.decorator.js";
|
||||
import { PUBLIC_VERIFY_IP_RULE, PUBLIC_VERIFY_ID_RULE } from "../common/rate-limit/rate-limit-rules.js";
|
||||
|
||||
@ApiTags("public-verify")
|
||||
@Controller("v1/public-verify")
|
||||
@UseGuards(RateLimitGuard)
|
||||
export class PublicVerifyController {
|
||||
constructor(private readonly publicVerify: PublicVerifyService) {}
|
||||
|
||||
@Get(":verificationId")
|
||||
@RateLimit(PUBLIC_VERIFY_IP_RULE, PUBLIC_VERIFY_ID_RULE)
|
||||
verify(@Param("verificationId") verificationId: string) {
|
||||
return this.publicVerify.verify(verificationId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Delete, Get, Header, Param, Post, Res, StreamableFile, UseGuards } from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
@@ -75,6 +76,29 @@ export class SignatureEnvelopesController {
|
||||
return this.envelopes.listAuditEvents(id, user);
|
||||
}
|
||||
|
||||
@Get(":id/documents/:documentId/pdf")
|
||||
@RequirePermission("signatures", "view")
|
||||
@Header("Content-Type", "application/pdf")
|
||||
async downloadDocumentPdf(
|
||||
@Param("id") id: string,
|
||||
@Param("documentId") documentId: string,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { documentTitle, stream } = await this.envelopes.getDocumentPdf(id, documentId, user);
|
||||
res.set({ "Content-Disposition": `inline; filename="${documentTitle}.pdf"` });
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Get(":id/final-pdf")
|
||||
@RequirePermission("signatures", "view")
|
||||
@Header("Content-Type", "application/pdf")
|
||||
async downloadFinalPdf(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser, @Res({ passthrough: true }) res: Response) {
|
||||
const { envelope, stream } = await this.envelopes.getFinalPdf(id, user);
|
||||
res.set({ "Content-Disposition": `inline; filename="${envelope.envelope_number}.pdf"` });
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
/** Passo 1 da finalização — pré-condição status SIGNED (eden.md §8.2). */
|
||||
@Post(":id/seal-checkpoint")
|
||||
@RequirePermission("signatures", "edit")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { RateLimitModule } from "../common/rate-limit/rate-limit.module.js";
|
||||
import { SignatureEnvelopesController } from "./signatures.controller.js";
|
||||
import { PublicSignatureController } from "./public-signature.controller.js";
|
||||
import { PublicVerifyController } from "./public-verify.controller.js";
|
||||
@@ -9,7 +10,7 @@ import { PublicSignatureService } from "./public-signature.service.js";
|
||||
import { PublicVerifyService } from "./public-verify.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, RateLimitModule],
|
||||
controllers: [SignatureEnvelopesController, PublicSignatureController, PublicVerifyController],
|
||||
providers: [EnvelopeService, EnvelopeFinalizeService, PublicSignatureService, PublicVerifyService],
|
||||
exports: [EnvelopeService, EnvelopeFinalizeService],
|
||||
|
||||
Reference in New Issue
Block a user