Bundled auth flow (signup / activate / login / reset)¶
Desde v0.31.0 o SDK fornece o ciclo completo de conta local — signup com email/senha, ativação por link, login com JWT pair, reset de senha — via UserAuthService + make_auth_router. Endpoints prontos pra mount (incluindo POST /auth/refresh desde v0.65.0), templates Jinja2 bundled, settings flags controlando se o link sai por e-mail ou no body da resposta, e cinco modos pré-pensados: produção, dev com SMTP local, dev sem SMTP, CI e backend-only.
Vá direto ao seu caso
A tabela abaixo mapeia o que o usuário quer fazer → a seção e os endpoints. Comece pelo seu caso; volte ao setup só quando precisar ligar as peças.
| Quero… | Está logado? | Seção | Endpoints |
|---|---|---|---|
| Criar conta + ativar por e-mail | — | Setup | signup → activate/{token} |
| Testar tudo sem frontend (curl, Swagger, app mobile) | — | Só o backend | signup → activate/{token} → login → rota protegida |
| O e-mail de ativação não chegou | ❌ não | Reenviar a ativação | activation/request → activate/{token} |
| Entrar | — | Endpoints | login |
| Esqueci minha senha | ❌ não | Recuperação de senha | password-reset/request → password-reset/confirm |
| Trocar minha senha | ✅ sim | Trocar senha logado | password-change |
| Trocar meu e-mail | ✅ sim | Trocar e-mail | email-change/request → email-change/confirm |
| Re-verificar meu e-mail atual | ✅ sim | Re-verificar e-mail | email-verify/request → email-verify/confirm |
| Perdi acesso ao meu e-mail | ❌ não | Recuperação de e-mail | email-recovery/request → email-change/confirm |
| Minha sessão expirou | — | Refresh | refresh |
Os dois "esqueci": senha perdida → Recuperação de senha; caixa de e-mail perdida → Recuperação de e-mail.
Conteúdo da receita¶
- Setup mínimo — instalação dos extras + wiring de quatro objetos (
AsyncDatabaseManager,EmailUtils,UserAuthService,make_auth_router). - UserTokenModel concreto —
BaseUserTokenModelé abstrato, projeto cria a tabela final. - Endpoints — tabela de todos os endpoints + payload + comportamento.
- Limite de tentativas — o login e o cadastro com freio contra força bruta, sem revelar quais e-mails existem.
- Só o backend — o ciclo cadastro → ativação → login → rota protegida sem frontend, medido com
curl, e o mesmo ciclo num teste.
- Reenviar a ativação — o e-mail de ativação não chegou.
- Recuperação de senha — o fluxo "esqueci a senha", passo a passo, mais trocar a senha logado.
- Troca e recuperação de e-mail — trocar e-mail logado, re-verificar, e recuperar quando a caixa se perdeu.
- Settings — variáveis de ambiente — env vars em grupos (JWT, política de senha, fluxo de e-mail, TTL, URLs/templates, páginas backend) — cada uma em tabela tipada, não num blob.
- Anatomia de um e-mail: como link, template e URL se encaixam — desambigua os três conceitos que mais confundem.
- Idioma dos e-mails e páginas (i18n) — idioma padrão, preferência por usuário e tradução própria.
- Cinco modos de operação — produção, dev com SMTP local (Mailhog / smtp4dev), dev sem SMTP, CI sem ativação e backend-only (links e páginas servidas direto pelo backend).
- Entrega de token — bearer, cookie ou os dois.
- Mailhog vs smtp4dev — qual escolher pra dev local — comparativo + receitas docker-compose copy-paste.
- Customizando os templates de e-mail — override do
activation.htmlepassword_reset.html+ variáveis disponíveis no contexto Jinja2. - Segurança — como o token é armazenado, TTL, anti-enumeração.
- Pegando o
current_userda requisição — a dependency de usuário autenticado nas suas rotas. - Próximos passos.
Setup mínimo¶
Requer:
[auth](bcrypt + PyJWT) — obrigatório, sempre.[email](aiosmtplib + Jinja2 + email-validator) — opcional; quando ausente, os links vão no body da resposta em vez de e-mail.
São dois arquivos, e a ordem entre eles importa. Os objetos compartilhados
— conexão, e-mail, service — moram em src/api/dependencies/; o
src/api/app.py importa de lá e só monta a aplicação:
# src/api/dependencies/resources.py
from tempest_fastapi_sdk import AsyncDatabaseManager, EmailUtils, UserAuthService
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
db = AsyncDatabaseManager(settings.DATABASE_URL)
# EmailUtils — só instancie se [email] estiver instalado E você quiser e-mail
# real (modos A e B abaixo). Nos modos C e D, passe email=None pro service.
emails = EmailUtils(
host=settings.SMTP_HOST,
port=settings.SMTP_PORT,
username=settings.SMTP_USERNAME,
password=settings.SMTP_PASSWORD,
from_addr=settings.SMTP_FROM_ADDR,
template_dir="emails", # diretório onde seus templates custom moram
)
auth_service = UserAuthService(
db=db, # necessário pra current_user_dependency (ver seção final)
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings, # mistura AuthSettings (ver seção 4)
jwt_settings=settings, # mistura JWTSettings
email=emails, # ou None — controla envio real vs link no body
)
# src/api/app.py
from fastapi import FastAPI
from tempest_fastapi_sdk import make_auth_router
from src.api.dependencies.resources import auth_service, db
app = FastAPI()
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
),
)
O mesmo get_session do resto do app
session_factory aceita a dependency de sessão que o app já usa, inclusive
com sub-dependências (get_session(database=Depends(get_database))): uma
função geradora (async ou não) é registrada como Depends(session_factory),
então app.dependency_overrides[get_session] vale também para
/auth/login e as outras rotas de auth. Outro callable sem argumentos que
devolve um iterador async continua funcionando como antes. A mesma regra
vale para todo router do SDK que recebe session_factory: push, Web Push,
chat, reviews, wallet, sessões, voz e app-errors.
O service não pode morar no app.py
É tentador criar o UserAuthService ao lado do app e importá-lo de
volta nas dependencies — e é um ciclo de import: app.py importa os
routers, os routers importam dependencies/, e esse importaria app.py
de novo. O erro sai longe da causa (ImportError: cannot import name
'auth_service' from partially initialized module), então a direção fica
fixa desde o começo: dependencies não importa app. É também o que
deixa o service testável sem subir a aplicação.
TL;DR de quatro objetos
AsyncDatabaseManager → conexão. EmailUtils → SMTP + Jinja2. UserAuthService → regras de negócio. make_auth_router → cola tudo nos endpoints HTTP: 13 com os settings default, mais os de MFA, cookie, login social, WebAuthn e páginas backend quando ligados.
UserTokenModel concreto¶
BaseUserTokenModel é abstrato — projeto cria a tabela concreta porque a FK pra users precisa do nome da sua tabela. Exemplo src/db/models/user_token.py:
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseUserTokenModel
class UserTokenModel(BaseUserTokenModel):
"""Concrete token table for activation / reset / email-verification."""
__tablename__ = "user_tokens"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
E importar em src/db/models/__init__.py pra Alembic ver:
from src.db.models.user import UserModel
from src.db.models.user_token import UserTokenModel
__all__: list[str] = ["UserModel", "UserTokenModel"]
Gerar migration (a primeira vez, bootstrape o Alembic com tempest db init):
# Só na primeira vez — gera alembic/, alembic.ini e env.py:
uv run tempest db init
# A partir daí, ciclo normal de revisão:
uv run tempest db revision -m "users + user_tokens"
uv run tempest db upgrade
Endpoints¶
| Método | Path | Body / Output | Comportamento |
|---|---|---|---|
| POST | /auth/signup |
SignupSchema → SignupResponseSchema |
Cria user. Não montado quando AUTH_SIGNUP_ENABLED=false (v0.272.0+). Emite e-mail (modos A/B) ou devolve link no body (modo C). Se AUTH_AUTO_ACTIVATE=True, user nasce ativo e JWT pair volta direto (modo D). |
| POST | /auth/activate/{token} |
— → ActivationResponseSchema |
Consome token + is_active=True + emite JWT pair. Conta recém-cadastrada não tem fator, então nada muda; mas se o token veio de /auth/activation/request para uma conta desativada que tinha MFA inscrito, responde o desafio (mfa_required=true + mfa_token) em vez do par. |
| POST | /auth/activation/request (v0.297.0+) |
ActivationRequestSchema → ActivationResendResponseSchema |
Não autenticado. Reemite o link de ativação de uma conta que nunca ativou. Sempre 202 + corpo genérico. |
| POST | /auth/login |
LoginSchema → LoginResponseSchema |
Email + senha → JWT pair. Erros genéricos (não enumera contas). |
| GET | /auth/me (v0.198.0+) |
— → AuthUserSchema |
Autenticado. Devolve a conta dona do bearer token. Nunca serializa o hash da senha: o handler entrega o modelo inteiro e o response_model filtra. Troque o schema por um seu com me_response_model= pra expor colunas próprias. |
| POST | /auth/password-reset/request |
PasswordResetRequestSchema → PasswordResetResponseSchema |
Sempre HTTP 202 + corpo genérico. Link via e-mail (A/B) ou no body (C). |
| POST | /auth/password-reset/confirm |
PasswordResetConfirmSchema → LoginResponseSchema |
Consome token + grava nova senha + revoga as sessões + emite JWT pair. Com MFA inscrito na conta, responde o desafio (mfa_required=true + mfa_token) em vez do par — conclua em /auth/mfa/verify. |
| POST | /auth/password-change |
PasswordChangeSchema → 204 |
Autenticado (bearer token). Troca a própria senha: confirma a senha atual + grava a nova. Sem token de e-mail. |
| POST | /auth/email-change/request (v0.92.0+) |
EmailChangeRequestSchema → EmailChangeResponseSchema |
Autenticado. Confirma a senha atual + envia link de confirmação pro novo e-mail. Sempre 202. |
| POST | /auth/email-change/confirm (v0.92.0+) |
EmailChangeConfirmSchema → EmailChangeResponseSchema |
Consome o token + aplica o novo e-mail + (opcional) avisa o e-mail antigo. |
| POST | /auth/email-verify/request (v0.92.0+) |
— → EmailChangeResponseSchema |
Autenticado. Reenvia link de verificação pro e-mail atual. |
| POST | /auth/email-verify/confirm (v0.92.0+) |
EmailChangeConfirmSchema → EmailChangeResponseSchema |
Consome o token + marca a conta ativa. |
| POST | /auth/email-recovery/request (v0.92.0+, opt-in) |
EmailRecoveryRequestSchema → EmailChangeResponseSchema |
Não autenticado. Recupera conta cujo e-mail se perdeu: senha (+ MFA se inscrito). Só montado com AUTH_EMAIL_RECOVERY_ENABLED=True. Sempre 202. |
| GET | /auth/oauth/{provider}/login (v0.273.0+) |
— → 302 |
Sorteia o state, grava no cookie HttpOnly e redireciona ao provedor. Só montado com AUTH_OAUTH_ENABLED=true. Veja a receita de login social. |
| GET | /auth/oauth/{provider}/callback (v0.273.0+) |
— → LoginResponseSchema |
Confere o state, troca o code, resolve (provider, subject) no banco e emite o mesmo par JWT do /auth/login — typ, refresh opaco, rotação e /auth/logout incluídos. Conta com TOTP recebe o desafio (mfa_required=true + mfa_token), como no /auth/login (v0.314.0+). |
| GET | /auth/oauth/accounts (v0.273.0+) |
— → list[OAuthAccountSchema] |
Autenticado. Provedores ligados à conta. Lista vazia é 200, não 404. |
| POST | /auth/oauth/accounts/unlink (v0.273.0+) |
OAuthUnlinkSchema → 204 |
Autenticado. Desliga um provedor. 404 quando não está ligado nesta conta. |
| POST | /auth/refresh (v0.65.0+) |
RefreshSchema → LoginResponseSchema |
Troca um refresh token válido por um JWT pair novo. Sem email/senha. Rejeita access token replayado (401) e conta inativa (403). |
password-reset/confirm vs password-change — qual é qual?
São fluxos diferentes, não confunda:
/auth/password-reset/confirm— o usuário esqueceu a senha. Ele não está logado; prova identidade com o token que recebeu por e-mail. (Veja/auth/password-reset/requestantes.)/auth/password-change— o usuário lembra a senha e está logado. Manda oaccess_tokenno headerAuthorization: Bearer …e reconfirma acurrent_password. Não envolve e-mail nem token de reset. Retorna 204 e os tokens atuais continuam válidos.
Sistema fechado — sem porta de cadastro (v0.272.0+)¶
Nem todo serviço quer POST /auth/signup no ar. Num sistema fechado —
conta criada só por administrador, nunca por quem chega na porta — a rota de
cadastro público é exatamente o buraco que não se quer aberto.
Desligue por variável de ambiente:
Ou por router, quando o mesmo processo monta mais de um e só um deles é fechado:
# src/api/app.py
from fastapi import FastAPI
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
UserAuthService,
make_auth_router,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
app = FastAPI()
db = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
email=None,
)
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
allow_signup=False,
),
)
O argumento vence a setting nos dois sentidos; allow_signup=None (o
default) delega para AUTH_SIGNUP_ENABLED. É o mesmo par que
token_delivery já formava com AUTH_TOKEN_DELIVERY.
O que o serviço passa a responder:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"quem-chegou@example.com","password":"strong-pass-12"}'
404
Filtrar router.routes depois de montar não é equivalente
O workaround que sobrava antes era remover a rota da lista já construída:
router.routes = [
route for route in router.routes
if getattr(route, "path", "") != "/auth/signup"
]
Ele casa uma string de path — quebra em silêncio no dia em que o
caminho mudar — e não diz nada ao schema: o /openapi.json continua
anunciando uma porta que não existe mais. Com AUTH_SIGNUP_ENABLED
a rota nunca é registrada, então some da aplicação e do schema.
A ativação continua montada — de propósito
Desligar o signup não desmonta /auth/activate/{token}. Conta
criada por um administrador também precisa ser ativada, e é esse
endpoint que consome o token do e-mail. Nada além de
POST /auth/signup muda de lugar.
Recap. AUTH_SIGNUP_ENABLED=false fecha o cadastro público sem tocar em
nenhum outro endpoint; allow_signup= sobrescreve por router; a rota some do
OpenAPI junto, que é o que o filtro manual nunca entregou.
Conta que nasce com mais que e-mail e senha (v0.278.0+)¶
Quase nenhum produto tem uma conta que é só email, password e name. Tem o
telefone, tem o CPF, tem a flag que separa cliente de produtor. Até aqui a saída
era desligar o POST /auth/signup e escrever a rota na mão — e a rota na mão
tinha que reconstruir a política de senha, o 409 de e-mail duplicado, o ramo de
ativação e a emissão do par JWT. Quatro coisas que a rota do SDK já acerta, e
que passam a driftar uma release por vez.
Um argumento resolve o caso comum: signup_schema troca o corpo da
requisição, e todo campo novo dele que também é coluna do seu UserModel é
gravado na linha antes do insert (v0.305.0+). Para o que não é coluna —
um aceite de termos, um campo derivado — existe on_signup.
# src/api/app.py
from fastapi import FastAPI
from pydantic import Field
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
SignupSchema,
UserAuthService,
ValidationException,
make_auth_router,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
class ProducerSignupSchema(SignupSchema):
"""O cadastro deste produto: e-mail, senha, nome — e o resto."""
phone: str = Field(max_length=20)
is_producer: bool = Field(default=False)
accept_terms: bool = Field(default=False)
async def require_terms(
session: AsyncSession,
user: UserModel,
payload: ProducerSignupSchema,
) -> None:
"""Recusa o cadastro sem aceite dos termos.
Args:
session (AsyncSession): A transação em que a linha foi inserida.
user (UserModel): A instância recém-criada.
payload (ProducerSignupSchema): O corpo validado.
Raises:
ValidationException: Quando ``accept_terms`` é falso.
"""
if not payload.accept_terms:
raise ValidationException(message="accept the terms to sign up")
app = FastAPI()
db = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
email=None,
)
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
signup_schema=ProducerSignupSchema,
on_signup=require_terms,
),
)
Aqui phone e is_producer são colunas do UserModel (phone é
NOT NULL e UNIQUE), então chegam à linha sem hook nenhum. accept_terms
não é coluna: fica só no payload que o on_signup recebe.
Com AUTH_AUTO_ACTIVATE=true no .env a conta nasce ativa e o signup já devolve o par de JWT. No default (false) a mesma chamada responde "activation_required":true com access_token e refresh_token em null, e o par só sai no POST /auth/activate/{token}:
$ curl -s -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"strong-pass-12","phone":"5511999999999","is_producer":true,"accept_terms":true}'
{"user_id":"0e5cf2fc-…","activation_required":false,"activation_url":null,"access_token":"eyJ…","refresh_token":"eyJ…"}
Um segundo cadastro com o mesmo telefone responde 409, com a coluna em
details e em field — não um 500 de IntegrityError:
$ curl -s -X POST localhost:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"bia@example.com","password":"strong-pass-12","phone":"5511999999999","accept_terms":true}'
{"detail":"phone already in use","code":"CONFLICT","details":{"columns":["phone"],"constraint":null},"field":"phone"}
O constraint sai null no SQLite, que não nomeia a constraint de unicidade;
no PostgreSQL vem o nome (users_phone_key, por exemplo). O detail acima é o
de um app sem catálogo; com register_exception_handlers(app,
catalog=default_message_catalog()) ele sai traduzido (Conflito de recurso),
e code, details e field não mudam.
Coluna protegida nunca vem do corpo
id, email, hashed_password, is_active e is_admin
(SIGNUP_PROTECTED_FIELDS) são do SDK. Um signup_schema com um campo
is_admin não promove ninguém: a rota descarta esses nomes antes de
gravar. Chamando o service direto, signup(fields={"is_admin": True})
levanta ValueError.
Anote o hook com as suas classes — o model concreto e o schema concreto —
como acima: é o que faz payload.accept_terms passar no type-checker do seu
lado.
O schema precisa herdar de SignupSchema — a rota lê email, password e
name pelo nome, então um corpo que não os traz derruba o make_auth_router
na hora da montagem, não no primeiro request. Os campos novos entram no
/openapi.json junto: o cliente gerado enxerga phone e is_producer sem
ninguém escrever schema à mão.
Se o seu service sobrescreve signup
Declare fields: Mapping[str, Any] | None = None no override e repasse para
super().signup(...): é por ele que as colunas do signup_schema chegam
antes do insert. Um override sem fields continua funcionando — a rota o
chama sem esse argumento, como antes da 0.305.0, e avisa no log, na
montagem do router, quais colunas ficaram para o on_signup preencher.
Sem router: signup(fields=...)
O mesmo caminho está no service, para quem monta a própria rota:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
async def create_driver(
service: UserAuthService,
session: AsyncSession,
email: str,
password: str,
phone: str,
) -> None:
"""Cria o motorista com o telefone obrigatório já no insert.
Args:
service (UserAuthService): O service de auth.
session (AsyncSession): A sessão da requisição.
email (str): E-mail da conta.
password (str): Senha em texto puro.
phone (str): Telefone, coluna ``NOT NULL`` e única.
"""
await service.signup(
session,
email=email,
password=password,
fields={"phone": phone},
)
await session.commit()
Chave que não é coluna do model vira ValueError antes de qualquer
escrita. Coluna NOT NULL esquecida também vira ValueError, com o nome
dela na mensagem — é defeito de montagem, não erro do cliente.
O hook roda antes do commit, e isso é o ponto
on_signup é aguardado logo depois do insert e antes do
session.commit(), na mesma transação. Um hook que levanta desfaz a
conta junto.
A alternativa — escrever os campos depois, num segundo commit — é o defeito
clássico: um CPF recusado deixa para trás uma conta com e-mail, senha e
nenhum dos campos que a tornam usável. Ela não aparece em lugar nenhum do
produto, mas ocupa o e-mail (que é UNIQUE), responde ao
POST /auth/password-reset/request e bloqueia o cadastro correto com um
409 que ninguém consegue explicar.
O que ainda não muda
A resposta continua sendo SignupResponseSchema. Se o cliente precisa do
perfil completo logo depois do cadastro, o GET /auth/me já responde com
ele — e aceita o seu próprio schema por me_response_model.
Recap. signup_schema é o contrato de entrada — subclasse de
SignupSchema, visível no OpenAPI —, e os campos dele que são coluna entram na
linha antes do insert. on_signup é para o resto, dentro da transação do
insert. Nada mais precisa sair do SDK para o seu serviço.
Limite de tentativas no login e no cadastro¶
Sem limite, POST /auth/login responde 401 a cada senha errada para sempre —
força bruta é só questão de paciência — e POST /auth/signup cria conta sem
teto. O router já traz os dois freios, no molde do mfa_throttle:
| Parâmetro | Chave | Conta | Default |
|---|---|---|---|
login_throttle |
e-mail normalizado (SHA-256) | toda tentativa, zerada no login certo | ligado: 5 por 900 s, em memória |
login_ip_throttle |
IP do cliente | só credencial errada; login certo não zera | desligado |
signup_throttle |
IP do cliente | toda tentativa, certa ou não | desligado |
import asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient, Response
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AttemptThrottle,
AuthSettings,
BaseUserModel,
InMemoryThrottleBackend,
JWTSettings,
UserAuthService,
make_auth_router,
make_user_token_model,
register_exception_handlers,
)
class UserModel(BaseUserModel):
__tablename__ = "users"
UserTokenModel = make_user_token_model(user_table="users")
async def main() -> None:
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables()
service: UserAuthService = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=AuthSettings(AUTH_AUTO_ACTIVATE=True),
jwt_settings=JWTSettings(),
email=None,
)
app: FastAPI = FastAPI()
register_exception_handlers(app)
app.include_router(
make_auth_router(
service,
session_factory=db.session_dependency,
login_ip_throttle=AttemptThrottle(
InMemoryThrottleBackend(), max_attempts=20, window_seconds=900
),
signup_throttle=AttemptThrottle(
InMemoryThrottleBackend(), max_attempts=10, window_seconds=3600
),
trusted_ip_header="x-real-ip",
)
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
await client.post(
"/auth/signup",
json={"email": "ana@example.com", "password": "senha-forte-123"},
)
for password in ["senha-errada-123"] * 5 + ["senha-forte-123"]:
response: Response = await client.post(
"/auth/login",
json={"email": "ana@example.com", "password": password},
)
print(response.status_code, response.headers.get("retry-after"))
print(response.json())
await db.disconnect()
asyncio.run(main())
Saída:
401 None
401 None
401 None
401 None
401 None
429 900
{'detail': 'too many login attempts, try again later', 'code': 'LOGIN_THROTTLED', 'details': {'retry_after_seconds': 900}}
A sexta tentativa leva 429 com a senha certa: o orçamento é reservado
antes de a senha ser conferida, então uma rajada de chutes em paralelo não
passa toda junta por um "ainda não bloqueado". O login certo antes do limite
zera a contagem do e-mail.
O 429 não revela se o e-mail existe
O orçamento do e-mail é gasto do mesmo jeito para um endereço sem conta, e o
detail é o mesmo texto nos dois casos. Quem sonda o login aprende que
errou demais, não quais e-mails estão cadastrados.
Por que os limites por IP vêm desligados
Atrás de um proxy, o IP que o app vê é o do proxy — sem
trusted_ip_header, todo cliente cai no mesmo balde, e um orçamento por IP
trava o login (ou o cadastro) de todo mundo de uma vez. Ligue
login_ip_throttle e signup_throttle junto com o trusted_ip_header que
a sua borda sobrescreve ("x-real-ip"), nunca com X-Forwarded-For.
Mais quatro coisas que valem saber:
- Com mais de um worker, passe um backend compartilhado. O default conta
em memória, por processo;
AttemptThrottle(redis, max_attempts=5, window_seconds=900)divide o contador entre réplicas. login_throttle=Falsedesliga o limite por e-mail, para quem já limita o login em outra camada.- Bloquear por e-mail tem custo: quem conhece o e-mail de alguém consegue gastar o orçamento e deixar o dono de fora por uma janela. É a troca de todo bloqueio por conta; a janela curta é o que a deixa aceitável.
- O resto do router não muda.
/auth/me,/auth/refreshe as demais rotas não passam por nenhum desses limites, e o OpenAPI só declara o429nas rotas de login e em/auth/signupquando o limite correspondente está ligado.
Recap. O login vem protegido por e-mail sem você fazer nada; os limites por IP você liga quando sabe de onde vem o IP real.
Só o backend: do cadastro à rota protegida¶
Você ainda não tem frontend. Talvez esteja testando a API com curl ou pelo
Swagger, escrevendo um app mobile, ou quer que o link do e-mail ative a conta
direto no backend. O default do AUTH_ACTIVATION_URL_TEMPLATE aponta para
http://localhost:3000/activate?token={token} — uma página de frontend que,
no seu caso, não existe.
Esta seção roda o ciclo inteiro só com a API: cadastrar, pegar o token de
ativação, ativar, logar e chamar uma rota protegida. Cada saída abaixo saiu de
um curl contra o app desta página.
Passo 1 — o app inteiro num arquivo¶
[auth] traz bcrypt e PyJWT, [sqlite] traz o driver aiosqlite, e
[email] traz o Jinja2 que as páginas HTML do Modo E
renderizam. O uvicorn é o servidor.
# main.py
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import uvicorn
from fastapi import Depends, FastAPI
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AuthSettings,
AuthUserSchema,
BaseUserModel,
DatabaseSettings,
JWTSettings,
UserAuthService,
make_auth_router,
make_user_token_model,
)
class Settings(DatabaseSettings, JWTSettings, AuthSettings):
"""Settings lidas do ambiente (e do `.env`)."""
class UserModel(BaseUserModel):
"""A tabela de contas."""
__tablename__ = "users"
UserTokenModel = make_user_token_model(user_table="users")
settings: Settings = Settings()
db: AsyncDatabaseManager = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service: UserAuthService = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
email=None,
)
get_current_user = auth_service.current_user_dependency()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Abre o banco e cria as tabelas no startup.
Args:
app (FastAPI): A aplicação que está subindo.
Yields:
None: Devolve o controle ao FastAPI enquanto o app atende.
"""
await db.connect()
await db.create_tables()
yield
await db.disconnect()
app: FastAPI = FastAPI(lifespan=lifespan)
app.include_router(
make_auth_router(auth_service, session_factory=db.session_dependency),
)
@app.get("/api/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> AuthUserSchema:
"""Devolve a conta dona do bearer token.
Args:
current (UserModel): O usuário resolvido a partir do token.
Returns:
AuthUserSchema: A visão pública da conta.
"""
return AuthUserSchema.model_validate(current)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
Três linhas fazem o trabalho desta seção:
email=None— semEmailUtils, o link de ativação volta no corpo dosignup(campoactivation_url). Não precisa deAUTH_RETURN_TOKEN_IN_RESPONSE=true: comemail=Noneo link aparece mesmo assim, e é o único lugar onde ele aparece.db.create_tables()— criauserseuser_tokensno SQLite (./app.db, o default doDATABASE_URL). Bom para dev; em produção o schema vem do Alembic, como em UserTokenModel concreto.current_user_dependency()— a dependency que protege/api/profile. Detalhe em Pegando ocurrent_user.
Passo 2 — o .env de dev sem frontend¶
# .env
AUTH_BACKEND_LINKS=true
AUTH_ACTIVATION_URL_TEMPLATE=http://127.0.0.1:8000/auth/activate/{token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=http://127.0.0.1:8000/auth/password-reset/{token}
AUTH_BACKEND_LINKS=true liga o Modo E: o
backend passa a montar GET /auth/activate/{token}, uma página HTML que
ativa a conta quando alguém abre o link. O template aponta para essa rota,
então o activation_url que você recebe é um link que funciona sozinho.
127.0.0.1 só vale porque o link abre na sua máquina
Em dev, quem abre o link é você, no mesmo computador do servidor. Em produção o link abre no browser do usuário, e o host tem de ser o domínio público do backend — veja o aviso de prefixo no Modo E.
Suba o servidor:
Passo 3 — cadastrar¶
curl -X POST 127.0.0.1:8000/auth/signup \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "senha-forte-123"}'
201 Created:
{
"user_id": "69ce003d-1384-43a6-86df-01e547a03f13",
"activation_required": true,
"activation_url": "http://127.0.0.1:8000/auth/activate/5wScVz_EX6rW-PGCYkaDYpv0dGjjm779J3-HbcVW6nswr_Lao7Q_ynzwicIrQzZe",
"access_token": null,
"refresh_token": null
}
O token é o último pedaço do activation_url. A conta existe, mas ainda
está inativa — logar agora responde 401 com
{"detail": "invalid email or password"}, a mesma frase de senha errada,
para não contar a ninguém que o e-mail tem conta.
Passo 4 — ativar¶
Escolha um dos dois caminhos. O token é de uso único, então o segundo uso responde erro.
Uso único também sob concorrência
O resgate é um UPDATE ... WHERE used_at IS NULL RETURNING só, e o banco
escolhe o vencedor. Oito trocas de senha simultâneas com o mesmo link,
em sessões separadas, dão um sucesso e sete InvalidTokenException
(tests/auth/test_consume_token_race.py, em SQLite e PostgreSQL); até a
0.304.0 as oito passavam no PostgreSQL. Vale para ativação, troca de
senha e troca de e-mail. RETURNING exige PostgreSQL ou SQLite 3.35+.
Abra o activation_url no browser, ou:
curl -i http://127.0.0.1:8000/auth/activate/5wScVz_EX6rW-PGCYkaDYpv0dGjjm779J3-HbcVW6nswr_Lao7Q_ynzwicIrQzZe
200 OK com content-type: text/html e a página Conta ativada. É
o que o usuário do Modo E vê ao clicar no e-mail. Abrir o mesmo link de
novo responde 400 com a página Falha na ativação.
Esta página não entrega JWT (token em URL vaza para histórico e log), então o próximo passo é o login.
curl -X POST 127.0.0.1:8000/auth/activate/5wScVz_EX6rW-PGCYkaDYpv0dGjjm779J3-HbcVW6nswr_Lao7Q_ynzwicIrQzZe
200 OK com o par JWT — a conta já sai ativada e logada:
{
"user_id": "69ce003d-1384-43a6-86df-01e547a03f13",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…"
}
Repetir responde 401 com {"detail": "token already used"}. Esta rota
existe em todos os modos; a de GET só com AUTH_BACKEND_LINKS=true —
sem ele, abrir o link no browser responde 405 Method Not Allowed.
Passo 5 — logar e chamar a rota protegida¶
curl -X POST 127.0.0.1:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "password": "senha-forte-123"}'
200 OK:
{
"user_id": "69ce003d-1384-43a6-86df-01e547a03f13",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"mfa_required": false,
"mfa_token": null
}
Mande o access_token no header Authorization:
200 OK:
{
"id": "69ce003d-1384-43a6-86df-01e547a03f13",
"is_active": true,
"created_at": "2026-09-27T22:16:42.190695Z",
"updated_at": "2026-09-27T22:16:48.642307Z",
"email": "ana@example.com",
"is_admin": false,
"last_login_at": "2026-09-27T22:16:48.641560Z"
}
Sem o header, a mesma rota responde 401 com
{"detail": "Authorization token is missing or invalid"}.
Pelo Swagger
Abra http://127.0.0.1:8000/docs. POST /auth/signup devolve o
activation_url; cole o token em POST /auth/activate/{token}; copie o
access_token da resposta, clique em Authorize (o esquema
HTTPBearer que a dependency registra) e cole. A partir daí, o
Try it out de /api/profile já vai autenticado.
E o app mobile?¶
O app não tem página web para receber o link, mas pode receber um deep
link. O template é uma string .format() sem validação de esquema, então
aceita o do seu app:
O signup passa a devolver
"activation_url": "meuapp://ativar?token=f8vX7Qa…". O app abre por esse
link, lê o token da query string e chama POST /auth/activate/{token} —
o caminho JSON do passo 4, que já devolve o par JWT. Aqui
AUTH_BACKEND_LINKS pode ficar desligado: quem trata o link é o app, não o
backend.
O mesmo ciclo num teste, com o e-mail capturado¶
Em teste você quer o caminho de produção — link no e-mail, não no corpo.
O FakeEmailUtils é um EmailUtils que guarda cada mensagem em outbox em
vez de abrir conexão SMTP:
# tests/test_auth_flow.py
import re
from collections.abc import AsyncIterator
import pytest
from fastapi import Depends, FastAPI
from httpx import ASGITransport, AsyncClient, Response
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
AuthSettings,
AuthUserSchema,
BaseUserModel,
JWTSettings,
UserAuthService,
make_auth_router,
make_user_token_model,
)
from tempest_fastapi_sdk.testing.fakes import FakeEmailUtils
class UserModel(BaseUserModel):
"""A tabela de contas."""
__tablename__ = "users"
UserTokenModel = make_user_token_model(user_table="users")
@pytest.fixture
async def mailer() -> FakeEmailUtils:
"""Devolve um mailer cujas mensagens caem em `outbox`.
Returns:
FakeEmailUtils: O mailer fake.
"""
return FakeEmailUtils()
@pytest.fixture
async def client(mailer: FakeEmailUtils) -> AsyncIterator[AsyncClient]:
"""Serve o router de auth sobre um SQLite em memória.
Args:
mailer (FakeEmailUtils): Onde o e-mail de ativação cai.
Yields:
AsyncClient: Um client ligado ao app, sem socket.
"""
db: AsyncDatabaseManager = AsyncDatabaseManager("sqlite+aiosqlite:///:memory:")
await db.create_tables()
service: UserAuthService = UserAuthService(
db=db,
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=AuthSettings(
AUTH_BACKEND_LINKS=True,
AUTH_ACTIVATION_URL_TEMPLATE="http://test/auth/activate/{token}",
),
jwt_settings=JWTSettings(),
email=mailer,
)
get_current_user = service.current_user_dependency()
app: FastAPI = FastAPI()
app.include_router(make_auth_router(service, session_factory=db.session_dependency))
@app.get("/api/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> AuthUserSchema:
"""Devolve a conta dona do bearer token.
Args:
current (UserModel): O usuário resolvido a partir do token.
Returns:
AuthUserSchema: A visão pública da conta.
"""
return AuthUserSchema.model_validate(current)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as opened:
yield opened
await db.disconnect()
async def test_signup_activate_login_profile(
client: AsyncClient,
mailer: FakeEmailUtils,
) -> None:
"""Leva a conta do cadastro até uma rota protegida.
Args:
client (AsyncClient): O client do app.
mailer (FakeEmailUtils): A caixa de saída capturada.
"""
credentials: dict[str, str] = {"email": "ana@example.com", "password": "senha-forte-123"}
signup: Response = await client.post("/auth/signup", json=credentials)
assert signup.status_code == 201
assert signup.json()["activation_url"] is None
match = re.search(r"http://test/auth/activate/\S+", mailer.outbox[0].body)
assert match is not None
link: str = match.group(0)
page: Response = await client.get(link)
assert page.status_code == 200
assert "text/html" in page.headers["content-type"]
login: Response = await client.post("/auth/login", json=credentials)
assert login.status_code == 200
token: str = login.json()["access_token"]
profile: Response = await client.get(
"/api/profile",
headers={"Authorization": f"Bearer {token}"},
)
assert profile.status_code == 200
assert profile.json()["is_active"] is True
Repare no assert signup.json()["activation_url"] is None: com um
EmailUtils configurado, o link sai do corpo e vai só para o e-mail —
é o que prova que o teste exercita o caminho de produção. As fixtures
async pedem asyncio_mode = "auto" no [tool.pytest.ini_options] do
pyproject.toml (com pytest-asyncio instalado).
Instalou só [auth]?
O caminho JSON funciona com [auth,sqlite]. Com AUTH_BACKEND_LINKS=true
e sem o Jinja2 do [email], o make_auth_router recusa subir e diz o
extra que falta — antes, o router subia, e o primeiro clique no link
ativava a conta e respondia 500.
Recap.
- Sem frontend, o link vai no corpo do
signupenquantoemail=None; com e-mail real, só no e-mail. POST /auth/activate/{token}ativa e já devolve o par JWT;GETno mesmo path é a página HTML do Modo E, que pede login depois.AUTH_ACTIVATION_URL_TEMPLATEaponta para quem vai abrir o link: o frontend, o backend (Modo E) ou o app (deep link).- O
access_tokenvai emAuthorization: Bearer …em qualquer rota protegida porcurrent_user_dependency().
Reenviar a ativação¶
O e-mail de ativação sai uma vez, no signup. Quando ele não chega —
filtro de spam, relay que devolveu, aba fechada antes de clicar — a conta
fica sem saída:
signupde novo responde 409, o e-mail já está na tabela;loginresponde 401, porqueis_activeainda éFalse;email-verify/requestexiste para exatamente esse caso, mas exige bearer token — que só sai de um login bem-sucedido.
Isso é um beco sem saída que só o acesso ao banco resolvia.
POST /auth/activation/request é a saída, e ela não pede credencial
nenhuma por necessidade:
curl -X POST localhost:8000/auth/activation/request \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com"}'
{
"message": "If the email matches a pending account, a link was sent.",
"activation_url": "http://localhost:3000/activate?token=..."
}
O activation_url só aparece quando você mesmo entrega o link —
AUTH_RETURN_TOKEN_IN_RESPONSE=True ou EmailUtils não configurado. Em
produção ele vem null e o link viaja por e-mail, no idioma que o
i18n resolver.
202 sempre, com a mesma frase
Endereço desconhecido, conta já ativa e reenvio de verdade
respondem o mesmo status e o mesmo corpo. É o mesmo cuidado
anti-enumeração do password-reset/request: quem sondar a rota não
descobre quais e-mails têm conta aqui.
Conta já ativa não recebe e-mail
Reemitir para quem já ativou mandaria um link que não autoriza nada — e contaria a um estranho que aquele endereço tem conta. Por isso o caminho para de silenciosamente.
O token novo segue as mesmas regras do original: TTL de
AUTH_ACTIVATION_TTL_SECONDS e, com AUTH_SINGLE_ACTIVE_TOKEN=True (o
default), o pedido anterior é queimado — só o link mais recente funciona.
Recuperação de senha¶
O clássico "esqueci minha senha". O usuário não está logado e prova identidade com um token de uso único que chega por e-mail. São dois passos.
Caso base (SPA / JSON)¶
Passo 1 — pedir o link. O usuário digita o e-mail; o backend sempre responde 202 com a mesma mensagem genérica (não vaza se o e-mail existe). A mensagem é fixa no código, em inglês:
curl -X POST localhost:8000/auth/password-reset/request \
-H "Content-Type: application/json" \
-d '{"email": "ana@example.com"}'
Passo 2 — trocar a senha. O usuário abre o link do e-mail; o
front-end lê o token da URL e o envia com a nova senha. Em caso de
sucesso a senha é trocada e o usuário já sai logado (vem um JWT pair):
curl -X POST localhost:8000/auth/password-reset/confirm \
-H "Content-Type: application/json" \
-d '{"token": "abc123…", "new_password": "nova-senha-forte-12"}'
{
"user_id": "7d8e4d5a-…",
"access_token": "eyJhbGciOi…",
"refresh_token": "eyJhbGciOi…",
"mfa_required": false,
"mfa_token": null
}
Conta com MFA: o reset termina no /auth/mfa/verify
Se a conta tem TOTP inscrito (e AUTH_MFA_ENABLED=True), o
confirm não devolve o par — devolve mfa_required=true +
mfa_token, igual ao /login. É o que impede um e-mail
comprometido de pular o segundo fator: a senha é trocada, mas a
sessão só nasce depois do código. Conclua o login mandando o
mfa_token e o código TOTP:
curl -X POST localhost:8000/auth/mfa/verify \
-H "Content-Type: application/json" \
-d '{"mfa_token": "eyJ…", "code": "123456"}'
O par que sai daí carrega amr == ["email", "otp"]; sem MFA, o par do
próprio confirm carrega ["email"] (v0.314.0+). Veja a
receita de MFA.
O reset derruba as sessões já abertas
Rotacionar credencial encerra o que foi aberto com a antiga: trocar a
senha por este caminho revoga os refresh tokens que já existiam,
na mesma transação que grava o hash — uma sessão aberta antes do reset
deixa de trocar o refresh token. (Access tokens já emitidos valem até
o TTL curtinho.) É o mesmo default do /auth/mfa/confirm e do
tempest user set-password; quem chama o service com
revoke_sessions=False abre mão disso de propósito.
Erros que você vai ver
- Token desconhecido / já usado / expirado → 401 com
code: "INVALID_TOKEN"e umdetailque diz o motivo (token desconhecido:token not recognized). tokencom menos de 16 caracteres → 422 do próprio FastAPI (string_too_short), antes de chegar ao serviço.new_passwordviolaAUTH_PASSWORD_MIN_LENGTH/ complexidade → 422.requestnunca erra por e-mail inexistente — sempre 202.
Modo dev: link no body (sem SMTP)
Sem o extra [email], ou com AUTH_RETURN_TOKEN_IN_RESPONSE=True, o
reset_url volta no corpo do request em vez de ir por e-mail —
você dispara o fluxo inteiro sem inbox. Em produção ele fica null.
Conta que não usa link de e-mail (v0.316.0+)
O UserAuthService(refuses_email_link=...) fecha este fluxo (e o de
ativação) para contas escolhidas por um predicado — um operador, um
staff. O request responde o mesmo 202 e não manda e-mail; a
confirmação devolve 403 EMAIL_LINK_FORBIDDEN sem trocar a senha; e
o signup cria a conta inativa sem gravar nem mandar link de ativação,
com a mesma resposta de um cadastro pendente comum.
Veja a receita de MFA.
Sem front-end? Páginas HTML pelo backend (Modo E)
Com AUTH_BACKEND_LINKS=True o SDK monta GET/POST
/auth/password-reset/{token}: o link do e-mail aponta pro backend,
que renderiza um formulário HTML, valida e mostra uma página de
sucesso/erro — sem nenhuma rota no front-end. Veja
Cinco modos de operação (Modo E).
Trocar a senha (logado)¶
Diferente do reset: aqui o usuário lembra a senha e está logado.
Não há token nem e-mail — ele manda o access_token no header e
reconfirma a current_password:
curl -X POST localhost:8000/auth/password-change \
-H "Authorization: Bearer eyJhbGciOi…" \
-H "Content-Type: application/json" \
-d '{"current_password": "senha-atual", "new_password": "nova-senha-forte-12"}'
Retorna 204; os tokens atuais continuam válidos (este endpoint não
revoga sessões). current_password errada → 401.
Reset vs change — qual é qual?
- Esqueceu a senha, não está logado → Recuperação de senha (
password-reset/request→confirm, prova com token de e-mail). - Lembra a senha, está logado → trocar a senha (
password-change, prova com a senha atual no header).
Troca e recuperação de e-mail¶
O espelho do fluxo de senha, mas pro e-mail da conta (v0.92.0+). Três cenários distintos — comece pelo seu.
Trocar e-mail (logado)¶
O caso base: o usuário está logado e quer mudar o próprio e-mail.
Confirma a current_password e informa o new_email; um link de
confirmação vai pro novo endereço. O e-mail só muda quando esse link
é confirmado.
# Passo 1 — pedir a troca (autenticado). Sempre 202.
curl -X POST localhost:8000/auth/email-change/request \
-H "Authorization: Bearer eyJhbGciOi…" \
-H "Content-Type: application/json" \
-d '{"new_email": "nova@example.com", "current_password": "senha-atual"}'
# Passo 2 — confirmar (o front lê o token do link enviado ao novo e-mail).
curl -X POST localhost:8000/auth/email-change/confirm \
-H "Content-Type: application/json" \
-d '{"token": "abc123…"}'
Ao confirmar, se AUTH_EMAIL_CHANGE_NOTIFY_OLD=True (padrão) um aviso
de segurança vai pro endereço antigo — o padrão de bancos/Google, pra
uma conta sequestrada ainda alertar o dono.
Erros
- Senha atual errada → 401.
new_emailjá em uso (no pedido ou na confirmação — corrida) → 409.- Token inválido / expirado / usado → 401 com
code: "INVALID_TOKEN".
Re-verificar o e-mail atual¶
Reenvia um link de verificação pro endereço atual — útil quando o e-mail de ativação se perdeu. Autenticado, sem trocar nada; confirmar o link marca a conta ativa.
curl -X POST localhost:8000/auth/email-verify/request \
-H "Authorization: Bearer eyJhbGciOi…"
# depois: POST /auth/email-verify/confirm {"token": "…"}
Recuperação de e-mail¶
O "esqueci / perdi acesso ao meu e-mail". Endpoint não autenticado e
opt-in — só é montado com AUTH_EMAIL_RECOVERY_ENABLED=True. O
usuário prova identidade com a senha (e um código MFA quando o
TOTP está inscrito) e informa o novo endereço; o link de confirmação vai
pro novo e-mail e reusa o mesmo email-change/confirm.
# Só existe se AUTH_EMAIL_RECOVERY_ENABLED=True. Sempre 202 (anti-enumeração).
curl -X POST localhost:8000/auth/email-recovery/request \
-H "Content-Type: application/json" \
-d '{
"email": "antigo@example.com",
"new_email": "novo@example.com",
"current_password": "senha-atual",
"mfa_code": "123456"
}'
# depois: POST /auth/email-change/confirm {"token": "…"} (link foi pro novo e-mail)
Recuperação é sensível — ligue com cuidado
Esse endpoint deixa quem tem a senha mover a conta pra outro e-mail sem acessar a caixa antiga — vetor de account takeover se a senha vazou. Regras:
- Opt-in: fica desligado até
AUTH_EMAIL_RECOVERY_ENABLED=True. - Sempre 202 genérico pra toda falha leve (e-mail desconhecido,
senha errada,
mfa_codefaltando/errado) — não enumera contas. - Se o TOTP estiver inscrito, exige um
mfa_codeválido. - Mantenha
AUTH_EMAIL_CHANGE_NOTIFY_OLD=Truepra o e-mail antigo sempre ser avisado da troca.
Onde o novo e-mail fica guardado
O endereço pendente viaja na coluna payload do token
(EMAIL_CHANGE), não no UserModel. A confirmação relê o payload,
re-checa que o endereço ainda está livre (corrida entre pedido e
confirmação → 409) e só então grava. Veja a nota de migração da
0.92.0.
Renovando a sessão com o refresh token¶
O access_token é curto por design (JWT_ACCESS_TTL_SECONDS, 1 h por
padrão). Quando ele expira, não force o usuário a logar de novo —
troque o refresh_token (longo, 7 dias por padrão) por um par novo em
POST /auth/refresh:
curl -X POST localhost:8000/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "eyJhbGciOi…"}'
{
"user_id": "7d8e4d5a-9f4b-4c3a-bd0a-1234567890ab",
"access_token": "eyJhbGciOi…(novo)",
"refresh_token": "eyJhbGciOi…(novo)",
"mfa_required": false,
"mfa_token": null
}
O endpoint decodifica o token, exige que ele realmente carregue a claim
refresh (um access token replayado aqui é rejeitado com 401),
resolve o sub pra um usuário ativo e emite um par novo.
Os dois tokens rotacionam
A resposta traz um refresh_token novo. Persista esse e descarte
o que você enviou. No modo stateless (default) o SDK emite JWTs,
então o par antigo não é revogado — ele continua válido até o próprio
exp.
Precisa de revogação real? O SDK já entrega
Não escreva sua própria tabela de refresh tokens — desde a
v0.66.0 o SDK oferece refresh tokens DB-backed opacos com
rotação single-use, detecção de reuso (token roubado revoga a
família inteira) e POST /auth/logout. É opt-in: passe um
refresh_token_model pro UserAuthService. Veja a receita
Refresh tokens (rotação/revogação).
Quando o refresh token também expira
Aí não tem renovação possível — o 401 é definitivo e o cliente
cai de volta no POST /auth/login com email + senha.
Sem frontend? O método de serviço por trás do endpoint é público —
await service.refresh_tokens(session, refresh_token=...) devolve
(user, access_token, refresh_token).
Settings — variáveis de ambiente¶
Toda a config do flow vem de mixins de settings. Mixe-as na sua classe Settings:
# src/core/settings.py
from tempest_fastapi_sdk import (
AuthSettings,
BaseAppSettings,
DatabaseSettings,
EmailSettings,
JWTSettings,
ServerSettings,
)
class Settings(
ServerSettings,
DatabaseSettings,
EmailSettings,
JWTSettings,
AuthSettings,
BaseAppSettings,
):
pass
settings: Settings = Settings()
Nome do atributo é o nome da env var
Cada atributo das tabelas abaixo é lido de uma variável de ambiente de mesmo nome, case-sensitive, sem prefixo. AUTH_PASSWORD_MIN_LENGTH no .env → settings.AUTH_PASSWORD_MIN_LENGTH. Todos têm default — você só seta o que quer mudar.
As variáveis se dividem em dois mixins e seis grupos de concern. Elas estão separadas de propósito: senha não é a mesma coisa que e-mail, e autenticação (JWT) não é a mesma coisa que ativação de conta.
Grupo 1 — Autenticação / JWT (JWTSettings)¶
Controla a assinatura e validade dos tokens que o login devolve. É o mesmo JWT_SECRET que a dependency get_current_user usa pra validar (veja Pegando o current_user).
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
JWT_SECRET |
str (≥32 bytes) |
change-me-…-32 |
Segredo HMAC que assina o JWT. Obrigatório trocar em produção. |
JWT_ALGORITHM |
str |
HS256 |
Algoritmo JOSE. HS256/HS512 (segredo simétrico) ou RS256 (par de chaves). |
JWT_ACCESS_TTL_SECONDS |
int (≥1) |
3600 |
Validade do access token (1 h). Curto por design — renove via refresh. |
JWT_REFRESH_TTL_SECONDS |
int (≥1) |
604800 |
Validade do refresh token (7 dias). |
JWT_ISSUER |
str | None |
None |
Claim iss. None omite o claim. |
JWT_SECRET default vaza tokens
O default change-me-change-me-change-me-32 existe só pra subir local. Em produção, qualquer um com o default consegue forjar um JWT válido. Gere um segredo forte (openssl rand -base64 48) e injete por secret manager — nunca commite.
Grupo 2 — Política de senha (AuthSettings)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_PASSWORD_MIN_LENGTH |
int (≥1) |
12 |
Comprimento mínimo aceito no signup e no reset. |
AUTH_PASSWORD_MAX_BYTES |
int (≥1) |
72 |
Comprimento máximo em bytes UTF-8. É o limite duro do bcrypt. |
AUTH_PASSWORD_REQUIRE_COMPLEXITY |
bool |
false |
true = exige 1 minúscula + 1 maiúscula + 1 dígito + 1 caractere especial. |
Os dois interagem — é aqui que costuma confundir. A regra exata:
complexity=false(default): só o comprimento importa. Qualquer senha com≥ AUTH_PASSWORD_MIN_LENGTHcaracteres passa, sem exigência de composição.complexity=true: além das 4 classes de caracteres, o piso de comprimento efetivo viramax(AUTH_PASSWORD_MIN_LENGTH, 8). Ou seja, umAUTH_PASSWORD_MIN_LENGTHabaixo de 8 é ignorado enquanto a complexidade está ligada.
Tabela de decisão:
MIN_LENGTH |
REQUIRE_COMPLEXITY |
Senha aceita quando |
|---|---|---|
12 |
false |
≥ 12 chars, qualquer composição |
4 |
false |
≥ 4 chars, qualquer composição (piso baixo, dev-only) |
4 |
true |
≥ 8 chars (piso 8 sobrescreve o 4) + as 4 classes |
16 |
true |
≥ 16 chars + as 4 classes |
O piso é fonte única da verdade
Os schemas de request (SignupSchema, PasswordResetConfirmSchema) não impõem limite próprio de comprimento — eles delegam pra essas duas vars. Baixar AUTH_PASSWORD_MIN_LENGTH pra 4 realmente afrouxa a validação na rota também. Não há um segundo limite escondido no schema "te protegendo".
O teto conta bytes, não caracteres
AUTH_PASSWORD_MAX_BYTES existe porque bcrypt recusa entrada acima de 72 bytes: hashpw levanta ValueError, e sem esse teto o erro subia como 500 no signup / reset / troca de senha. Bytes é a unidade que o hash vê, e 72 bytes chegam bem antes de 72 caracteres em texto não-ASCII — um emoji custa 4 bytes, uma letra acentuada 2, então "🔒" * 19 (19 caracteres) já passa do limite e responde 422. Só aumente o valor se você trocar o hasher por um sem esse limite.
Cada violação tem o seu message_key (v0.305.0+)
O code da resposta continua VALIDATION_ERROR, mas o message_key diz
qual regra caiu, e o catálogo default traduz cada uma:
message_key |
Quando | detail em pt-BR (catálogo default) |
|---|---|---|
PASSWORD_TOO_SHORT |
abaixo do piso efetivo | A senha precisa ter pelo menos 12 caracteres |
PASSWORD_TOO_LONG |
acima de AUTH_PASSWORD_MAX_BYTES |
A senha pode ter no máximo 72 bytes |
PASSWORD_TOO_WEAK |
falta classe de caractere (complexidade ligada) | A senha precisa ter letra minúscula, letra maiúscula, número e caractere especial |
O mesmo valor está em PasswordPolicyViolation.code
(PasswordViolationCode), para quem chama check_password_policy direto.
Para trocar também o code, veja
os códigos de erro do seu produto.
Grupo 3 — Controle do fluxo de e-mail (AuthSettings)¶
Decidem se e como o link chega ao usuário. Mapeiam direto nos cinco modos de operação.
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_AUTO_ACTIVATE |
bool |
false |
true = user nasce ativo, pula activation, signup devolve JWT pair direto (Modo D). Nunca em produção. |
AUTH_RETURN_TOKEN_IN_RESPONSE |
bool |
false |
true = link de ativação/reset vai no corpo JSON em vez do e-mail (Modo C). |
Grupo 4 — TTL dos tokens de conta (AuthSettings)¶
Validade dos tokens de uso único (ativação / reset) — distintos dos JWT do grupo 1.
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_ACTIVATION_TTL_SECONDS |
int (≥60) |
604800 |
Validade do token de ativação (7 dias). |
AUTH_PASSWORD_RESET_TTL_SECONDS |
int (≥60) |
3600 |
Validade do token de reset (1 h). Curto é mais seguro. |
Grupo 5 — URLs e templates de e-mail (AuthSettings)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_ACTIVATION_URL_TEMPLATE |
str |
http://localhost:3000/activate?token={token} |
Link de ativação que vai no e-mail (ou no corpo do signup, quando não há e-mail); {token} é substituído. Aponta para quem vai abrir o link — frontend, backend ou app. Tabela por modo logo abaixo. |
AUTH_PASSWORD_RESET_URL_TEMPLATE |
str |
http://localhost:3000/reset-password?token={token} |
Idem, para reset. |
AUTH_ACTIVATION_TEMPLATE |
str |
activation.html |
Nome do arquivo Jinja2 do HTML do e-mail de ativação, resolvido no template_dir do EmailUtils. |
AUTH_PASSWORD_RESET_TEMPLATE |
str |
password_reset.html |
Idem, para reset. |
Para onde o AUTH_ACTIVATION_URL_TEMPLATE aponta¶
O template é só uma string .format(): o SDK troca {token} e não sabe
quem vai abrir o resultado. Quem decide é o modo:
| Modo | Quem abre o link | Template | Rota que consome o token |
|---|---|---|---|
| A, B, C | página do frontend, que lê ?token= e chama a API |
https://app.example.com/activate?token={token} |
POST /auth/activate/{token} |
| E — backend-only | o próprio backend: AUTH_BACKEND_LINKS=true monta uma página HTML em GET /auth/activate/{token} que ativa a conta no clique |
https://api.example.com/auth/activate/{token} (com o prefixo do router, se houver) |
GET /auth/activate/{token} |
| App mobile | o app, por deep link | meuapp://ativar?token={token} |
POST /auth/activate/{token} |
| D | ninguém — a conta nasce ativa | irrelevante | nenhuma |
Dev sem frontend: o default aponta para localhost:3000, uma página que
não existe no seu caso. Ligue o Modo E com o template no próprio backend
(http://127.0.0.1:8000/auth/activate/{token}), ou deixe o template como
está e mande o token para POST /auth/activate/{token}. O passo a passo, com
as respostas medidas, está em
Só o backend.
URL template ≠ Jinja2 template
*_URL_TEMPLATE é uma string .format() com {token} — é o link. *_TEMPLATE é o nome de um arquivo .html — é o e-mail que embrulha o link. Confundir os dois é o erro nº 1. Detalhe completo em Anatomia de um e-mail.
Grupo 6 — Páginas renderizadas pelo backend (Modo E, AuthSettings)¶
Só relevantes quando AUTH_BACKEND_LINKS=true. Veja o Modo E para o fluxo completo.
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_BACKEND_LINKS |
bool |
false |
true = monta 5 endpoints HTML extras; o link do e-mail aponta pro backend, não pro frontend. |
AUTH_LOGIN_URL |
str | None |
None |
URL de login no botão "Ir pro login" das páginas de sucesso. None esconde o botão. |
AUTH_ACTIVATION_SUCCESS_TEMPLATE |
str |
activation_success.html |
Página HTML de ativação OK. |
AUTH_ACTIVATION_ERROR_TEMPLATE |
str |
activation_error.html |
Página HTML de ativação com erro. |
AUTH_PASSWORD_RESET_FORM_TEMPLATE |
str |
password_reset_form.html |
Form HTML de nova senha. |
AUTH_PASSWORD_RESET_SUCCESS_TEMPLATE |
str |
password_reset_success.html |
Página HTML de reset OK. |
AUTH_PASSWORD_RESET_ERROR_TEMPLATE |
str |
password_reset_error.html |
Página HTML de reset com erro. |
Grupo 7 — Idioma dos e-mails e páginas (AuthSettings)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_DEFAULT_LOCALE |
str |
pt-BR |
Idioma dos e-mails e páginas HTML bundled quando nenhum outro sinal existe. Aceita pt-BR e en-US (normalizado: PT-BR, pt_br, ptbr → pt-BR). |
AUTH_STAMP_LOCALE_IN_LINK |
bool |
True |
Carimba ?lang=<locale> no link do e-mail, para a página abrir no idioma do e-mail que a produziu. |
Tem uma seção inteira só pra isso, explicada bem devagar: Idioma dos e-mails e páginas (i18n).
Grupo 8 — Entrega de token: bearer / cookie / both (AuthSettings) (v0.87.0+)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_TOKEN_DELIVERY |
"bearer" | "cookie" | "both" |
bearer |
Como o login/refresh devolvem o par JWT. Veja Entrega de token. |
AUTH_COOKIE_SECURE |
bool |
true |
Marca os cookies como Secure (só trafegam via HTTPS). Desligue só em HTTP puro — senão o browser descarta o cookie. |
AUTH_COOKIE_SAMESITE |
"lax" | "strict" | "none" |
lax |
SPA cross-site precisa none (+ Secure=true). |
AUTH_COOKIE_DOMAIN |
str | None |
None |
Domain do cookie. None = host exato. Use .example.com pra compartilhar entre subdomínios. |
AUTH_ACCESS_COOKIE_NAME |
str |
access_token |
Nome do cookie do access token. |
AUTH_REFRESH_COOKIE_NAME |
str |
refresh_token |
Nome do cookie do refresh token (escopado ao path do endpoint de refresh). |
Grupo 9 — Sistema fechado (AuthSettings) (v0.272.0+)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_SIGNUP_ENABLED |
bool |
true |
false = make_auth_router não monta POST /auth/signup; a rota some da aplicação e do OpenAPI. Ativação, reset e o resto ficam intactos. Veja Sistema fechado. |
Grupo 10 — Login social (AuthSettings + OAuthSettings) (v0.273.0+)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_OAUTH_ENABLED |
bool |
false |
true monta as cinco rotas /auth/oauth/* ({provider}/login, {provider}/callback, {provider}/token, accounts e accounts/unlink). Exige um client em oauth_clients=, um oauth_account_model no serviço e a coluna name no user model — cada um que faltar levanta RuntimeError na construção do router. |
AUTH_OAUTH_STATE_COOKIE_NAME |
str |
oauth_state |
Cookie que carrega o state CSRF entre o redirect e o callback. Sempre HttpOnly e sempre SameSite=Lax. |
AUTH_OAUTH_STATE_TTL_SECONDS |
int |
600 |
Quanto tempo o usuário tem para concluir o consentimento no provedor. |
AUTH_OAUTH_LINK_BY_VERIFIED_EMAIL |
bool |
false |
true liga uma identidade nova a uma conta existente cujo e-mail bate — só quando email_verified is True. Desligado por padrão porque é o knob que transforma a palavra do provedor sobre um e-mail em controle de uma conta. |
AUTH_OAUTH_ALLOW_ACCOUNT_CREATION |
bool ou vazio |
vazio | Se o callback pode criar conta. Vazio herda AUTH_SIGNUP_ENABLED, então fechar o cadastro fecha essa porta junto. |
OAUTH_REDIRECT_BASE_URL |
str |
"" |
Origem pública do serviço. O redirect URI é derivado dela por oauth_redirect_uri(provider). |
OAUTH_GOOGLE_CLIENT_ID / OAUTH_GOOGLE_CLIENT_SECRET |
str |
"" |
Credenciais do console do Google. google_kwargs() monta o client. |
OAUTH_GOOGLE_EXTRA_AUDIENCES |
list[str] |
[] |
Os outros client ids deste projeto (Android, iOS). Sem eles, todo login mobile por POST /auth/oauth/google/token é recusado como token de outra aplicação. |
OAUTH_GITHUB_CLIENT_ID / OAUTH_GITHUB_CLIENT_SECRET |
str |
"" |
Credenciais do OAuth app do GitHub. github_kwargs() monta o client. |
Grupo 11 — Um link por vez (AuthSettings) (v0.274.0+)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_SINGLE_ACTIVE_TOKEN |
bool |
true |
Emitir um token de conta gasta os outros tokens não usados do mesmo propósito daquele usuário, então só o link mais recente de ativação / reset / troca de e-mail abre a conta. false restaura o comportamento anterior à v0.274.0. Veja o guia de migração. |
Sem isso, a reação certa do usuário não fecha a janela
Um atacante dispara POST /auth/password-reset/request para a vítima. A
vítima recebe um e-mail de recuperação que não pediu, desconfia, e por
conta própria pede um reset e conclui — exatamente a reação correta. Com
vários links vivos, o link do atacante continua valendo até
AUTH_PASSWORD_RESET_TTL_SECONDS, e um token vazado por qualquer via
lateral (log de proxy, extensão de browser, e-mail encaminhado, dispositivo
compartilhado) ainda reseta a senha depois do incidente parecer resolvido.
O escopo é estreito nas duas direções: só o mesmo propósito (pedir um reset
não derruba uma troca de e-mail pendente) e só o mesmo usuário. A linha
antiga é marcada used_at, não apagada, então a auditoria fica.
Grupo 12 — Conta desativada (AuthSettings) (v0.305.0+)¶
| Env var | Tipo | Default | O que faz |
|---|---|---|---|
AUTH_REVEAL_INACTIVE_ACCOUNT |
bool |
false |
true = a senha certa numa conta desativada responde 403 ACCOUNT_INACTIVE em vez do 401 genérico. Senha errada e e-mail desconhecido continuam 401. |
Por padrão, conta desativada responde o mesmo 401 de senha errada, para a
resposta não contar quais e-mails têm conta. Só que o dono da conta, sem saber
por que não entra, pede "esqueci a senha", troca a senha e continua sem entrar.
Ligado, quem provou a senha fica sabendo — e isso não enumera nada, porque
quem tem a senha já é o dono:
$ curl -s -X POST localhost:8000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"ana@example.com","password":"strong-pass-12-chars"}'
{"detail":"Esta conta está desativada — fale com o suporte para reativá-la","code":"ACCOUNT_INACTIVE","details":{}}
(Com o catálogo default em register_exception_handlers(app, catalog=default_message_catalog());
sem catálogo, o detail é account is not active.)
O custo é o mesmo nos três ramos
A senha é verificada antes de olhar is_active, ligado ou desligado.
E-mail desconhecido paga um bcrypt contra um hash descartável. Medido em
tests/auth/test_refusal_codes.py: exatamente uma verificação por login
recusado, nos três ramos, com a flag ligada e desligada.
MFA / TOTP tem suas próprias vars
Quando AUTH_MFA_ENABLED=true, o AuthSettings ainda expõe AUTH_MFA_ISSUER, AUTH_MFA_RECOVERY_CODES_COUNT, AUTH_MFA_TOKEN_TTL_SECONDS e AUTH_MFA_VERIFY_WINDOW. Ficam fora do escopo desta receita (signup/activate/login/reset) — são cobertos na receita de MFA.
Códigos de erro do seu produto (AuthExceptions) (v0.305.0+)¶
Serviço que já existe tem clientes tratando codes próprios —
ERROR_USER_INVALID_CREDENTIALS, USER_ALREADY_EXISTS, INVALID_PASSWORD. O
SDK responde UNAUTHORIZED, CONFLICT, VALIDATION_ERROR, e o catálogo troca
a mensagem, não o código. Sem uma costura, adotar o UserAuthService
significava sobrescrever método só para trocar a exceção.
AuthExceptions é essa costura: diz qual classe sobe em cada ponto de recusa.
# src/services/auth.py
from tempest_fastapi_sdk import AuthExceptions, UserAuthService
from tempest_fastapi_sdk.exceptions import (
ConflictException,
ForbiddenException,
InvalidTokenException,
UnauthorizedException,
ValidationException,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
class InvalidCredentialsError(UnauthorizedException):
"""E-mail ou senha errados."""
code: str = "ERROR_USER_INVALID_CREDENTIALS"
class UserDeactivatedError(ForbiddenException):
"""Conta desativada, revelada a quem acertou a senha."""
code: str = "USER_ACCOUNT_DEACTIVATED"
class UserAlreadyExistsError(ConflictException):
"""E-mail já cadastrado."""
code: str = "USER_ALREADY_EXISTS"
class InvalidPasswordError(ValidationException):
"""Senha curta ou fraca."""
code: str = "INVALID_PASSWORD"
class PasswordTooLongError(ValidationException):
"""Senha acima do limite do bcrypt."""
code: str = "PASSWORD_TOO_LONG"
class InvalidResetTokenError(InvalidTokenException):
"""Link de uso único que não vale."""
code: str = "INVALID_PASSWORD_RESET_TOKEN"
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
exceptions=AuthExceptions(
invalid_credentials=InvalidCredentialsError,
account_inactive=UserDeactivatedError,
email_taken=UserAlreadyExistsError,
password_too_short=InvalidPasswordError,
password_too_long=PasswordTooLongError,
password_too_weak=InvalidPasswordError,
invalid_token=InvalidResetTokenError,
),
)
Medido com register_exception_handlers(app, catalog=default_message_catalog())
e AUTH_REVEAL_INACTIVE_ACCOUNT=true:
| Requisição | Sem exceptions= |
Com o mapa acima |
|---|---|---|
| login, senha errada | 401 UNAUTHORIZED |
401 ERROR_USER_INVALID_CREDENTIALS |
| login, conta desativada, senha certa | 403 ACCOUNT_INACTIVE |
403 USER_ACCOUNT_DEACTIVATED |
| signup, senha curta | 422 VALIDATION_ERROR |
422 INVALID_PASSWORD |
| signup, 73 bytes | 422 VALIDATION_ERROR |
422 PASSWORD_TOO_LONG |
| signup, e-mail repetido | 409 CONFLICT |
409 USER_ALREADY_EXISTS |
Cada campo é tipado com a classe default, então a sua precisa herdar dela.
É isso que segura o status: um InvalidCredentialsError é um
UnauthorizedException, então continua 401, e o caminho da recusa — inclusive
o bcrypt único do login — é o mesmo. Muda a classe e, com ela, o code.
A mensagem do seu code
Nas violações de senha e na conta desativada o SDK passa um message_key
próprio (PASSWORD_TOO_SHORT, ACCOUNT_INACTIVE, …), então o detail
continua traduzido mesmo com a sua classe. Nos outros pontos o catálogo
procura o seu code; sem tradução para ele, o detail é a mensagem em
inglês do SDK (invalid email or password). Acrescente as suas com
default_message_catalog().merge({"pt-BR": {"ERROR_USER_INVALID_CREDENTIALS": "..."}}).
O que invalid_token cobre
Os links de uso único: ativação, troca de senha, troca de e-mail e
verificação — desconhecido, usado, expirado, ou apontando para usuário que
não existe mais. Desde a v0.305.0 a troca de senha com usuário removido é
InvalidTokenException como os irmãos, não mais 404. Refresh token fica de
fora: é outro contrato com o cliente.
Recap. AuthExceptions troca a classe — e o code — em cada ponto de
recusa, sem override e sem mudar status nem custo. As violações de senha já
saem com message_key próprio, e AUTH_REVEAL_INACTIVE_ACCOUNT mostra o 403
só a quem provou a senha.
Anatomia de um e-mail¶
Três conceitos diferentes que parecem o mesmo. Eis o que cada um faz, exatamente uma vez, em pseudo-código:
1. SDK gera um token opaco aleatório (string de 64 chars).
2. AUTH_ACTIVATION_URL_TEMPLATE.format(token=…) → link com o token embutido.
3. Renderiza AUTH_ACTIVATION_TEMPLATE (Jinja2 HTML) passando { user, activation_url, expires_at, expires_at_str }.
4. EmailUtils.send(to=user.email, subject=..., html=<HTML renderizado>).
Em prosa:
- Token opaco — string aleatória que o SDK gera, hasheia (SHA-256) e grava na tabela
user_tokens. O plaintext sai pelo e-mail uma única vez; o banco só guarda o hash. - URL template (
AUTH_ACTIVATION_URL_TEMPLATE) — formato literal pra montar a URL que vai pro usuário clicar. Nos modos A–C aponta pro frontend: ele recebe?token=…, capta da query string e chamaPOST /auth/activate/{token}no backend. No Modo E aponta pro backend, que ativa a conta noGET. Tabela completa em Para onde o template aponta. - Jinja2 template (
AUTH_ACTIVATION_TEMPLATE) — nome do arquivo HTML dentro dotemplate_dirdoEmailUtils. É o HTML do e-mail, não a URL. Recebe o contexto{ user, activation_url, expires_at, expires_at_str }e renderiza o markup final. Use{{ expires_at_str }}no template — é a data de expiração já formatada e curta (ex.:21/06/2026 23:25 (UTC), sem segundos);expires_atcontinua disponível comodatetimecru se você quiser formatar do seu jeito.
URL template ≠ Jinja2 template
AUTH_ACTIVATION_URL_TEMPLATE é uma string Python .format()-style — só tem o placeholder {token}. Não confunda com o arquivo .html que o Jinja2 renderiza. A URL formatada é injetada como variável no contexto do Jinja2 sob o nome activation_url, e o template HTML embrulha ela num botão.
Fluxo visual:
sequenceDiagram
participant U as Usuário
participant F as Frontend
participant API as Backend (SDK)
participant E as SMTP
participant DB as Banco
U->>F: preenche email + senha
F->>API: POST /auth/signup
API->>DB: INSERT user (is_active=false) + INSERT token (hash, TTL)
API->>API: token plaintext + AUTH_ACTIVATION_URL_TEMPLATE.format(token=…)
API->>API: render Jinja2 (user, activation_url, expires_at)
alt AUTH_RETURN_TOKEN_IN_RESPONSE=false
API->>E: SMTP send (HTML renderizado)
API->>F: 201 + {activation_required: true, activation_url: null}
else AUTH_RETURN_TOKEN_IN_RESPONSE=true
API->>F: 201 + {activation_required: true, activation_url: "https://app/activate?token=..."}
end
Note right of F: usuário (ou dev) abre a URL
F->>API: POST /auth/activate/{token}
API->>DB: hash(token) match? expirou? já usou?
API->>F: 200 + JWT pair
Idioma dos e-mails e páginas (i18n)¶
Desde a v0.59.0, os e-mails e as páginas HTML que o SDK já traz
prontos falam dois idiomas: 🇧🇷 Português do Brasil (pt-BR) —
que é o padrão — e 🇺🇸 Inglês americano (en-US). Você não
precisa criar nenhum template pra isso funcionar. 🚀
A regra de ouro (decore só isso)¶
Um fluxo, um idioma. O e-mail e a página que aquele e-mail abre resolvem o idioma do mesmo jeito, na mesma ordem — primeiro sinal que existir vence:
| # | Sinal | De onde vem |
|---|---|---|
| 1 | ?lang= no link |
O SDK carimba no link o idioma em que aquele e-mail saiu. |
| 2 | user.locale |
A preferência que você gravou na linha do usuário (coluna do LocaleColumnMixin). É por ela que o e-mail se decide — quando ele é montado ainda não existe link. |
| 3 | Accept-Language |
O navegador de quem clicou (só páginas — e-mail não tem navegador). |
| 4 | AUTH_DEFAULT_LOCALE |
O último recurso. |
Por que o link vence a preferência gravada?
Porque ele registra a língua daquele e-mail. Se a pessoa troca o idioma no app entre o envio e o clique, a página ainda casa com a mensagem que ela está lendo. Preferir a linha recriaria o descasamento que essa ordem existe para fechar — só que invertido.
Até a v0.263.0 as duas pontas discordavam
O e-mail lia só o AUTH_DEFAULT_LOCALE e a página negociava
só o Accept-Language. Um cadastro com o padrão pt-BR e um
navegador em inglês mandava o e-mail em português e abria a página em
inglês — e nenhuma configuração consertava, porque setar
AUTH_DEFAULT_LOCALE=pt-BR era exatamente o valor que o header
atropelava. Se você guarda o idioma do usuário, agora ele manda nas
duas pontas.
Por que o link precisa carregar o idioma?
No cadastro a conta acabou de nascer: não há preferência gravada
ainda. O único lugar onde a escolha do e-mail sobrevive até o clique é
o próprio link — daí o ?lang=. Desligue com
AUTH_STAMP_LOCALE_IN_LINK=false se a sua rota de frontend rejeita
query param desconhecido.
Passo 1 — escolher o idioma padrão¶
Só uma variável de ambiente. É isso:
Quer tudo em inglês? Troque por:
Não precisa acertar a caixa/formato exato
O valor é normalizado pra você. Tudo isso vira pt-BR: pt-BR,
PT-BR, pt_br, ptbr, pt. E tudo isso vira en-US: en-US,
EN_us, enus, en. Se você digitar algo que o SDK não conhece
(tipo klingon), ele cai no padrão pt-BR em vez de quebrar.
Passo 2 — (opcional) guardar a preferência do usuário¶
Se o seu serviço deixa a pessoa escolher o idioma, guarde na linha dela
com o LocaleColumnMixin — e o SDK passa a respeitar isso nas duas
pontas, sem mais nenhuma configuração:
from tempest_fastapi_sdk import BaseUserModel, LocaleColumnMixin
class UserModel(LocaleColumnMixin, BaseUserModel):
"""A coluna `locale` (BCP-47) entra pelo mixin."""
__tablename__ = "users"
Sem o mixin nada quebra: o SDK lê o atributo com getattr, então um
model sem a coluna simplesmente cai para o próximo sinal.
user.locale = "pt-BR", navegador en-US → e-mail E página em Português
link com ?lang=pt-BR, linha diz en-US → página em Português (casa com o e-mail)
sem os dois, navegador en-US → página em Inglês
nada disso → AUTH_DEFAULT_LOCALE
Passo 3 — (opcional) traduzir/customizar você mesmo¶
Os templates bundled ficam em subpastas por idioma (pt-BR/,
en-US/). Pra trocar só o texto/visual de um idioma, crie um
arquivo de mesmo nome na subpasta certa do seu template_dir (ex.:
template_dir/pt-BR/activation_success.html). A ordem de busca completa
está na dica "Override por idioma" mais abaixo, no Modo E.
Bônus — data de expiração curta e legível¶
Antes, o e-mail mostrava a expiração crua e feia assim:
Agora o SDK injeta no template a variável expires_at_str, já
formatada e sem segundos, no formato do idioma:
| Idioma | Como aparece |
|---|---|
pt-BR |
21/06/2026 23:25 (UTC) |
en-US |
2026-06-21 23:25 (UTC) |
Nos seus templates custom, use {{ expires_at_str }} (curto e bonito).
Se quiser formatar do seu jeito, o datetime cru ainda está disponível
em {{ expires_at }}.
Recapitulando
- Um fluxo, um idioma: e-mail e página resolvem na mesma ordem —
?lang=do link →user.locale→Accept-Language→AUTH_DEFAULT_LOCALE. - Guardou o idioma do usuário? Ele manda nas duas pontas.
- Padrão é
pt-BR. Coloqueen-USse quiser inglês. AUTH_STAMP_LOCALE_IN_LINK=falsedesliga o carimbo?lang=.- Use
{{ expires_at_str }}pra mostrar a expiração sem segundos.
Cinco modos de operação¶
| Modo | Quando usar | Flags | Onde o link aparece |
|---|---|---|---|
| A. Produção (SPA) | SaaS público, e-mail real, frontend SPA dono das páginas | AUTH_AUTO_ACTIVATE=falseAUTH_RETURN_TOKEN_IN_RESPONSE=falseAUTH_BACKEND_LINKS=falseSMTP real (Mailgun, SES, Postmark…) |
Inbox real → frontend processa o token |
| B. Dev local com SMTP fake | Desenvolvimento diário sem mandar e-mail real | AUTH_AUTO_ACTIVATE=falseAUTH_RETURN_TOKEN_IN_RESPONSE=falseSMTP apontando pra Mailhog ( localhost:1025) ou smtp4dev (localhost:2525) |
UI web do Mailhog/smtp4dev em localhost:8025 / localhost:5000 |
| C. Dev sem SMTP | Validação rápida sem subir nenhum container de e-mail | AUTH_AUTO_ACTIVATE=falseAUTH_RETURN_TOKEN_IN_RESPONSE=trueemail=None ou SMTP inválido |
Body da resposta HTTP do signup |
| D. CI / testes | Suite de testes que não exercita activation | AUTH_AUTO_ACTIVATE=true |
Nenhum — signup já devolve JWT pair |
| E. Backend-only (v0.32.0+) | Quer 100% de controle no backend — sem responsabilidade no frontend. Ideal pra APIs sem SPA, MVPs, intranets. | AUTH_BACKEND_LINKS=trueURL templates apontam pro backend ( https://api.example.com/auth/activate/{token})AUTH_LOGIN_URL=https://app.example.com/login (opcional, mostra botão "Ir pro login" nas páginas HTML) |
Backend renderiza HTML success/error direto — usuário só clica no link do e-mail |
Modo A — produção¶
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USERNAME=postmaster@mg.example.com
SMTP_PASSWORD=... # secret, não commitar
SMTP_FROM_ADDR=noreply@example.com
AUTH_ACTIVATION_URL_TEMPLATE=https://app.example.com/activate?token={token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=https://app.example.com/reset?token={token}
Fluxo: signup → e-mail real chega no inbox → usuário clica → frontend chama POST /auth/activate/{token} → login.
Modo B — dev com SMTP local (Mailhog ou smtp4dev)¶
Mesmo .env do modo A, mas apontando o SMTP para um container local que intercepta os e-mails em vez de mandar de verdade. Use este modo no dia-a-dia — o fluxo é idêntico ao de produção, então você pega bugs de template, encoding, charset, etc. ao mesmo tempo que evita spammar e-mail real.
# .env.dev
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
SMTP_HOST=localhost
SMTP_PORT=1025 # Mailhog SMTP padrão
SMTP_USERNAME= # vazio — Mailhog não autentica
SMTP_PASSWORD=
SMTP_FROM_ADDR=dev@local
AUTH_ACTIVATION_URL_TEMPLATE=http://localhost:5173/activate?token={token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=http://localhost:5173/reset?token={token}
Abra http://localhost:8025 (Mailhog) ou http://localhost:5000 (smtp4dev) pra ver os e-mails interceptados. Veja a seção Mailhog vs smtp4dev abaixo.
Modo C — dev sem SMTP (link no body)¶
Sem container de SMTP nenhum. O signup devolve o link de ativação no JSON da resposta:
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=true
AUTH_ACTIVATION_URL_TEMPLATE=http://localhost:5173/activate?token={token}
Request:
curl -X POST localhost:8000/auth/signup \
-H 'content-type: application/json' \
-d '{"email":"dev@local","password":"abcdefghijkl","name":"Dev"}'
Resposta (formato real do SignupResponseSchema):
{
"user_id": "0193e9ea-7c4b-7c8e-bc05-2a3a8d9f7e10",
"activation_required": true,
"activation_url": "http://localhost:5173/activate?token=aBcD...xYz",
"access_token": null,
"refresh_token": null
}
Cole a URL no navegador / curl pra exercitar POST /auth/activate/{token}.
Modo D — CI / testes (skip total)¶
Signup pula ativação inteira e devolve {access_token, refresh_token} direto. Use só em testes ou quando o produto for interno e cada usuário já é confiável.
Modo E — backend-only (v0.32.0+)¶
Quando você prefere que toda a experiência do link aconteça no backend, sem nenhuma página no frontend, ative AUTH_BACKEND_LINKS=True. O router passa a montar cinco endpoints HTML adicionais — GET /auth/activate/{token}, GET /auth/password-reset/{token}, POST /auth/password-reset/{token} (form-encoded), GET /auth/email-change/{token} e GET /auth/email-verify/{token}. O e-mail aponta o usuário direto pra esses endpoints; o backend ativa a conta / processa o reset / renderiza HTML success ou error — usando templates Jinja2 bundled que você pode shadowar.
É também o modo de dev sem frontend: o ciclo inteiro — cadastro, link, login e rota protegida — com as respostas medidas está em Só o backend.
# .env — Modo E (backend-only)
AUTH_BACKEND_LINKS=true
AUTH_AUTO_ACTIVATE=false
AUTH_RETURN_TOKEN_IN_RESPONSE=false
# IMPORTANTE: URL templates apontam pro BACKEND, não pro frontend.
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/auth/activate/{token}
AUTH_PASSWORD_RESET_URL_TEMPLATE=https://api.example.com/auth/password-reset/{token}
# Opcional: URL do seu login. Quando setado, aparece um botão "Ir pro login"
# nas páginas de success/error renderizadas pelo backend. Quando null,
# o botão é omitido (puro server-side, zero acoplamento com frontend).
AUTH_LOGIN_URL=https://app.example.com/login
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_FROM_ADDR=noreply@example.com
Link dá 404? Alinhe o template com o prefixo onde o router está montado
Os endpoints acima são relativos ao ponto onde você monta o make_auth_router.
Se você inclui o router com um prefixo — comum pra separar rotas de negócio:
então a rota real de ativação vira GET /api/auth/activate/{token}, não
/auth/activate/{token}. Mas o AUTH_ACTIVATION_URL_TEMPLATE é uma string
literal — ele não sabe do prefixo. Se o template apontar pra
.../auth/activate/{token} (sem /api), o link do e-mail bate numa rota
inexistente e responde 404, mesmo com o signup retornando 201.
# ❌ router montado com prefix="/api", mas o template não tem /api → 404
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/auth/activate/{token}
# ✅ template alinhado com o prefixo real de montagem
AUTH_ACTIVATION_URL_TEMPLATE=https://api.example.com/api/auth/activate/{token}
Duas checagens ao configurar o Modo E: (1) o host é o domínio público
do backend (nunca localhost — o link roda no browser do usuário, não no
servidor); (2) o path inclui todo prefixo com que você montou o router.
Fluxo:
sequenceDiagram
participant U as Usuário
participant E as Inbox
participant API as Backend
participant DB as Banco
U->>API: POST /auth/signup
API->>DB: INSERT user (is_active=false) + token (hash, TTL)
API->>E: e-mail com link https://api.example.com/auth/activate/{token}
U->>E: clica no link
E->>API: GET /auth/activate/{token}
API->>DB: hash(token) válido? não-usado? não-expirado?
alt token válido
API->>DB: is_active=true + token.used_at=now
API->>U: HTML activation_success.html (botão "Ir pro login" se AUTH_LOGIN_URL setado)
else token inválido / expirado
API->>U: HTML activation_error.html (HTTP 400)
end
Password reset segue padrão similar: GET renderiza form HTML; POST (form-encoded) consome o token e renderiza success/error.
Templates HTML bundled (override droppando o mesmo nome no template_dir):
| Template | Endpoint que renderiza | Variáveis Jinja2 disponíveis |
|---|---|---|
activation_success.html |
GET /auth/activate/{token} (sucesso) |
user, login_url |
activation_error.html |
GET /auth/activate/{token} (falha) |
reason, login_url |
password_reset_form.html |
GET /auth/password-reset/{token} |
user, form_action, min_length, error, login_url |
password_reset_success.html |
POST /auth/password-reset/{token} (sucesso) |
user, login_url |
password_reset_error.html |
POST /auth/password-reset/{token} (token inválido) |
reason, login_url |
Pra override: passe template_dir no make_auth_router e crie arquivos de mesmo nome.
from fastapi import FastAPI
from tempest_fastapi_sdk import UserAuthService, make_auth_router
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
)
app = FastAPI()
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
template_dir="src/templates/auth", # opcional
),
)
Override por idioma (desde v0.59.0)
Os templates bundled agora moram em subpastas por idioma
(pt-BR/ e en-US/). Você tem duas formas de override, e o SDK
procura nesta ordem (a primeira que existir vence):
template_dir/<locale>/activation_success.html— override só daquele idioma (ex.:src/templates/auth/pt-BR/...).template_dir/activation_success.html— override flat, vale pra todos os idiomas (compatível com versões anteriores à 0.59.0; continua funcionando sem mudar nada).
Ou seja: se você já tinha templates flat, não precisa mexer em nada. Se quiser um visual diferente por idioma, crie a subpasta.
Trade-offs do Modo E:
- ✅ Zero dependência do frontend — backend é fonte única da verdade do fluxo de auth.
- ✅ MVP em minutos — sem precisar criar rotas SPA pra processar tokens.
- ✅ Funciona em projetos sem frontend — APIs públicas, intranets, ferramentas internas.
- ⚠️ JWT não é entregue automaticamente — após ativação, o usuário precisa fazer login manualmente (clicando em "Ir pro login" e usando as credenciais). Por design: zero leak de token em URL, history, ou server logs.
- ⚠️ Requer
[email]extra (Jinja2) pra renderizar as páginas HTML — mesma dependência do template de e-mail. Sem ele, omake_auth_routerlevantaRuntimeErrorna construção citando o extra, em vez de o primeiro clique ativar a conta e responder 500. - ⚠️ CSRF na form de reset — o form HTML usa POST tradicional sem token CSRF. Aceita a request porque o token de reset é one-shot + TTL curto + bound a um user específico, mas considere acoplar
CSRFMiddlewarese atacantes conseguirem prever URLs ativas.
Os endpoints JSON (POST /auth/activate/{token}, POST /auth/password-reset/confirm) continuam montados — você pode usar Modo E + manter SPA endpoints ao mesmo tempo.
Entrega de token¶
(v0.87.0+)
Por padrão o login devolve access_token / refresh_token no corpo da resposta e o cliente reenvia como Authorization: Bearer <token>. É ótimo pra apps mobile/API, mas um SPA no browser precisa guardar o token em algum lugar acessível por JS — exposto a XSS. O AUTH_TOKEN_DELIVERY deixa você escolher.
| Modo | O que muda | Pra quem |
|---|---|---|
bearer (default) |
Tokens só no body. Comportamento histórico, retrocompatível. | Mobile, APIs, clientes que mandam Authorization. |
cookie |
Tokens setados como cookies HttpOnly nos mesmos paths (/auth/login, /auth/refresh, /auth/logout); o body volta com os tokens null. |
SPA no browser — o token nunca fica visível pro JS (defesa contra XSS). |
both |
Endpoints bearer ficam em /auth/* e um conjunto cookie paralelo é montado em /auth/cookie/*. |
Um backend que serve web (cookie) e mobile (bearer) ao mesmo tempo. |
Cookie Secure exige HTTPS
Com AUTH_COOKIE_SECURE=true (default) o browser só reenvia o cookie por HTTPS. Servindo o backend em HTTP puro, o cookie é descartado e a sessão nunca persiste (login parece funcionar mas nada fica logado). Em produção, ponha TLS na frente e mantenha true; em HTTP local de dev, AUTH_COOKIE_SECURE=false.
Modo cookie¶
# .env
AUTH_TOKEN_DELIVERY=cookie
AUTH_COOKIE_SECURE=true # false só em HTTP puro
AUTH_COOKIE_SAMESITE=lax # "none" (+Secure) se o SPA for cross-site
from fastapi import FastAPI
from tempest_fastapi_sdk import UserAuthService, make_auth_router
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
)
app = FastAPI()
app.include_router(make_auth_router(auth_service, session_factory=db.session_dependency))
Fluxo no frontend — não guarda token nenhum, só chama os endpoints com credentials: "include":
// login: o browser grava os cookies HttpOnly sozinho
await fetch("/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
// requests autenticadas: o cookie access viaja automático
await fetch("/api/me", { credentials: "include" });
// renovar sessão: o cookie refresh é lido no backend, sem body
await fetch("/auth/refresh", { method: "POST", credentials: "include" });
// logout: limpa os cookies (e revoga o refresh se houver refresh_token_model)
await fetch("/auth/logout", { method: "POST", credentials: "include" });
A dependency de current_user (veja Pegando o current_user) passa a ler o access token do cookie automaticamente quando a entrega envolve cookie — o header Authorization ainda tem prioridade se vier.
Modo both¶
Monta os dois conjuntos, sem colisão de rota:
# bearer (body):
POST /auth/login
POST /auth/refresh
POST /auth/logout
# cookie (HttpOnly):
POST /auth/cookie/login
POST /auth/cookie/refresh
POST /auth/cookie/logout
O que continua no body
A entrega cookie cobre o ciclo login / refresh / logout. Ativação (POST /auth/activate/{token}), auto-login no signup (AUTH_AUTO_ACTIVATE) e o POST /auth/mfa/verify ainda retornam o par JWT no body, independente do AUTH_TOKEN_DELIVERY.
Ajuste fino dos cookies: cookie_config=
As settings AUTH_COOKIE_* cobrem o caso comum. Quando você precisa de
controle por atributo — nome, max_age, path do refresh, Domain — passe um
AuthCookieConfig no make_auth_router; ele ganha das settings:
from tempest_fastapi_sdk import AuthCookieConfig, make_auth_router
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
token_delivery="cookie",
cookie_config=AuthCookieConfig(
access_name="session",
refresh_name="session_refresh",
access_max_age=900,
refresh_max_age=1209600,
samesite="none",
secure=True,
domain=".exemplo.com",
),
),
)
Espelhe access_max_age no JWT_ACCESS_TTL_SECONDS e refresh_max_age no
JWT_REFRESH_TTL_SECONDS: cookie que vive mais que o token deixa o browser
mandando credencial morta, e o inverso desloga antes da hora. O
refresh_path é estreitado pelo router pro endpoint de refresh, então o
token longo não viaja em request comum. http_only=True é o ponto do modo
cookie — não desligue.
Fora do make_auth_router, as mesmas regras valem via
apply_auth_cookies(response, access_token=..., refresh_token=..., config=...)
e clear_auth_cookies(response, config=...) — útil em rota de login própria
(o callback de OAuth da receita de login social, por exemplo).
CORS com credenciais
Pra um SPA em outra origem enviar/receber cookies, o backend precisa de allow_credentials=True no CORS e AUTH_COOKIE_SAMESITE=none + AUTH_COOKIE_SECURE=true (portanto HTTPS). Same-origin (frontend servido do mesmo domínio da API) roda com o default lax.
Mailhog vs smtp4dev¶
Os dois interceptam SMTP local e renderizam os e-mails numa UI web. Diferenças relevantes:
| Aspecto | Mailhog | smtp4dev |
|---|---|---|
| Imagem Docker | mailhog/mailhog:latest |
rnwood/smtp4dev:latest |
| Porta SMTP padrão | 1025 |
2525 (configurável) |
| Porta da UI | 8025 |
5000 |
| Tamanho da imagem | ~10 MB | ~120 MB (.NET) |
| Multi-conta / multi-inbox | não — uma única caixa | sim — filtra por destinatário |
| API HTTP / REST | sim (/api/v2/messages) |
sim (Swagger built-in) |
| Validação de DKIM / SPF | não | sim |
| Manutenção upstream | arquivado em 2020, ainda funciona | ativa |
Sugestão: comece com Mailhog (mais leve, zero-config) e migre pra smtp4dev quando precisar de multi-inbox ou inspeção de DKIM. Para o ciclo signup → activate → reset, Mailhog é suficiente.
docker-compose.yaml — Mailhog¶
services:
mailhog:
image: mailhog/mailhog:latest
container_name: mailhog
ports:
- "1025:1025" # SMTP — aponte SMTP_HOST aqui
- "8025:8025" # UI web
SMTP_PORT=1025, abra http://localhost:8025.
docker-compose.yaml — smtp4dev¶
services:
smtp4dev:
image: rnwood/smtp4dev:latest
container_name: smtp4dev
ports:
- "2525:25" # SMTP — aponte SMTP_HOST aqui
- "5000:80" # UI web
environment:
- ServerOptions__HostName=smtp4dev
SMTP_PORT=2525, abra http://localhost:5000.
tempest generate --docker já inclui o Mailhog
Quando o projeto fixa o extra [email], o tempest generate --docker anexa automaticamente um serviço Mailhog ao docker-compose.yaml — sem colar bloco na mão e sem flag --with mailhog. Os blocos acima ficam só como referência, ou para quem prefere smtp4dev.
Customizando templates¶
O SDK ship dois templates Jinja2 bundled (activation.html + password_reset.html) — HTML responsivo, inline styles, mobile-friendly. Você nunca precisa mexer neles pra um MVP funcionar. Quando quiser branding próprio, basta criar um arquivo com o mesmo nome dentro do template_dir que você passou no EmailUtils:
emails/ # ← template_dir="emails"
├── activation.html # override do default do SDK
└── password_reset.html # override do default do SDK
EmailUtils usa um ChoiceLoader interno do Jinja2 que procura primeiro no seu diretório e só cai no template bundled se não achar. Você pode sobrescrever um, o outro, ou ambos — sem precisar copiar o template inteiro.
Variáveis disponíveis no contexto Jinja2¶
| Variável | Tipo | Em quais templates | Exemplo |
|---|---|---|---|
user |
instância de UserModel |
ambos | {{ user.email }}, {{ user.name }} (quando seu modelo expõe a coluna) |
activation_url |
str |
activation.html |
https://app.example.com/activate?token=aBcD...xYz |
reset_url |
str |
password_reset.html |
https://app.example.com/reset?token=aBcD...xYz |
expires_at |
datetime (UTC, timezone-aware) |
ambos | o valor cru, se quiser formatar do seu jeito |
expires_at_str |
str |
ambos | recomendado — já formatado e curto, sem segundos: 21/06/2026 23:25 (UTC) |
Prefira expires_at_str
Use {{ expires_at_str }} em vez de {{ expires_at }} — os
templates bundled fazem isso. Ele já vem localizado (conforme
AUTH_DEFAULT_LOCALE) e sem os segundos/microssegundos barulhentos.
O expires_at cru continua disponível se você precisar de um formato
próprio.
Exemplo: emails/activation.html enxuto¶
<!doctype html>
<html lang="pt-BR">
<body style="font-family: sans-serif; max-width: 480px; margin: auto;">
<h1>Bem-vindo(a){% if user.name %}, {{ user.name }}{% endif %}!</h1>
<p>Para ativar sua conta, clique no botão abaixo:</p>
<p>
<a href="{{ activation_url }}"
style="display: inline-block; padding: 12px 24px;
background: #4f46e5; color: white;
text-decoration: none; border-radius: 6px;">
Ativar conta
</a>
</p>
<p style="color: #6b7280; font-size: 12px;">
Link válido até {{ expires_at_str }}.
Se você não criou esta conta, ignore este e-mail.
</p>
</body>
</html>
O Jinja2 só roda quando há e-mail real
Nos modos C (AUTH_RETURN_TOKEN_IN_RESPONSE=true) e D (AUTH_AUTO_ACTIVATE=true) o template Jinja2 não é renderizado — o link sai cru no JSON, sem HTML. Só os modos A e B (e-mail SMTP real ou interceptado) exercitam o template.
Segurança¶
- Token armazenado como hash SHA-256. O plaintext sai pelo e-mail uma única vez; o banco nunca tem como reproduzir o token original. Vazamento da tabela
user_tokensnão permite ativação retroativa. - One-shot.
used_até carimbado no consume; replay rejeitado comUnauthorizedException. - TTL-bounded.
expires_atcalculado a partir deAUTH_ACTIVATION_TTL_SECONDS/AUTH_PASSWORD_RESET_TTL_SECONDS. Tokens expirados rejeitados. - Anti-enumeração.
POST /auth/password-reset/requestretorna sempre HTTP 202 + corpo genérico, independente de o e-mail existir ou não.POST /auth/loginlevanta a mesmaUnauthorizedExceptionpara email-errado, senha-errada e conta inativa, e as três pagam uma verificação bcrypt (e-mail desconhecido é conferido contra um hash descartável), então o tempo de resposta também não separa os casos. Medido nesta máquina (bcrypt custo 12,UserAuthService.logindireto, mediana de N=21): senha errada 153,8 ms, e-mail inexistente 153,8 ms; antes da correção, 0,5 ms para o e-mail inexistente. - Password floor aplicado duas vezes.
SignupSchemavalida no input;UserAuthServicerevalida antes do hash — defesa em profundidade caso alguém bypasse o schema.
Pegando o current_user da requisição¶
make_auth_router emite o JWT pair (login/activate devolvem access_token + refresh_token). Mas e depois? Quando o frontend manda Authorization: Bearer <access_token> nas suas próprias rotas, você precisa de uma dependency que decodifica o token e resolve o usuário.
Desde a v0.49.0, o próprio UserAuthService constrói essa dependency — current_user_dependency(). Ela:
- Procura o token em três lugares, na ordem header → cookie → query string (primeiro hit ganha):
Authorization: Bearer <jwt>viaHTTPBearer, depois o cookie (cookie_name) e por último o parâmetro de query (query_param). - Decodifica e verifica o JWT com o mesmo
JWTUtilsque o service usou pra assinar — sem segundo segredo pra manter sincronizado. - Pega o
sub(id do usuário) do payload, abre uma sessão a partir dodb=e devolve oUserModelpersistido. - Recusa a conta desativada com
403(v0.305.0+) — ver abaixo.
1. Declare a dependency uma vez¶
O service já tem user_model, JWTUtils e a sessão — então não precisa escrever load_user à mão. Junte as duas variantes no src/api/dependencies/auth.py:
# src/api/dependencies/auth.py
from src.api.dependencies.resources import auth_service
get_current_user = auth_service.current_user_dependency()
get_current_user_or_none = auth_service.current_user_dependency(soft=True)
Requer db= no UserAuthService
current_user_dependency precisa do db= (o AsyncDatabaseManager do Setup mínimo) — sem ele, levanta RuntimeError. Como reusa o self.jwt interno, o token é validado com o mesmo segredo que assinou — o footgun de JWT_SECRET divergente some.
O usuário é carregado na sessão do request (db.session_dependency por default), a mesma que os seus repositórios usam. Ou seja: a instância vem attached e você pode mutar/refresh sem tomar InvalidRequestError: Instance is not persistent within this Session. Se os seus repositórios dependem de outro callable de sessão (um get_session local, por exemplo), passe esse mesmo callable em session_dependency= — o FastAPI cacheia sub-dependency por callable, então um wrapper diferente abre uma segunda sessão e volta a destacar o usuário.
A mesma garantia vale para as rotas autenticadas do próprio make_auth_router (/auth/password-change, /auth/mfa/*): elas carregam o usuário na sessão do request desde a 0.171.1. Antes disso o router abria uma sessão privada e devolvia a instância já detached, então a escrita era descartada em silêncio e o refresh seguinte estourava — /auth/password-change respondia 500 mantendo a senha antiga.
Conta desativada não passa, mesmo com token válido (v0.305.0+)
O login já recusa conta inativa, mas um token emitido antes da
desativação continua válido até expirar — o access TTL inteiro. Por isso a
dependency confere is_active depois de carregar o usuário e responde
403 ACCOUNT_INACTIVE (o mesmo AuthExceptions.account_inactive do login); até a 0.304.0 ela devolvia a linha e cada rota precisava
lembrar do require_active. As rotas autenticadas do make_auth_router
(/auth/me, troca de senha e de e-mail, MFA, passkeys) fazem a mesma
conferência.
soft=TruedevolveNonepara conta inativa, como para token inválido.inactive_exception=SuaExcecaotroca o403pela sua classe.require_active=Falsevolta ao comportamento antigo — para a rota que precisa atender a conta inativa (reativação, por exemplo).
Medido em tests/auth/test_current_user_active.py: token emitido, conta
desativada, request com o token → 403; com require_active=False →
200.
Não é só o header: cookie e query string também valem
A dependency tenta header → cookie → query string e para no primeiro hit, então a mesma linha acima serve cliente bearer e cliente cookie.
- Cookie — automático. Com
AUTH_TOKEN_DELIVERYem"cookie"ou"both", ocookie_nameé auto-derivado deAUTH_ACCESS_COOKIE_NAME, o mesmo cookie que o/auth/loginbundled setou. Zero wiring extra: rota guardada por essa dependency já aceita o cookie. Em delivery bearer-only ficaNone(só header). Passecookie_name="..."pra forçar um nome. - Query string — opt-in. Nunca é auto-derivada. Serve pra cliente que não consegue mandar header nem cookie — caso clássico é o
EventSourcedo browser (SSE) cross-origin:
# src/api/dependencies/auth.py
from src.api.dependencies.resources import auth_service
get_current_user = auth_service.current_user_dependency(
cookie_name="access_token",
query_param="access_token",
)
Detalhe do fluxo SSE na receita SSE ».
Token na URL vaza
Query string entra em log de acesso, histórico do browser e header Referer. Habilite query_param só sobre TLS, só com access token de vida curta (nunca refresh token), e limpe o valor do formato de log. Se o cliente compartilha a origem da API, prefira cookie de sessão (withCredentials).
Sem UserAuthService? Monte a dependency na mão
Se o seu serviço não usa o flow bundled, a primitiva make_jwt_user_dependency aceita qualquer JWTUtils + um user_loader async de um argumento:
from uuid import UUID
from tempest_fastapi_sdk import JWTUtils, make_jwt_user_dependency
from src.api.dependencies.resources import db
from src.core.settings import settings
from src.db.models import UserModel
from src.db.repositories import UserRepository
tokens: JWTUtils = JWTUtils(
secret=settings.JWT_SECRET,
algorithm=settings.JWT_ALGORITHM,
)
async def load_user(subject: str) -> UserModel:
"""Resolve o subject do JWT (uma string UUID) para o usuário persistido."""
async with db.get_session_context() as session:
repo: UserRepository = UserRepository(session)
return await repo.get_by_id(UUID(subject))
get_current_user = make_jwt_user_dependency(tokens, load_user)
get_current_user_or_none = make_jwt_user_dependency(tokens, load_user, soft=True)
Atenção: aqui o tokens precisa usar o mesmo JWT_SECRET / JWT_ALGORITHM do login, senão todo token válido é rejeitado.
2. Injete na rota com Depends¶
# src/api/routers/users.py
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import get_current_user
from src.db.models import UserModel
from src.schemas import UserResponseSchema
router: APIRouter = APIRouter(prefix="/users", tags=["users"])
@router.get("/profile")
async def profile(current: UserModel = Depends(get_current_user)) -> UserResponseSchema:
"""Devolve o usuário dono do bearer token da requisição."""
return UserResponseSchema.model_validate(current)
current é o UserModel que o service resolveu — tipado, persistido, pronto pra uso. Token ausente ou inválido → 401 UnauthorizedException antes do corpo da rota rodar.
Pro /me puro, não escreva nada
O router empacotado já monta GET /auth/me desde a v0.198.0, com
AuthUserSchema (as colunas que BaseUserModel garante) como
resposta. Só escreva o seu quando a rota fizer algo além de
devolver a conta — agregar contadores, juntar o perfil de outra
tabela. Se for só uma coluna extra na mesma tabela, subclasse o
schema em vez de reescrever a rota:
O seu session_factory não precisa garantir nada
As páginas HTML renderizam depois do commit que consome o token, e o
async_sessionmaker do SQLAlchemy usa expire_on_commit=True por
default — o que fazia a página ler coluna expirada e responder 500
(MissingGreenlet) até a v0.265.0. Agora o router recarrega a linha
só quando ela expirou, checando inspect(user).expired, que não
toca no banco. Com o session_factory do AsyncDatabaseManager
(expire_on_commit=False) não há query extra nenhuma.
3. Auth opcional — soft=True¶
Para rotas que funcionam logado e anônimo (ex.: feed público que personaliza se houver login), use a variante soft — ela devolve None em vez de levantar:
from fastapi import APIRouter, Depends
from src.api.dependencies.auth import get_current_user_or_none
from src.db.models import UserModel
from src.schemas import PostResponseSchema
from src.services import FeedService
feed_service = FeedService()
router = APIRouter()
@router.get("/feed")
async def feed(
current: UserModel | None = Depends(get_current_user_or_none),
) -> list[PostResponseSchema]:
"""Feed público; personaliza o ranking quando há usuário logado."""
if current is None:
return await feed_service.public()
return await feed_service.personalized(current.id)
Role e permission são o próximo passo
Quando a rota precisa de papel (admin) ou permissão (users:write) e não só "estar logado", troque por make_role_dependency / make_permission_dependency. Veja a receita HTTP » — mesma JWTUtils, mesmo padrão de Depends.
4. Guards imperativos — checagem dentro do service / controller¶
As deps acima gateiam a rota (antes do handler rodar). Mas e quando você já tem o user em mãos, mais fundo na pilha (service, controller), e quer só assertar uma condição antes de continuar? Desde a v0.50.0 o SDK traz três guards prontos — sem reescrever if user is None: raise ... em todo serviço:
| Guard | Levanta se | Status HTTP |
|---|---|---|
require_authenticated(user) |
user is None |
401 UnauthorizedException |
require_active(user) |
None, ou not user.is_active |
401 / 403 ForbiddenException |
require_admin(user) |
None, ou not user.is_admin |
401 / 403 ForbiddenException |
O detalhe que importa: cada um devolve o user já estreitado — sem None e com o tipo concreto preservado — então o resto da função para de ver | None:
from tempest_fastapi_sdk import require_admin
from src.db.models import UserModel
class ReportService:
async def delete_all(self, current: UserModel | None) -> None:
"""Só admin apaga relatórios."""
admin: UserModel = require_admin(current) # 401/403, ou devolve tipado
await self.repository.purge(by=admin.id) # `admin` não é mais `| None`
Combina direto com o current_user_dependency(soft=True): a rota passa UserModel | None, e o guard decide no service.
Já tem o auth_service? Use os mirrors estáticos
Os mesmos guards existem como staticmethods em UserAuthService — auth_service.require_admin(current) — pra quando você já injeta o service e não quer um import extra. Mesma semântica, mesma exceção.
Recap¶
- O fluxo bundled cobre signup, ativação, login, reset de senha e troca de
e-mail — e o
UserTokenModelconcreto é a única tabela que o seu serviço precisa ship. - Recuperação de senha e recuperação de e-mail são fluxos diferentes: um é para quem esqueceu a senha, o outro para quem perdeu o acesso à caixa.
- Os cinco modos de operação existem porque "quem renderiza a página" muda por projeto: API pura, backend renderizando, SPA, ou combinação.
AUTH_TOKEN_DELIVERYdecide se o par de JWT sai no corpo, no cookie ou nos dois — e cookie cross-site exigeSameSite=nonemaisSecure.- Template e idioma são sobrescrevíveis, mas o default funciona sem você criar arquivo nenhum.
- Prefixo do router e URL do template têm de concordar: montar em
/apicom template apontando para a raiz dá 404 no clique do usuário, não no deploy.
Próximos passos¶
- Idempotência » — proteja
POST /auth/signupde retentativas que duplicariam linha. - Storage MinIO/S3 » — anexar avatar / foto de perfil já no signup.
- Logging » —
request_idjá propaga automaticamente em cada log emitido durante o flow. - Métricas » —
PrometheusMiddlewareconta/auth/*separadamente sem config extra.