Pular para conteúdo

0001 — Recuperação de senha por e-mail

Status CONCLUÍDA
Branch spec/0001-recuperacao-de-senha
Criada em 2026-07-24
Apps afetados api · web · e2e

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

1. Problema

Quem esquece a senha hoje não tem saída própria: não existe endpoint de recuperação na API, a tela de login não oferece o caminho, e a única forma de voltar a acessar é alguém com permissão administrativa alterar a senha por fora. Num SaaS B2B multi-tenant isso significa que cada esquecimento de senha de um usuário de imobiliária vira um chamado de suporte para a garantidora.

2. Objetivo

O usuário solicita a recuperação informando o e-mail, recebe um link válido por 30 minutos e define a nova senha sozinho, sem intervenção de suporte.

3. Fora de escopo

  • Autofinalização do cadastro. Usuário que nunca verificou o e-mail continua passando pelo fluxo de verificação existente, não por este.
  • Troca de senha por usuário autenticado (o "alterar minha senha" de dentro do sistema). É outra feature, com outra tela e sem token por e-mail.
  • Segundo fator / MFA.
  • Política de complexidade de senha além do que já existe hoje no cadastro — se hoje não há regra de força, esta spec não a introduz.
  • Rate limiting por IP. Ver RN7 para o que entra; o bloqueio por IP depende de infraestrutura que o projeto não tem e fica para depois.
  • Recuperação por SMS/WhatsApp. O ChannelIntegration já prevê WHATSAPP, mas este fluxo é só e-mail.

4. Regras de negócio

  • RN1. Qualquer usuário ativo (is_active = true, deleted_at IS NULL) pode solicitar recuperação, independente do papel — inclui AGENCY_USER e ADMIN_GLOBAL.
  • RN2. Usuário inativo, excluído ou inexistente não recebe e-mail, e a API responde exatamente como responderia no caso de sucesso (ver RN3).
  • RN3. Resposta sempre genérica. POST /auth/forgot-password retorna sempre 202 Accepted com a mesma mensagem, exista o e-mail ou não. A tela não pode virar um oráculo para descobrir quais e-mails têm conta — num SaaS B2B isso revelaria quem é cliente da garantidora.
  • RN4. Link válido por 30 minutos, contados da emissão. Expirado, a tela de redefinição informa e oferece solicitar novo link.
  • RN5. Uso único. Ao redefinir com sucesso, o token é marcado como usado (used_at) e não funciona de novo.
  • RN6. Nova solicitação invalida as anteriores. Ao emitir um token de PASSWORD_RESET, todos os tokens do mesmo tipo ainda não usados daquele usuário são marcados como usados. Evita vários links válidos circulando por e-mail.
  • RN7. Limite de solicitações. No máximo 3 solicitações por usuário em 30 minutos. Excedido, a API continua respondendo 202 genérico (RN3) mas não envia e-mail nem gera token.
  • RN8. Redefinir encerra as outras sessões. Após a troca, todo access e refresh token emitido antes do momento da redefinição deixa de valer. O motivo mais comum de recuperar senha é suspeita de conta comprometida — manter a sessão do invasor ativa anularia o efeito.
  • RN9. O token vai na URL como valor opaco de alta entropia. O e-mail é enviado ao endereço cadastrado, nunca a um endereço informado na requisição.
  • RN10. Redefinir a senha não verifica o e-mail do usuário: quem tinha email_verified_at nulo continua nulo (ver Fora de escopo).

5. Impacto por app

apps/api

Modelos/migrations

  • VerificationCodeType: acrescentar PASSWORD_RESET. O model VerificationCode já tem tudo que o fluxo precisa (code, expires_at, used_at, user_id) — não criar tabela nova.
  • User: novo campo sessions_valid_from: datetime | None. É o mecanismo de RN8 — hoje o JWT não tem revogação, mas já emite iat, então basta rejeitar token cujo iat seja anterior a este campo.
  • Migration Alembic com as duas mudanças. O enum precisa de ALTER TYPE ... ADD VALUE no PostgreSQL; conferir que o downgrade é viável ou documentar que não é.

Config

  • PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30 em core/config.py, ao lado de INVITE_TOKEN_EXPIRE_HOURS. Prazo não vai hardcoded no service.

Endpoints (routers/auth.py)

Método Path Permissão Efeito
POST /api/v1/auth/forgot-password pública Gera token e envia e-mail. Sempre 202.
POST /api/v1/auth/reset-password pública Valida token e grava a nova senha.

