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:
@@ -569,3 +569,51 @@ Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros
|
||||
|
||||
### Pendências
|
||||
Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também.
|
||||
|
||||
---
|
||||
|
||||
## Rate limiting em todas as rotas públicas — lacuna fechada (eden-security)
|
||||
|
||||
Data: 2026-09-17. Essa lacuna estava registrada em pelo menos três lugares diferentes (`docs/security/threat-model.md` §2.1/§2.2, e as pendências de MFA/forgot-password/assinatura acima): nenhuma rota sem sessão tinha qualquer limite de requisições. Escopo: `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` (`logout` não estava na lista original do pedido, mas também não tem `JwtAuthGuard` — incluído por completude), `GET /v1/public-signature/:token`, `GET /v1/public-signature/:token/documents/:documentId/pdf`, `POST /v1/public-signature/:token/{documents/:documentId/confirm-read,confirm-data,consent,otp/request,otp/verify,sign}` e `GET /v1/public-verify/:verificationId`.
|
||||
|
||||
### Decisão: guard/decorator próprio, não `@nestjs/throttler`
|
||||
|
||||
Sem Redis no ambiente (`compose.yaml`: "eden-redis (quando houver job real)" — ainda não existe) e API roda como instância única (ADR-0002), então em memória é a escolha certa nos dois casos — `@nestjs/throttler` teria servido igual de bem nesse ponto. A razão real de ter escrito um guard próprio (`apps/api/src/common/rate-limit/`) foi precisar de **chaves compostas por rota**: `public-signature/:token/*` precisa de um balde por IP (pega uma fonte varrendo/testando muitos tokens diferentes) *e* um balde por token (pega um link vazado sendo atacado por qualquer número de IPs — é exatamente o caso que `@nestjs/throttler` não cobre bem sem sobrepor múltiplos throttlers nomeados com trackers customizados por controller, o que na prática vira quase tanto código próprio quanto escrever o guard direto). `RateLimiterService` é um Map com janela fixa (não sliding window/token bucket — trade-off deliberado de simplicidade, documentado no próprio arquivo), `RateLimitGuard` lê metadata de `@RateLimit(...rules)` e aplica todas; sem decorator no handler, o guard é um no-op (por isso pôde ser plugado a nível de controller sem afetar rotas autenticadas do mesmo controller, ex. `mfa/enroll`).
|
||||
|
||||
### Limites
|
||||
|
||||
Todos em `apps/api/src/common/rate-limit/rate-limit-rules.ts`, cada um com a razão numa linha de comentário — resumo: `login`/`mfa/challenge`/`forgot-password`/`reset-password` a 10/min por IP (alvo de adivinhação de credencial — argon2/token de 256 bits já encarece o palpite, isso limita a *taxa*), `refresh`/`logout` a 30/min por IP (token de 256 bits, não é alvo de força bruta — limite mais alto porque sessão ativa com múltiplas abas atualiza sozinha com frequência), `public-signature/*` com um balde de IP compartilhado do controller inteiro (60/min) somado a um balde por token específico da ação (20/5min nas ações comuns, 5/15min em `otp/request` porque dispara e-mail de verdade, 15/5min em `otp/verify` — complementar ao lockout por tentativas erradas que já existe em `public-signature.service.ts::verifyOtp`, persistido no banco, esse sim já sobrevive a troca de IP), `public-verify` a 60/min por IP e 30/min por `verificationId`.
|
||||
|
||||
### O que NÃO foi resolvido (residual, registrado em `docs/security/threat-model.md`)
|
||||
|
||||
Um atacante trocando de IP lentamente (abaixo do limite por IP, em baixo volume, por muito tempo) não é pego por isto — não existe lockout de conta a nível de aplicação para login hoje, só o rate limit por IP. E `app.set('trust proxy', ...)` nunca foi configurado nesta API; se algum dia um reverse proxy/LB externo ficar na frente do `eden-api` em produção, `req.ip` vai reportar o IP do proxy para todo mundo e essa mitigação (e todo log de IP do projeto) fica cega — não configurei isso adivinhando a topologia futura, porque confiar em `X-Forwarded-For` sem saber exatamente quantos hops confiáveis existem abre uma forma de o próprio cliente spoofar seu IP.
|
||||
|
||||
### Verificação
|
||||
`apps/api/test/rate-limit.spec.ts`: metade pura (sem banco/HTTP, no estilo de `signature-hash-chain.spec.ts`) provando que uma chave estoura exatamente no limite configurado, que chaves diferentes (duas IPs, ou dois tokens de assinatura) nunca competem pelo mesmo balde, e que a janela reseta (`vi.useFakeTimers`); metade HTTP real (app real, Postgres real) provando estouro de verdade em `POST /v1/auth/login` (11ª tentativa em 1 minuto vira 429 com `Retry-After`, não mais 401) e que esgotar o balde do login não afeta o balde de `forgot-password` na mesma requisição/IP. Suíte completa: 77/77 verdes (70→77). `pnpm turbo run typecheck build` 28/28. Testado ao vivo contra o container real `eden-api` (rebuild + restart): 10× 401 seguidos de 429 com corpo `{"statusCode":429,"message":"Muitas requisições. Tente novamente mais tarde.","retryAfterSeconds":60}` e header `Retry-After: 60`, log estruturado `WARN [RateLimitGuard] rate limit exceeded: rule=auth-login ...` no `docker logs eden-api`, e confirmado que `forgot-password` seguiu respondendo 200 no mesmo IP enquanto `login` estava bloqueado.
|
||||
|
||||
---
|
||||
|
||||
## UI de Assinatura Eletrônica (backoffice + páginas públicas) — segunda pendência do módulo fechada
|
||||
|
||||
Data: 2026-09-17. Continuação direta, sem parar para autorização — retomando as duas pendências deixadas pela leva anterior (rate limiting, ver entrada acima, e a UI completa do módulo). Antes de delegar o frontend, um gap real de backend foi encontrado e corrigido diretamente (não delegado, por ser rota nova/sensível): `PublicSignatureService.getPublicState` só devolvia metadados dos documentos (título, versão, contagem de páginas) — não existia NENHUMA forma de o signatário efetivamente ver o conteúdo do PDF antes de "confirmar leitura", nem do backoffice baixar o documento congelado ou o PDF final consolidado. Endpoints novos, mesmo padrão de streaming já usado em `documents.controller.ts` (`StreamableFile`, nunca URL assinada — eden.md linha ~4442):
|
||||
- `GET /v1/signature-envelopes/:id/documents/:documentId/pdf` e `GET /v1/signature-envelopes/:id/final-pdf` (autenticados, escopo por revenda como o resto do módulo).
|
||||
- `GET /v1/public-signature/:token/documents/:documentId/pdf` (público, autorização inteiramente pelo token — mesma checagem de posse do documento que `confirmDocumentRead` já usava).
|
||||
|
||||
Cobertos em `test/signature-envelope-flow.spec.ts` (72/72 antes da leva de rate limiting subir para 77/77). `pnpm turbo run typecheck build` 28/28.
|
||||
|
||||
### Frontend (agente `eden-frontend`, uma leva só — public+autenticado não foram paralelizados desta vez, para evitar a reconciliação manual de `App.tsx` que as levas anteriores precisaram fazer a cada par de agentes)
|
||||
|
||||
**Backoffice** (`apps/core-web/src/routes/signatures/`): lista de envelopes (enriquecida por chamada individual `GET /:id`, já que a listagem não traz signatários — mesmo idioma já usado em `TicketsListPage` para o SLA), criação (origem cliente XOR revenda, seleção de `document_generations` já `final`, form-array de signatários contratante — contratada nunca é pedida no formulário), detalhe com documentos/signatários/timeline de auditoria e todas as ações de transição de estado (enviar convite, cancelar, excluir, selar checkpoint, finalizar, baixar PDF final, verificar cadeia), gates de UI espelhando `ENVELOPE_TRANSITIONS` do repositório.
|
||||
|
||||
**Público** (`apps/core-web/src/routes/`, fora do `AuthenticatedLayout`, mesmo padrão de `/login`/`/forgot-password`): `PublicSignaturePage.tsx` em `/assinar/:token` (path literal que o backend já usa no e-mail de convite) — wizard guiado inteiramente por `signer.status`, sempre re-buscando o estado a cada ação para permitir retomar de onde parou se o signatário fechar o navegador no meio; `PublicVerifyPage.tsx` em `/verificar/:verificationId` (idem, path literal do e-mail de conclusão).
|
||||
|
||||
**Achado de infraestrutura do próprio agente**: nenhuma tela existente no projeto tinha um padrão pronto de "esconder ação por permissão" (grep não encontrou nenhum uso de `permissions` em rotas) — o agente adicionou `permissions` ao `AuthContext` (buscado de `/auth/me`) e um `hasPermission` mínimo, usado só para UX (esconder/desabilitar botão), nunca como controle de acesso — a decisão real continua inteiramente no backend.
|
||||
|
||||
**Divergências reais encontradas contra o brief, corrigidas antes de entregar**: `GET /signature-envelopes` não inclui signatários/documentos (resolvido enriquecendo cada linha); `verify-audit` pode retornar `failedAt`/`reason` além de `{valid, eventsChecked}` quando a cadeia está corrompida (UI trata); o contador de tentativas do OTP não é exposto pela API (UI usa mensagem genérica); `sendInvite` não valida o status do envelope no backend (UI esconde o botão em estados terminais por UX, sem depender disso para segurança).
|
||||
|
||||
### Verificação
|
||||
|
||||
`pnpm turbo run typecheck build` 28/28. Teste ao vivo de ponta a ponta contra `eden-api`/`eden-postgres`/`eden-minio` reais (o agente subiu um mail-catcher SMTP efêmero na rede docker para extrair tokens/OTP reais dos e-mails, revertido ao final — `.env` original confirmado intacto via `docker inspect`): empresa+representante legal → revenda → template→geração final → criar envelope → enviar convite → os dois signatários completando o fluxo público inteiro (ver PDF do documento, confirmar leitura, dados, consentimento, OTP errado então certo, assinar) → `SENT`→`SIGNED` automático no último signatário → seal-checkpoint → finalize (Chromium + merge de PDF reais) → `COMPLETED` → download do PDF final autenticado confirmado como PDF válido → `verify-audit` válido (38 eventos) → `public-verify` sem auth retornando o formato esperado com `integrityValid: true`. Depois containers `eden-api`/`eden-core` reconstruídos com o estado final combinado (endpoints de streaming + rate limiting + UI) e testados ao vivo: `/signatures`, `/signatures/new`, `/assinar/:token`, `/verificar/:verificationId` respondendo 200 via proxy same-origin (porta 3001), e o 11º login errado seguido confirmando 429 (rate limiting da leva anterior intacto após o rebuild combinado).
|
||||
|
||||
### Pendências
|
||||
`DECLINED` (recusa de assinatura) segue sem rota que o produza — reservado no schema, mesma lacuna do legado. `contract_versions`/`contract_amendments` (ADR-0006, adiados desde a leva de trava de edição de contrato) agora estão desbloqueados — o fluxo de assinatura já existe — mas ainda não iniciados. `reseller-web`/`subscriber-web` seguem só com o shell de login — nenhuma tela própria, incluindo nada do módulo de assinatura (fora de escopo desta leva).
|
||||
|
||||
@@ -21,6 +21,10 @@ Existem por necessidade de negócio (cadastro de cliente/revenda, assinatura ele
|
||||
- Revalidação total de regra de negócio no backend — o cliente é sempre não confiável.
|
||||
- 404/409 sem vazar existência de recurso quando aplicável (mensagens genéricas para "não existe" vs. "já usado").
|
||||
|
||||
**Implementado (2026-09-17):** todas as rotas públicas hoje existentes têm rate limiting — `apps/api/src/common/rate-limit/` (`RateLimiterService` em memória, janela fixa; `RateLimitGuard` + `@RateLimit(...)` por rota). `POST /v1/auth/{login,mfa/challenge,forgot-password,reset-password,refresh,logout}` por IP; `GET/POST /v1/public-signature/:token/*` por IP (balde compartilhado do controller, pega scraping de vários tokens de uma fonte) **e** por token (pega força bruta de um link vazado vindo de qualquer IP); `GET /v1/public-verify/:verificationId` por IP e por id. Limites concretos e a razão de cada um em `apps/api/src/common/rate-limit/rate-limit-rules.ts`. Sem Redis (ainda não existe, ver compose.yaml) — armazenamento local ao processo, correto para a instância única atual (ADR-0002); não sobrevive a múltiplas réplicas do `eden-api`, revisar quando isso mudar. Testes: `apps/api/test/rate-limit.spec.ts`.
|
||||
|
||||
**Risco residual aceito, não coberto por este trabalho:** (1) um atacante que gire IPs lentamente ainda opera abaixo do limite por IP indefinidamente — não há lockout por conta a nível de aplicação além do que já existe no OTP de assinatura; (2) `app.set('trust proxy', ...)` nunca foi configurado — se um reverse proxy/LB externo vier a ficar na frente do `eden-api` em produção, `req.ip` (chave do rate limit e de todo log de IP) passará a ver sempre o IP do proxy, não o do cliente real, cegando esta mitigação — configurar isso corretamente (e só isso, um hop confiável) é pré-requisito de qualquer topologia com proxy na frente da API.
|
||||
|
||||
### 2.2 Autenticação e sessão
|
||||
- Herdar do legado: `token_version`-like mecanismo de invalidação global, MAS evoluir para refresh token rotativo hashado (ADR-0003) — não repetir JWT de 30 dias sem revogação.
|
||||
- Argon2id (não bcrypt) para novo hash de senha — custo a definir em ADR de segurança/config, revisável sem quebrar hashes existentes (versionamento de parâmetro).
|
||||
|
||||
Reference in New Issue
Block a user