MFA / 2FA com TOTP (Authenticator)¶
Desde v0.35.0 o fluxo de auth bundled suporta autenticação de dois fatores com apps Authenticator (Google Authenticator, 1Password, Authy, etc.) seguindo o padrão TOTP (RFC 6238). Você ganha quatro endpoints prontos, códigos de recuperação single-use, e um login de dois passos — tudo controlado por um kill-switch global.
Conteúdo da receita¶
- Como funciona em 30 segundos — o modelo mental do fluxo de dois passos.
- Setup — extra
[mfa], colunas novas noUserModel, tabela de recovery codes. - Wiring — passar
recovery_code_modelpromake_auth_router. - Os quatro endpoints — enroll / confirm / verify / disable.
- Login de dois passos — como
POST /auth/loginmuda quando MFA está ativo. - Settings (
AuthSettings) — flag a flag. - Usando só o
UserAuthService(sem o router) — pra quem monta os próprios endpoints. - Segurança.
- Próximos passos.
Como funciona¶
TOTP é o código de 6 dígitos que rola a cada 30 segundos no seu app Authenticator. O servidor e o app compartilham um segredo (gerado no enrollment); ambos derivam o mesmo código do relógio atual. Nenhum SMS, nenhuma rede — pura matemática local.
O fluxo tem dois momentos:
- Enrollment (uma vez) — o usuário logado pede um segredo, escaneia o QR code, e confirma digitando o primeiro código. A partir daí MFA está ativo.
- Login (toda vez) — senha valida o passo 1, mas em vez do JWT pair o backend devolve um
mfa_tokencurto. O usuário digita o código do Authenticator; o passo 2 trocamfa_token+ código pelo JWT pair real.
Por que dois passos e não tudo de uma vez?
Separar mantém a senha e o segundo fator desacoplados. O mfa_token (TTL de 5 min por padrão) carrega só o sub do usuário — interceptá-lo sozinho não basta pra logar, porque ainda falta o código do Authenticator.
Setup¶
Requer o extra [mfa] (instala pyotp), por cima de [auth]:
Colunas via MFAMixin¶
As colunas do MFA (totp_secret, totp_enabled_at) não moram no BaseUserModel — elas vêm de um mixin opt-in, MFAMixin. Misture-o no seu UserModel só quando for adotar MFA, assim projetos que nunca ligam a feature não carregam colunas mortas:
# src/db/models/user.py
from tempest_fastapi_sdk import BaseUserModel, MFAMixin
class UserModel(MFAMixin, BaseUserModel):
"""Concrete user table — MFAMixin adiciona totp_secret / totp_enabled_at."""
__tablename__ = "users"
Ordem do MRO
O mixin vem antes do BaseUserModel na lista de bases — mesmo padrão de AuditMixin / SoftDeleteMixin. O mixin também expõe a property is_mfa_active (totp_enabled_at is not None).
Migration obrigatória
totp_secret e totp_enabled_at são colunas novas. Rode uv run tempest db revision -m "mfa columns" + uv run tempest db upgrade antes de ligar a flag.
Tabela de recovery codes¶
Códigos de recuperação salvam o usuário que perdeu o celular. São single-use, mostrados uma vez no enrollment, e o banco guarda só o hash SHA-256 de cada um. BaseUserRecoveryCodeModel é abstrato — use o helper make_user_recovery_code_model pra criar a tabela concreta amarrada à sua tabela de users:
# src/db/models/__init__.py
from tempest_fastapi_sdk import make_user_recovery_code_model
from src.db.models.user import UserModel
from src.db.models.user_token import UserTokenModel
UserRecoveryCodeModel = make_user_recovery_code_model(
user_table="users",
tablename="user_recovery_codes",
class_name="UserRecoveryCodeModel",
)
__all__: list[str] = [
"UserModel",
"UserTokenModel",
"UserRecoveryCodeModel",
]
Prefere subclassar à mão?
O helper é só açúcar. O equivalente explícito:
from uuid import UUID
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from tempest_fastapi_sdk import BaseUserRecoveryCodeModel
class UserRecoveryCodeModel(BaseUserRecoveryCodeModel):
__tablename__ = "user_recovery_codes"
user_id: Mapped[UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
Wiring¶
Ligue a flag AUTH_MFA_ENABLED e passe o recovery_code_model pro router. Sem o modelo, o router levanta RuntimeError no build — é uma trava proposital:
# src/api/app.py
from tempest_fastapi_sdk import (
AsyncDatabaseManager,
UserAuthService,
make_auth_router,
)
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel, UserRecoveryCodeModel
db = AsyncDatabaseManager(settings.DATABASE_URL)
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings, # mistura AuthSettings (AUTH_MFA_* abaixo)
jwt_settings=settings,
email=None,
)
app.include_router(
make_auth_router(
auth_service,
session_factory=db.session_dependency,
recovery_code_model=UserRecoveryCodeModel, # obrigatório quando MFA on
),
)
Kill-switch global
Com AUTH_MFA_ENABLED=False (default), os endpoints /auth/mfa/* respondem 404 e o login ignora qualquer totp_secret persistido — útil pra desligar MFA na emergência de um Authenticator fora do ar, sem mexer no banco.
Endpoints¶
Os quatro só são montados quando AUTH_MFA_ENABLED=True:
| Método | Path | Auth | Body / Output | Comportamento |
|---|---|---|---|---|
| POST | /auth/mfa/enroll |
Bearer JWT | — → MFAEnrollResponseSchema |
Gera segredo + URI do QR + N recovery codes. Mostrados só uma vez. Não ativa MFA ainda. |
| POST | /auth/mfa/confirm |
Bearer JWT | MFAConfirmSchema |
Confirma o enrollment com o primeiro código. A partir daqui MFA está ativo. |
| POST | /auth/mfa/verify |
— | MFAVerifySchema → LoginResponseSchema |
Passo 2 do login: troca mfa_token + código pelo JWT pair. |
| POST | /auth/mfa/disable |
Bearer JWT | MFADisableSchema |
Desliga MFA. Exige senha e código ativo (TOTP ou recovery). |
Fluxo de enrollment¶
import httpx
BASE = "http://localhost:8000"
access = "<JWT do usuário logado>"
headers = {"Authorization": f"Bearer {access}"}
# 1. Enroll — devolve segredo, URI do QR e os recovery codes (uma vez!)
r = httpx.post(f"{BASE}/auth/mfa/enroll", headers=headers)
data = r.json()
print(data["provisioning_uri"]) # renderize como QR code
print(data["recovery_codes"]) # mostre pro usuário salvar OFFLINE
# 2. Usuário escaneia o QR no Authenticator e digita o código gerado:
code = input("Código do Authenticator: ")
httpx.post(f"{BASE}/auth/mfa/confirm", headers=headers, json={"code": code})
# 204 No Content → MFA ativo
Os recovery codes aparecem UMA vez
A resposta de enroll é a única vez que o secret e os recovery_codes saem em plaintext. Chamar enroll de novo rotaciona o segredo e invalida todos os códigos anteriores. Mostre-os com destaque e instrua o usuário a guardar offline.
Login de dois passos¶
Quando o usuário tem MFA ativo, POST /auth/login não devolve mais o JWT pair direto — devolve mfa_required=True + um mfa_token curto:
import httpx
BASE = "http://localhost:8000"
# Passo 1 — senha
r1 = httpx.post(
f"{BASE}/auth/login",
json={"email": "ana@example.com", "password": "strong-pass-12-chars"},
)
body = r1.json()
# {
# "user_id": "...",
# "access_token": null,
# "refresh_token": null,
# "mfa_required": true,
# "mfa_token": "eyJhbGciOi..."
# }
# Passo 2 — código do Authenticator (ou um recovery code)
code = input("Código do Authenticator: ")
r2 = httpx.post(
f"{BASE}/auth/mfa/verify",
json={"mfa_token": body["mfa_token"], "code": code},
)
tokens = r2.json()
# { "access_token": "...", "refresh_token": "...", "mfa_required": false }
Para usuários sem MFA (ou com o kill-switch desligado), POST /auth/login continua devolvendo o JWT pair direto, com mfa_required=False — o frontend simplesmente checa esse campo e ramifica.
sequenceDiagram
participant F as Frontend
participant API as Backend (SDK)
participant DB as Banco
F->>API: POST /auth/login (email + senha)
API->>DB: valida credenciais
alt usuário tem MFA ativo
API->>F: 200 {mfa_required: true, mfa_token: "..."}
F->>API: POST /auth/mfa/verify (mfa_token + código)
API->>DB: valida código TOTP ou recovery code
API->>F: 200 {access_token, refresh_token}
else MFA desligado / não-inscrito
API->>F: 200 {access_token, refresh_token, mfa_required: false}
end
Settings¶
Mixe AuthSettings na sua classe Settings (como no recipe de auth flow) e configure por env:
# .env — MFA
AUTH_MFA_ENABLED=true # kill-switch global (default false)
AUTH_MFA_ISSUER=Acme Inc. # nome mostrado no Authenticator
AUTH_MFA_RECOVERY_CODES_COUNT=10 # quantos códigos gerar no enroll (2..50)
AUTH_MFA_TOKEN_TTL_SECONDS=300 # TTL do mfa_token entre passo 1 e 2 (30..900)
AUTH_MFA_VERIFY_WINDOW=1 # tolerância de drift, em passos de 30s (0..4)
| Setting | Default | O que faz |
|---|---|---|
AUTH_MFA_ENABLED |
False |
Liga os endpoints /auth/mfa/* e o login de dois passos. |
AUTH_MFA_ISSUER |
"Tempest" |
Label ao lado do email no app Authenticator. Use o nome do seu produto. |
AUTH_MFA_RECOVERY_CODES_COUNT |
10 |
Quantidade de recovery codes gerados no enrollment. |
AUTH_MFA_TOKEN_TTL_SECONDS |
300 |
Vida do mfa_token intermediário (5 min). |
AUTH_MFA_VERIFY_WINDOW |
1 |
Janela de tolerância pro relógio do usuário. 1 aceita passo anterior + atual + próximo (90s). 0 é estrito; acima de 2 enfraquece. |
Service direto¶
Se você monta os próprios endpoints (sem make_auth_router), os seis métodos do UserAuthService cobrem o ciclo inteiro:
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models import UserModel, UserRecoveryCodeModel
async def enroll_user(service: UserAuthService, session: AsyncSession, user: UserModel) -> None:
"""Gera segredo + recovery codes e mostra ao usuário (uma vez)."""
secret, provisioning_uri, recovery_codes = await service.mfa_enroll(
session,
user=user,
recovery_code_model=UserRecoveryCodeModel,
)
await session.commit()
# renderize provisioning_uri como QR; mostre recovery_codes
async def confirm_user(
service: UserAuthService, session: AsyncSession, user: UserModel, code: str
) -> None:
"""Ativa MFA depois que o usuário prova que escaneou o QR."""
await service.mfa_confirm(session, user=user, code=code)
await session.commit()
Superfície completa:
| Método | Assinatura (resumida) | Retorno |
|---|---|---|
is_mfa_enrolled |
(user) -> bool |
True se MFA ativo (e kill-switch ligado). |
issue_mfa_token |
(user) -> str |
JWT curto que liga passo 1 e passo 2. |
mfa_enroll |
(session, *, user, recovery_code_model) -> tuple[str, str, list[str]] |
(secret, provisioning_uri, recovery_codes). |
mfa_confirm |
(session, *, user, code) -> None |
Ativa MFA. |
mfa_verify |
(session, *, mfa_token, code, recovery_code_model) -> UserModel |
Usuário autenticado (mint o JWT depois). |
mfa_disable |
(session, *, user, password, code, recovery_code_model) -> None |
Limpa segredo + códigos. |
Segurança¶
- Segredo TOTP persistido no
UserModel. Considere criptografar a colunatotp_secretem repouso (Postgrespgcryptoou um wrapper Fernet no nível da aplicação). - Recovery codes guardados como hash SHA-256. O plaintext sai uma única vez no enrollment; vazamento da tabela não rende códigos usáveis.
- Recovery codes são single-use.
used_até carimbado no consume; replay rejeitado. disableexige senha + código. Uma sessão sequestrada não consegue desligar MFA sozinha — precisa da senha e de um fator ativo.mfa_tokené curto e bound ao usuário. TTL de 5 min por padrão; carregapurpose: "mfa_pending"+ osub. Tokens de outro propósito são rejeitados emmfa_verify.- Verificação em tempo constante.
TOTPHelper.verifydelega aopyotp, que compara o código comhmac.compare_digest.
Próximos passos¶
- Auth flow (signup/reset) » — o fluxo de conta local que o MFA estende.
- Sessões server-side » — alternativa ao JWT, combinável com MFA no passo 1.
- Segurança » — CSRF, rate-limit e body-size limit pros endpoints de auth.