Login — Padrão Anti-Enumeração de Identificador
Data: 2026-05-12 Escopo: Fluxo de login unificado (CPF / CNPJ / INEP) — etapa de pré-check de método. Status: Comportamento intencional. Documentar e padronizar UX.
1. Regra (SSOT)
verify-password action check_method NUNCA revela se o identificador existe.
- Se o usuário/escola não existe → responde
{ has_password: true }. - Se o usuário existe e tem senha → responde
{ has_password: true }. - Se o usuário existe e NÃO tem senha → responde
{ has_password: false }(único caso distinto).
Consequência: qualquer identificador com formato válido (CPF 11, CNPJ 14, INEP 8) leva o usuário à tela de senha, mesmo quando o registro não existe. O erro só é revelado depois do submit da senha (mensagem genérica: "Não foi possível processar sua solicitação").
Por quê: sem isso um atacante poderia enumerar quais CPFs/CNPJs/INEPs têm conta na plataforma só observando se a tela de senha aparece.
2. Implementação
| Camada | Arquivo | Linhas | Comportamento |
|---|---|---|---|
| Backend | supabase/functions/verify-password/index.ts | 60-149 | check_method faz lookup primário (usuarios) + secundário (escolas → gestor com papel escola). Retorna has_password: senhaHash !== null. Default = true. |
| Backend | supabase/functions/send-otp/index.ts | 164-174 | send-otp retorna 400 genérico ("Não foi possível processar...") quando o lookup falha — sem distinguir "não existe" vs "inativo". antiTimingDelay() antes de responder. |
| Frontend | src/components/login-unified.tsx | 219-251 | handleAvancarParaSenha chama check_method. Se has_password === false → vai para OTP. Caso contrário (incluindo erro de rede) → setEtapa('senha'). Fallback no catch também vai para senha (anti-enumeração). |
Defesas auxiliares:
antiTimingDelay()em todos os caminhos para evitar timing oracle.- Validação de formato (
isValidCPF, comprimento) antes do lookup, mas a falha de formato também não revela existência — só dispara400 "Código inválido. Use CPF (11), INEP (8) ou CNPJ (14)", que é informativo de sintaxe, não de existência.
3. Como o usuário percebe (UX esperada)
- Digita identificador → clica Avançar.
- Vai para tela "Informe sua Senha".
- Digita senha → submit.
- Se identificador existe + senha certa → entra.
- Se identificador existe + senha errada → "Senha incorreta."
- Se identificador não existe → "Não foi possível processar sua solicitação." (mensagem idêntica à de senha errada quando o usuário existe mas tem outros bloqueios — proposital).
- Pode clicar "Entrar com WhatsApp" a qualquer momento. Aí sim
send-otpé chamado, e:- Se identificador existe → OTP enviado.
- Se não existe → mesmo erro genérico ("Não foi possível processar...").
Não existe tela ou toast que diga "INEP/CNPJ/CPF não encontrado". Por design.
4. Pegadinhas conhecidas (não confundir com bug)
| Sintoma relatado | Causa real |
|---|---|
| "Errei o INEP mas mesmo assim foi pra tela de senha." | check_method anti-enumeração — esperado. |
| "Apareceu erro 'do INEP' na tela de senha." | NÃO é do INEP. É a resposta genérica do submit da senha quando o usuário não existe. Aparece na mesma tela porque o frontend não voltou para o passo anterior. |
"Caí no send-otp e errei o identificador." | Só acontece se o usuário clicou "Entrar com WhatsApp". O fluxo "Avançar" não chama send-otp. |
Logs mostram send-otp 400 antes do verify-password 200. | Ordem possível: usuário tentou WhatsApp primeiro (400), depois trocou identificador, clicou Avançar → check_method → digitou senha errada → tentou WhatsApp de novo (200). Verificar timestamps e corpo da request. |
5. Auditoria
- Não tratar como bug relatos do tipo "deveria avisar antes que o INEP não existe". Encaminhar para esta página.
- Mudanças que revelem existência do identificador antes do submit da senha/OTP são rejeitadas por padrão — exigem ADR explícito de aceitação de risco.
- Toda nova action de
check_method(ex.: lookup por e-mail, telefone) DEVE seguir o mesmo contrato (has_password: trueno default).
Checklist (pré-merge em verify-password ou send-otp)
- [ ]
check_methodretornahas_password: truequando lookup falha. - [ ]
send-otpretorna mensagem genérica (400) sem distinguir "não existe" vs "inativo". - [ ]
antiTimingDelay()presente em todos os caminhos de erro. - [ ] Frontend
catchdocheck_methodcai emsetEtapa('senha'), não em toast de erro. - [ ] Nenhum log/toast contém o identificador cru (LGPD — usar
maskCodigo).
6. Trade-off & alternativas (registradas, não adotadas)
Opção rejeitada: validar identificador contra escolas/usuarios antes do submit e exibir "verifique o identificador".
- Custo: vira oracle de enumeração. Atacante consegue listar quais escolas/CPFs estão cadastrados via timing + resposta.
- Quando reabrir: somente se houver rate-limit por IP e por identificador suficientemente agressivo + WAF + monitoramento de scraping. Hoje não temos as três camadas.
Mitigação UX possível (sem quebrar a regra):
- Hint inline na tela inicial após N segundos de inatividade no campo: "Não tem certeza do código? Use o CPF do gestor." — não revela existência, só orienta.
- Mensagem da tela de senha pode incluir CTA explícita: "Se você não tem senha definida, use Entrar com WhatsApp." — já presente em
login-unified.tsx:880.
7. Referências
supabase/functions/verify-password/index.ts— actioncheck_method(linhas 60-149).supabase/functions/send-otp/index.ts— resposta genérica (linhas 164-174).src/components/login-unified.tsx— handlerhandleAvancarParaSenha(linhas 219-251).docs/security/AUTHENTICATION.md— visão geral do fluxo de login.docs/audits/SECURITY_AUDIT_PLAN.md—send-otpcobertura anti-enumeração (linha 28).docs/development/AUDIT.md— checklist geral.