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
ChannelIntegrationjá 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 — incluiAGENCY_USEReADMIN_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-passwordretorna sempre202 Acceptedcom 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
202gené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_atnulo continua nulo (ver Fora de escopo).
5. Impacto por app¶
apps/api¶
Modelos/migrations
VerificationCodeType: acrescentarPASSWORD_RESET. O modelVerificationCodejá tem tudo que o fluxo precisa (code,expires_at,used_at,user_id) — não criar tabela nova.User: novo camposessions_valid_from: datetime | None. É o mecanismo de RN8 — hoje o JWT não tem revogação, mas já emiteiat, então basta rejeitar token cujoiatseja anterior a este campo.- Migration Alembic com as duas mudanças. O enum precisa de
ALTER TYPE ... ADD VALUEno PostgreSQL; conferir que o downgrade é viável ou documentar que não é.
Config
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30emcore/config.py, ao lado deINVITE_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)— validatype == PASSWORD_RESET,used_at IS NULL,expires_at > now; gravapassword_hash, marcaused_at, e setasessions_valid_from = now(RN8). Seguir o padrão já existente emonboarding_service.accept_invite()— a validação é a mesma.
Dependências
core/dependencies.get_current_user: rejeitar token cujoiat<sessions_valid_fromdo usuário, com o mesmo erro de sessão expirada que o front já trata.POST /auth/refresh: mesma checagem.
- 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 doAuthPagegenérico do Refine, sem rota noApp.tsxe sem link no login. Substituir por formulário próprio que chamaPOST /auth/forgot-passworde mostra a confirmação genérica.pages/resetPassword/index.tsx— tela nova. Lê o token da query string, pede nova senha + confirmação, chamaPOST /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-passworde/reset-passwordna área pública (fora doAuthenticated). Não entram nocustom-sider: são telas públicas, não módulos de navegação.providers/auth.ts— implementarforgotPasswordeupdatePassworddoauthProviderdo 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.tscobrindo 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/messagesno ambiente local. É a primeira spec que depende disso; criar um helper reutilizável emfixtures/. - 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" }
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" }
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
202com 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
202mas 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_RESETno enum +User.sessions_valid_from - [x]
apps/api—PASSWORD_RESET_TOKEN_EXPIRE_MINUTESna config - [x]
apps/api— migration Alembic (enum + coluna) - [x]
apps/api—auth_service.request_password_reset/reset_password - [x]
apps/api— checagem deiatemget_current_usere no/auth/refresh - [x]
apps/api— templatepassword_reset.html+email_service.send_password_reset - [x]
apps/api— routersforgot-passwordereset-passwordcom Swagger completo - [x]
apps/api— docstrings (AutoAPI) no service e no model alterado - [x]
apps/api— testes emtests/test_auth.pycobrindo CA1–CA9 - [x]
apps/web—forgotPasswordreescrita +resetPasswordnova - [x]
apps/web— link no login, rotas públicas noApp.tsx - [x]
apps/web—forgotPassword/updatePasswordnoauthProvider - [x]
apps/web— todos osdata-testidda lista acima - [x]
apps/e2e— helper de leitura do Mailpit emfixtures/ - [x]
apps/e2e—password-reset.spec.ts - [x]
make testemake lintsem 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 erroWEAK_PASSWORDfica previsto no contrato mas só dispara pelomin_length— não há caso de complexidade a testar por ora. - [x]
APP_BASE_URLdo link. → Implementar com o valor atual e registrar a pendência de produção. Antes de publicar,APP_BASE_URLprecisa vir de configuração por ambiente (hoje aponta para o ambiente de dev de uma máquina). Anotado também noROADMAP.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:
-
Rotas públicas de recuperação ficaram sem
<Authenticated>, não dentro do bloco público (§5 dizia "fora doAuthenticated"; na prática isso significou rotas standalone no topo do<Routes>). Motivo: o wrapper<Authenticated fallback>das demais públicas redireciona ao login quandocheck()devolveredirectTo. Um link de reset chega por navegação direta e precisa renderizar mesmo com token residual — então/forgot-passworde/reset-passwordnão passam por guard. -
web: usei os hooksuseForgotPassword/useUpdatePassworddo Refine (com os métodos correspondentes noauthProvider), em vez defetchdireto nas telas. Mesmo resultado, mais idiomático; satisfaz o item doauthProviderda §5. -
Migration precisou reencadear. O head real era
67a730f73bd6, não o que a autonumeração sugeria — havia múltiplos heads. OALTER TYPE ADD VALUEroda fora de transação (COMMITantes) e o downgrade não remove o valor do enum (limitação do PostgreSQL), documentado na própria migration. -
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) umbeforeEachlimpa os tokensPASSWORD_RESETdo usuário viapsql, 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.