Service (auth_service.py)

  • request_password_reset(email) — resolve o usuário, aplica RN2/RN6/RN7, gera token, chama o e-mail. Nunca lança erro por e-mail inexistente.
  • reset_password(token, new_password) — valida type == PASSWORD_RESET, used_at IS NULL, expires_at > now; grava password_hash, marca used_at, e seta sessions_valid_from = now (RN8). Seguir o padrão já existente em onboarding_service.accept_invite() — a validação é a mesma.

Dependências

  • core/dependencies.get_current_user: rejeitar token cujo iat < sessions_valid_from do usuário, com o mesmo erro de sessão expirada que o front já trata.
  • POST /auth/refresh: mesma checagem.

E-mail

  • Template app/templates/email/password_reset.html, no padrão dos dois existentes.
  • email_service.send_password_reset(email, name, link).

Docstrings e Swagger — summary, description e response_description nos dois endpoints. A description do forgot-password deve dizer explicitamente que a resposta é genérica por decisão de segurança, senão parece bug para quem integra.

apps/web

  • pages/forgotPassword/index.tsx — hoje são 14 linhas do AuthPage genérico do Refine, sem rota no App.tsx e sem link no login. Substituir por formulário próprio que chama POST /auth/forgot-password e mostra a confirmação genérica.
  • pages/resetPassword/index.tsx — tela nova. Lê o token da query string, pede nova senha + confirmação, chama POST /auth/reset-password. Tratar token expirado/inválido com link para solicitar de novo.
  • pages/login/index.tsx — acrescentar o link "Esqueci minha senha". Sem isso o fluxo existe e ninguém acha.
  • App.tsx — registrar as rotas /forgot-password e /reset-password na área pública (fora do Authenticated). Não entram no custom-sider: são telas públicas, não módulos de navegação.
  • providers/auth.ts — implementar forgotPassword e updatePassword do authProvider do Refine, hoje ausentes.

data-testid (obrigatórios):

forgot-password-form            forgot-password-email-input
forgot-password-form-submit     forgot-password-success
forgot-password-error           login-forgot-password-link
reset-password-form             reset-password-password-input
reset-password-confirm-input    reset-password-form-submit
reset-password-error            reset-password-success
reset-password-expired          reset-password-request-new-link

apps/e2e

  • tests/e2e/password-reset.spec.ts cobrindo os cenários dos critérios de aceite.
  • O link chega por e-mail — o teste precisa lê-lo do Mailpit, via GET /mail/api/v1/messages no ambiente local. É a primeira spec que depende disso; criar um helper reutilizável em fixtures/.
  • Sem seed novo: usa usuário existente.

6. Contrato de API

Solicitar

POST /api/v1/auth/forgot-password
Content-Type: application/json

{ "email": "usuario@imobiliaria.com.br" }
{ "message": "Se o e-mail estiver cadastrado, enviaremos um link para redefinir a senha." }

202 Accepted — sempre, inclusive para e-mail inexistente, inativo ou com limite excedido (RN2, RN3, RN7).

Redefinir

POST /api/v1/auth/reset-password
Content-Type: application/json

{ "token": "<opaco>", "password": "NovaSenha@123" }
{ "message": "Senha redefinida com sucesso." }

Erros do reset-password

HTTP Código Quando
422 INVALID_TOKEN Token inexistente, já usado ou expirado — mensagem única, sem distinguir os três casos
422 WEAK_PASSWORD Nova senha reprovada pela validação já existente no cadastro

Distinguir "expirado" de "inválido" na resposta ajudaria o usuário, mas também confirmaria a um atacante que o token existiu. O front usa INVALID_TOKEN para mostrar a tela de "link inválido ou expirado" com a opção de pedir outro.

7. Critérios de aceite

  • [x] CA1. Dado um usuário ativo, quando solicita recuperação com o e-mail correto, então recebe um e-mail com link contendo o token e a API responde 202.
  • [x] CA2. Dado um e-mail não cadastrado, quando solicita recuperação, então a API responde 202 com a mesma mensagem do CA1 e nenhum e-mail é enviado.
  • [x] CA3. Dado um usuário inativo, quando solicita recuperação, então a resposta é idêntica ao CA2 e nenhum e-mail é enviado.
  • [x] CA4. Dado um link válido, quando o usuário define a nova senha, então consegue logar com ela e não consegue logar com a antiga.
  • [x] CA5. Dado um link já usado, quando o usuário tenta usá-lo de novo, então recebe INVALID_TOKEN.
  • [x] CA6. Dado um link com mais de 30 minutos, quando o usuário tenta usá-lo, então recebe INVALID_TOKEN.
  • [x] CA7. Dadas duas solicitações em sequência, quando o usuário usa o link da primeira, então recebe INVALID_TOKEN (RN6).
  • [x] CA8. Dado um usuário com sessão ativa em outro dispositivo, quando redefine a senha, então a requisição seguinte daquela sessão falha por token inválido (RN8).
  • [x] CA9. Dadas 4 solicitações em 30 minutos, então a 4ª responde 202 mas não gera token nem envia e-mail (RN7).
  • [x] CA10. A tela de login exibe o link de recuperação e ele leva à tela funcional.

8. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

  • [x] apps/api — PASSWORD_RESET no enum + User.sessions_valid_from
  • [x] apps/api — PASSWORD_RESET_TOKEN_EXPIRE_MINUTES na config
  • [x] apps/api — migration Alembic (enum + coluna)
  • [x] apps/api — auth_service.request_password_reset / reset_password
  • [x] apps/api — checagem de iat em get_current_user e no /auth/refresh
  • [x] apps/api — template password_reset.html + email_service.send_password_reset
  • [x] apps/api — routers forgot-password e reset-password com Swagger completo
  • [x] apps/api — docstrings (AutoAPI) no service e no model alterado
  • [x] apps/api — testes em tests/test_auth.py cobrindo CA1–CA9
  • [x] apps/web — forgotPassword reescrita + resetPassword nova
  • [x] apps/web — link no login, rotas públicas no App.tsx
  • [x] apps/web — forgotPassword/updatePassword no authProvider
  • [x] apps/web — todos os data-testid da lista acima
  • [x] apps/e2e — helper de leitura do Mailpit em fixtures/
  • [x] apps/e2e — password-reset.spec.ts
  • [x] make test e make lint sem apontamento novo nos arquivos tocados

9. Decisões em aberto

Ambas resolvidas em 2026-07-24, antes de implementar:

  • [x] Política de força de senha. → Manter min_length=8, mesma regra do cadastro e do convite (não há exigência de complexidade hoje). A redefinição não introduz régua nova. Endurecer a política vira feature própria, aplicada aos três pontos de uma vez. Consequência: o erro WEAK_PASSWORD fica previsto no contrato mas só dispara pelo min_length — não há caso de complexidade a testar por ora.
  • [x] APP_BASE_URL do link. → Implementar com o valor atual e registrar a pendência de produção. Antes de publicar, APP_BASE_URL precisa vir de configuração por ambiente (hoje aponta para o ambiente de dev de uma máquina). Anotado também no ROADMAP.md.

10. Registro

Implementada em 2026-07-24. Verificação: 147 testes de API (10 novos em test_password_reset.py cobrindo CA1–CA9 + RN10), tsc do web limpo, e 5 testes Playwright passando — incluindo o fluxo completo pelo link real do e-mail.

Divergências em relação ao planejado:

  1. Rotas públicas de recuperação ficaram sem <Authenticated>, não dentro do bloco público (§5 dizia "fora do Authenticated"; na prática isso significou rotas standalone no topo do <Routes>). Motivo: o wrapper <Authenticated fallback> das demais públicas redireciona ao login quando check() devolve redirectTo. Um link de reset chega por navegação direta e precisa renderizar mesmo com token residual — então /forgot-password e /reset-password não passam por guard.

  2. web: usei os hooks useForgotPassword/useUpdatePassword do Refine (com os métodos correspondentes no authProvider), em vez de fetch direto nas telas. Mesmo resultado, mais idiomático; satisfaz o item do authProvider da §5.

  3. Migration precisou reencadear. O head real era 67a730f73bd6, não o que a autonumeração sugeria — havia múltiplos heads. O ALTER TYPE ADD VALUE roda fora de transação (COMMIT antes) e o downgrade não remove o valor do enum (limitação do PostgreSQL), documentado na própria migration.

  4. e2e: o helper do Mailpit (fixtures/mailpit.ts) é a peça reutilizável prevista. Dois cuidados que só apareceram rodando: (a) o teste precisa navegar pela URL absoluta com /app, senão a barra inicial descarta o prefixo e o catch-all do Caddy redireciona; (b) um beforeEach limpa os tokens PASSWORD_RESET do usuário via psql, senão o RN7 (3/30min) faz execuções repetidas falharem.

Pendência registrada, não bloqueante: APP_BASE_URL ainda aponta para o ambiente de dev (ver §9 e ROADMAP.md) — precisa vir de config por ambiente antes de produção.