Visão geral das proteções implementadas e práticas recomendadas para deploy.
bcryptjs (10 rounds).next-auth.session-token (HttpOnly,
SameSite=Lax).null (sem
detalhes). Evita enumeração.TOO_MANY_ATTEMPTS (mensagem amigável traduzida nos 3 idiomas).O JWT é revalidado contra o banco a cada 60 segundos no callback jwt
em src/auth.ts. A cada hit que passa pela função auth()
em server-side, se a última checagem foi há mais de 1 min, o callback
consulta o banco e atualiza no token:
isActive e archived (a partir de User.isActive e archivedAt).role, mustChangePassword, locale — para refletir mudanças feitas
por um admin em outro device.O middleware (src/auth.config.ts) lê
session.user.isActive e session.user.archived e deslogará (redirect
para /login?revoked=1) qualquer usuário desativado/arquivado em até 1 min
após a alteração. Sem isso, JWTs em curso continuariam válidos até a
expiração natural.
mustChangePassword = true é setado./dashboard/profile/change-password.session.update().PasswordResetToken (hash do token via sha256, valor
cru só sai pela URL no email).updateMany filtrado por tokenHash, expiresAt > now,
usedAt = null. Se count = 0, token inválido ou já usado.requestPasswordReset retorna sempre
success: true, independente do email existir. Para nivelar timing,
o ramo “usuário inexistente” simula custo de bcrypt.hash() antes de
retornar. Rate limit duplo: 1/min por email + 5/min por IP.otplib v13.User.twoFactorBackupCodes (JSON de hashes).
O usuário recebe os códigos em texto plano apenas uma vez, no momento da
ativação — após isso só o hash sobrevive no banco.checkBackupCode aceita tanto entries
hasheadas (prefixo $2[aby]$) quanto texto plano legado. Usuários
pré-hardening não precisam regenerar, mas é recomendado.SecuritySettings.require2FARoles (JSON
array). Roles listados aqui não conseguem logar sem 2FA configurado —
retorna 2FA_SETUP_REQUIRED no fluxo de login.const isValid = verifyTotpToken(token, secret); // retorna boolean
A versão atual do helper já abstrai a API do otplib. Não chame verify
direto — passe sempre por src/lib/totp.ts.
Em Next.js App Router, toda função exportada de um arquivo "use server"
é um endpoint HTTP público. Não basta esconder a action no frontend — um
atacante autenticado (ou nem isso) pode invocá-la diretamente via POST.
Use sempre um dos helpers em src/lib/finance-access.ts como primeira instrução de cada Server Action:
import { denyIfNoEdit } from "@/lib/finance-access";
export async function updateGuest(_state, formData) {
const denied = await denyIfNoEdit();
if (denied) return denied;
// ...
}
| Helper | Permite | Use para |
|---|---|---|
denyIfNoEdit() |
ADMIN, GROOM, BRIDE, PLANNER | Conteúdo operacional do casamento (convidados, fornecedores, tarefas, lua de mel, enxoval, dia D). |
denyIfNoFinance() |
ADMIN, GROOM, BRIDE | Pagamentos, receitas, metas, ativos — qualquer mutação financeira direta. |
denyIfNoManage() |
ADMIN, GROOM, BRIDE | Configuração do evento (data, moeda, dados Pix, nomes do casal). |
Exceções (ações públicas por design): requestPasswordReset,
consumePasswordReset, validateResetToken, publicRsvpRespond. Estas
precisam de rate-limit no lugar do auth check.
Endpoints REST que servem dados financeiros (/api/backup, /api/files/[id])
checam role via canViewSensitiveFinance() direto — qualquer autenticado
que não esteja nesse grupo recebe 403. Todo download via /api/backup
grava AuditLog (BACKUP_EXPORT) para rastreabilidade.
Toda comparação de Bearer token, HMAC, código de reset (quando comparado em
JS), e similares deve usar timingSafeEquals(a, b) de
src/lib/timing-safe.ts. Nunca ===.
/api/cron/reminders exige Authorization: Bearer ${CRON_SECRET}. A
comparação é timing-safe. Gere o secret com openssl rand -hex 32 e nunca
exponha sem auth.
Módulo em src/lib/rate-limit.ts — in-memory, adequado para deploy single-node. Use em:
Chave deve combinar IP + recurso. Exemplo:
const ip = getClientIp(req);
const rl = rateLimit(`login:${ip}`, 10, 60_000);
if (!rl.ok) {
return Response.json({ error: "Calma" }, { status: 429 });
}
O limiter faz eviction periódica (sweep a cada 60 s) de buckets
expirados — o Map interno não cresce indefinidamente, mesmo sob spray de
IPs únicos.
getClientIp(headers) aceita apenas cf-connecting-ip (Cloudflare) e o
último hop de x-forwarded-for. Não confiamos em x-real-ip porque
qualquer cliente pode forjar o header quando não há proxy na frente.
⚠️ Para deploys com múltiplas réplicas, troque o limiter por Redis.
Cada ação relevante grava em AuditLog:
await audit("Payment", payment.id, "MARK_PAID", { method: "PIX" });
Campos: entity, entityId, action, payload (JSON serializado),
userId, createdAt.
A trilha é exibida em Configurações › Auditoria (/dashboard/settings),
aba restrita a quem tem canManageUsers (ADMIN/PLANNER). Mostra as últimas
500 ações com filtros por entidade, ação, intervalo de data e busca textual
(ID da entidade ou pessoa), além de paginação. O userId é resolvido para
nome/e-mail no servidor; ações sem ator (cron/sistema) aparecem como
“Sistema”. O payload não é exposto na UI — pode conter dados sensíveis,
então a tela fica em quem/o-quê/quando.
userIdO 5º argumento (userId) é opcional. Quando omitido, audit() faz um
import dinâmico de @/auth, chama auth() e extrai
session.user.id. Falhas (cron sem request lifecycle, scripts standalone)
são silenciadas e o registro fica com userId = null. Isso elimina o
boilerplate antigo em que cada call-site precisava propagar session.user.id
manualmente — e o esquecimento gerava trilha cega.
AuditEntity em src/lib/audit.ts cobre Vendor,
VendorContact, VendorNote, BudgetItem, Payment, Asset, Income, SavingsGoal,
EventSettings, User, SecuritySettings, SeatingTable, Guest, GuestGroup,
Gift, Contract, Attachment, Task, Venue, Honeymoon, HoneymoonItem,
TrousseauItem. Ao adicionar um novo modelo Prisma com mutação auditada,
estenda essa union antes de chamar audit() — TypeScript pega o erro.
Mutações em todos os modelos passam por audit(): create, update,
delete, status change, bulk import, assign/unassign de mesa, RSVP em grupo,
2FA enable/disable, backup export, upload/download/replace/sign de anexo,
reset de senha e mudança de própria senha.
.max(120) para nomes, .max(2000) para
notas longas, .max(8000) para textos do dia D./^\d{4}-\d{2}-\d{2}$/ quando vier de
<input type="date">.z.coerce.number().min(0).max(1_000_000).Templates de email são renderizados como HTML — dados de usuário devem
passar por escape. Existe um helper padrão (se ainda não, considere extrair
um escapeHtml(value) em src/lib/html-escape.ts).
/api/files/[id] aceita uploads de anexos (contratos, fotos de venues,
etc.). Boas práticas:
file.type declarado contra allowlist.| Item | Recomendação |
|---|---|
| HTTPS | Obrigatório. Cloudflare Tunnel ou nginx + Let’s Encrypt. |
| Cookies | Secure + HttpOnly (Auth.js já configura quando NEXTAUTH_URL é https) |
AUTH_TRUST_HOST |
true quando atrás de proxy/CDN. |
| Headers de segurança | Configurar Content-Security-Policy, Strict-Transport-Security, X-Frame-Options: DENY no proxy ou em next.config.ts. |
| Banco | Manter dev.db em diretório persistente (não em pasta volátil). Backup diário. |
.env |
NUNCA versione. Use sua plataforma para injetar. |
ADMIN_PASSWORD |
Trocar imediatamente no primeiro login (o sistema força). |
| Atualizações | npm audit periódico, especialmente em libs de auth e crypto. |
O sistema armazena dados pessoais (nomes, telefones, emails de convidados, fornecedores, parceiros). Quem opera o sistema é o responsável pela LGPD — recomendações:
Endurecimento aplicado ao módulo de anexos — veja anexos.md para detalhes completos. Resumo das proteções:
src/lib/file-validation.ts validam o tipo real do
arquivo (detectMagic + assertMagicMatchesMime) antes de aceitar.
PDF, PNG, JPEG, WEBP e HEIC são reconhecidos.CONTRACT só aceita application/pdf;
INVOICE/RECEIPT/ID_DOC aceitam PDF + JPG/PNG; PHOTO aceita
imagens (inclusive HEIC).Attachment.sha256Full.src/lib/storage.ts usa
path.resolve + startsWith(UPLOADS_ROOT).prisma.$transaction; versão antiga soft-deletada por 30 dias.canViewAttachmentKind), headers X-Content-Type-Options: nosniff,
X-Frame-Options: SAMEORIGIN, Referrer-Policy: no-referrer,
Cache-Control: private, no-store para contratos. CSP varia por MIME:
PDFs usam apenas frame-ancestors 'self' (sem sandbox, que bloqueava
o visualizador interno do Chrome); demais MIMEs mantêm
default-src 'none'; sandbox; style-src 'unsafe-inline'; frame-ancestors 'self'./api/cron/cleanup-files remove arquivos soft-deletados
após 30 dias e órfãos no FS. Protegido por CRON_SECRET via
timingSafeEquals.Ao revisar PR que toque endpoints/Server Actions, conferir:
await auth() no início + tratamento de sessão ausentetimingSafeEqualsAuditLogconsole.log de informação sensíveldetectMagic + assertMagicMatchesMime +
assertAllowedForKind + assertSizeForKind/api/files/[id] (nunca expor storagePath
diretamente)America/Sao_Paulo para display, UTC para storage)