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. - Rotas que exigem MFA — o claim
amremake_mfa_required_dependency. - Settings (
AuthSettings) — flag a flag. - Limite de tentativas —
429depois de cinco erros, e código TOTP de uso único. - Usando só o
UserAuthService(sem o router) — pra quem monta os próprios endpoints. - Conta que não usa link de e-mail —
refuses_email_linkpara operador e staff. - 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, totp_last_step) 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 as colunas totp_*."""
__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, totp_enabled_at e totp_last_step 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 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, UserRecoveryCodeModel, UserTokenModel
app = FastAPI()
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. Com MFA já ativo, responde 409 (MFA_ALREADY_ENROLLED) e não muda nada. |
| POST | /auth/mfa/confirm |
Bearer JWT | MFAConfirmSchema → LoginResponseSchema |
Confirma o enrollment com o primeiro código. A partir daqui MFA está ativo. Revoga os refresh tokens do usuário e devolve um par novo com amr de MFA (detalhes). |
| POST | /auth/mfa/verify |
— | MFAVerifySchema → LoginResponseSchema |
Passo 2 do login: troca mfa_token + código pelo JWT pair. Cinco códigos errados por conta em 15 min dão 429 (limite de tentativas). |
| 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})
# 200 → MFA ativo; o corpo traz um par novo, e o refresh token antigo morreu
Os recovery codes aparecem UMA vez
A resposta de enroll é a única vez que o secret e os recovery_codes saem em plaintext. Mostre-os com destaque e instrua o usuário a guardar offline.
Trocar o segredo: disable e depois enroll¶
Chamar enroll de novo antes do confirm substitui o segredo e os códigos pendentes: o enrollment ainda não está ativo, então não há fator a proteger. Depois do confirm, enroll recusa com 409 e não toca em nada:
{"detail":"MFA is already active — disable it before enrolling again","code":"MFA_ALREADY_ENROLLED","details":{}}
Esse é o corpo com o register_exception_handlers montado. A recusa existe porque enroll só pede o bearer token e apaga os recovery codes: aceitar um segundo enroll sobre MFA ativo deixava quem tem só o access token desligar o segundo fator, sem a senha e sem o código que disable exige. Para trocar de segredo (celular novo, segredo vazado), o usuário passa por POST /auth/mfa/disable com senha + código e então refaz enroll + confirm. No serviço, a mesma recusa é a exceção MFAAlreadyEnrolledException (subclasse de ConflictException), levantada por mfa_enroll mesmo com o kill-switch AUTH_MFA_ENABLED desligado.
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
Rotas que exigem MFA¶
Ligar o MFA protege o login. Uma rota sensível (criar outro staff, trocar
dado bancário) quer saber outra coisa: esta sessão passou pelo segundo
fator? Para isso todo access token do SDK carrega o claim amr (RFC 8176):
| Como a sessão começou | amr |
|---|---|
POST /auth/login só com senha (e o signup com ativação automática) |
["pwd"] |
POST /auth/activate/{token} e POST /auth/password-reset/confirm de uma conta sem MFA (v0.314.0+) |
["email"] (AMR_EMAIL_LINK) |
POST /auth/mfa/verify (código TOTP ou recovery code) |
o primeiro passo + "otp": ["pwd", "otp"] depois do /auth/login, ["ext", "otp"] depois do login social, ["email", "otp"] depois da ativação ou do reset |
POST /auth/mfa/confirm (o par novo que ele devolve) |
["pwd", "otp"] |
POST /auth/webauthn/authenticate/complete |
["hwk"] |
login social (/auth/oauth/...) de conta sem MFA |
["ext"] |
Numa conta com MFA inscrito, o /auth/password-reset/confirm e o
/auth/activate/{token} (de uma conta reativada) não emitem par: respondem o
desafio mfa_required=true + mfa_token, e o par só sai do
/auth/mfa/verify, com amr == ["email", "otp"]. É o mesmo tratamento do
/auth/login — um e-mail comprometido não pode ser atalho pro segundo fator.
Desde a v0.314.0 o login social segue a mesma regra: o callback e o
/auth/oauth/{provider}/token de uma conta com TOTP respondem o desafio, e o
/auth/mfa/verify emite ["ext", "otp"] (veja a
receita de login social).
Por que email, e não pwd nem otp
A ativação não recebe senha nenhuma, e o reset prova a caixa de e-mail
antes de gravar a senha nova — pwd afirmaria uma checagem que não
aconteceu. email fica fora do registro da RFC 8176, como o ext, porque
a RFC não tem valor para "link de uso único mandado por e-mail". E não é
otp de propósito: o link é um fator, e chamá-lo de senha de uso
único o faria passar no make_mfa_required_dependency. Ele fica fora de
MFA_AMR_METHODS, então o gate continua recusando.
O refresh repete o amr da família — ele fica gravado na linha do refresh
token (ou no próprio refresh JWT, no modo stateless) —, então rotacionar nunca
promove uma sessão de senha a MFA. make_mfa_required_dependency lê esse claim:
# src/api/routers/staff.py
from typing import Any
from fastapi import APIRouter, Depends
from tempest_fastapi_sdk import JWTUtils, make_mfa_required_dependency
jwt = JWTUtils("troque-por-um-segredo-de-32-bytes-ou-mais")
require_mfa = make_mfa_required_dependency(jwt)
router = APIRouter(prefix="/staff", tags=["staff"])
@router.post("/invite")
async def invite_staff(claims: dict[str, Any] = Depends(require_mfa)) -> dict[str, str]:
"""Só uma sessão que passou pelo segundo fator convida outro staff."""
return {"invited_by": str(claims["sub"])}
Token só com senha recebe 403 com code MFA_REQUIRED
(MFARequiredException); sem token, 401. Token sem amr — emitido antes
desta versão — também recebe 403: a janela de rollout nunca conta sessão
desconhecida como MFA, e o usuário sai dela refazendo o login de dois passos.
Passkey conta como MFA?
Por padrão, não: o default de methods= é {"otp"}. Se o seu
AUTH_WEBAUTHN_USER_VERIFICATION é "required", o passkey já prova posse
e um fator local, e você pode aceitá-lo com
make_mfa_required_dependency(jwt, methods={"otp", "hwk"}).
Confirmar o MFA encerra as outras sessões¶
Quem liga o MFA costuma ligar porque desconfia que alguém tem a senha. Por
isso mfa_confirm revoga todos os refresh tokens do usuário na mesma
transação: uma sessão aberta só com senha, antes do fator existir, recebe 401
no próximo /auth/refresh. O dispositivo que confirmou não perde nada — a rota
/auth/mfa/confirm responde 200 com um par novo, já com amr de MFA (no
corpo para quem chamou com bearer, em cookie para quem chamou pelo cookie de
acesso).
Chamando o serviço direto, o opt-out é explícito:
mfa_confirm(session, user=user, code=code, revoke_sessions=False). A sessão
que sobra continua ["pwd"], nunca "otp". Access token já emitido vive até o
TTL curto acabar, mas também é ["pwd"], então a dependência acima o recusa.
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. |
Limite de tentativas¶
Desde a 0.306.0 o passo 2 do login tem dois freios que você não precisa escrever.
Cinco códigos errados, e a conta espera¶
POST /auth/mfa/verify conta os códigos errados por conta, não por mfa_token. Depois de cinco erros em quinze minutos, a sexta tentativa responde 429 sem olhar o código, mesmo que ele esteja certo:
{"detail":"too many invalid MFA codes, try again later","code":"MFA_THROTTLED","details":{"retry_after_seconds":900}}
A resposta leva o header Retry-After com os segundos que faltam para a janela fechar. Esse é o corpo com o register_exception_handlers montado.
A chave é a conta porque o mfa_token não segura nada: quem tem a senha pede outro em POST /auth/login quando quiser. Um token novo continua recebendo 429 até a janela passar. Um código certo antes do limite loga normalmente e zera a contagem, então quem erra um dígito duas vezes não fica devendo nada.
Mais de um worker? Passe o Redis
O limite padrão guarda o contador num InMemoryThrottleBackend, que vive no processo. Com uvicorn --workers 4, gunicorn ou mais de um pod, cada processo tem o seu contador, e a conta chega a 5 * processos tentativas por janela. Nesse caso, passe um AttemptThrottle sobre o Redis que os processos compartilham:
from fastapi import FastAPI
from redis.asyncio import Redis
from tempest_fastapi_sdk import AttemptThrottle, UserAuthService, make_auth_router
from src.api.dependencies import get_session
from src.db.models import UserRecoveryCodeModel
def mount_auth(app: FastAPI, auth_service: UserAuthService, redis: Redis) -> None:
"""Monta o router de auth com o limite de MFA no Redis."""
mfa_throttle = AttemptThrottle(redis, max_attempts=5, window_seconds=900)
app.include_router(
make_auth_router(
auth_service,
session_factory=get_session,
recovery_code_model=UserRecoveryCodeModel,
mfa_throttle=mfa_throttle,
),
)
O mesmo parâmetro serve para mudar o orçamento (max_attempts, window_seconds).
Um código vale um login¶
O código TOTP fica válido pela janela de drift inteira (AUTH_MFA_VERIFY_WINDOW=1 dá 90 segundos). Sem mais nada, quem visse o código passar por cima do ombro, num log ou num proxy, entraria com ele de novo dentro desses 90 segundos.
Agora o SDK grava o passo de 30 segundos do último código aceito na coluna totp_last_step e recusa qualquer código do mesmo passo ou de um anterior como se fosse um código errado (no verify, o mesmo 401). Vale para confirm, verify, disable e a recuperação de e-mail (request_email_recovery): o código usado para ativar o MFA já não serve para o primeiro login, e o usuário espera o próximo aparecer no app. O desafio do painel admin (POST /admin/mfa) segue a mesma regra e tem o mesmo limite de tentativas: veja Admin.
A gravação é um UPDATE condicional: quem decide é a linha no banco, não o objeto carregado, então uma segunda sessão que leu o usuário antes do primeiro aceite também recebe a recusa (medido com SQLite, duas sessões abertas sobre a mesma linha).
Coluna nova: totp_last_step
Quem já usa MFAMixin precisa da migration antes de subir a 0.306.0 — veja o guia de migração.
Service direto¶
Se você monta os próprios endpoints (sem make_auth_router), os métodos do UserAuthService cobrem o ciclo inteiro:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
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). Levanta MFAAlreadyEnrolledException com MFA ativo. |
mfa_confirm |
(session, *, user, code) -> None |
Ativa MFA. |
mfa_verify |
(session, *, mfa_token, code, recovery_code_model, throttle=None) -> UserModel |
Usuário autenticado (mint o JWT depois). |
mfa_disable |
(session, *, user, password, code, recovery_code_model) -> None |
Limpa segredo + códigos. |
mfa_reset |
(session, *, user, recovery_code_model, revoke_sessions=True) -> int |
Limpa segredo + códigos sem prova e revoga as sessões; devolve quantas caíram. Caminho de operador — veja Conta que perdeu o segundo fator. |
change_password_with_mfa |
(session, *, user, code, new_password, recovery_code_model=None, revoke_sessions=True, throttle=None) -> UserModel |
Troca a senha provando o fator. Levanta MFANotEnrolledException em conta sem MFA. |
Trocar a senha provando o fator¶
A conta de equipe esqueceu a senha, e o link de reset por e-mail não serve — a caixa é compartilhada, ou o e-mail é justamente o que não se confia. Se a pessoa ainda tem o app Authenticator (ou um recovery code), o fator é a prova: change_password_with_mfa troca a senha sem a senha atual e sem link.
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
from src.db.models import UserModel, UserRecoveryCodeModel
async def reset_staff_password(
service: UserAuthService,
session: AsyncSession,
user: UserModel,
code: str,
new_password: str,
) -> None:
"""Troca a senha de uma conta com MFA ativo, provando o fator."""
await service.change_password_with_mfa(
session,
user=user,
code=code,
new_password=new_password,
recovery_code_model=UserRecoveryCodeModel,
)
await session.commit()
O que o método garante, nesta ordem:
- Conta sem MFA ativo é recusada com
MFANotEnrolledException(422,code="MFA_NOT_ENROLLED") — inclusive com o kill-switchAUTH_MFA_ENABLEDdesligado, que faz toda conta ler como "sem MFA", igual ao login. - Senha fora da política é recusada antes do código ser conferido (
ValidationException), então uma senha fraca não queima um recovery code. - O código é conferido pela mesma rotina do
mfa_verify: o TOTP é de uso único e o recovery code ganhaused_at. Código errado levantaUnauthorizedException("invalid MFA code"). - As sessões são revogadas por padrão, na mesma transação, como no
confirm_password_reset.revoke_sessions=Falseé a saída explícita.
Não é login: nenhum par de token é emitido e last_login_at não muda. Sem recovery_code_model, só código TOTP é aceito. Passe throttle= para limitar tentativas: a chave é mfa:{user_id}, a mesma do mfa_verify, então os erros aqui e no segundo passo do login gastam um orçamento só.
No terminal
tempest user set-password usa este método quando a conta tem MFA ativo — veja CLI ».
Bootstrap da primeira conta privilegiada¶
A rota que cria staff pode exigir MFA (make_mfa_required_dependency), mas a primeira conta privilegiada não tem quem a convide: nasce pelo terminal. tempest user create --require-mfa garante que ela nunca exista sem segundo fator:
tempest user create --email staff@example.com --admin --require-mfa \
--audit-model src.db.models:AuditLogModel --event staff.bootstrapped
Add this account to the authenticator app:
otpauth URI: otpauth://totp/Tempest:staff%40example.com?secret=GGZPWWOVQ6QIXMCMDOARELP3SR7JF424&issuer=Tempest
secret: GGZPWWOVQ6QIXMCMDOARELP3SR7JF424
Code from the authenticator app: 123456
Created admin: staff@example.com (id=2ced9167-4e7a-4dc5-8626-72b64ff7e4a7)
MFA active. Recovery codes (shown once, store them now):
_MqMXAW-jzw
...
Peça a peça:
- O usuário é inserido, o
mfa_enrollgera segredo e recovery codes e omfa_confirmconfere o código — tudo numa transação só, que só faz commit com código válido. Três códigos errados: sai com código 1 e nada é gravado. --audit-modelgravarecord_event(actor="cli",changescomemail,is_adminemfa_enrolled) nessa mesma transação. O model precisa deAuditRequestMixin— é a colunaeventque guarda o nome. Sem--audit-model, o comando segue e avisa no stderr que nenhum evento foi gravado: projeto sem tabela de auditoria ainda consegue fazer o bootstrap.- A tabela de recovery codes vem de
--recovery-code-model(defaultsrc.db.models:UserRecoveryCodeModel), e oUserModelprecisa deMFAMixin; faltando qualquer um, o comando sai com código 2 antes de escrever.
Depois, tempest user set-password --email staff@example.com nessa conta pede o código (TOTP ou recovery code) e troca a senha por change_password_with_mfa. Com AUTH_MFA_ENABLED desligado, nem o login nem o set-password pedem o fator.
Conta que perdeu o segundo fator¶
O dono da conta perdeu o celular e os recovery codes. O disable pede senha e código; o set-password da CLI pede o código. Nenhum dos dois ajuda — e é assim que deve ser, porque pedir o fator é o ponto. A saída é do operador, explícita e auditada:
Remove the second factor of staff@example.com? It will log in with the password alone until it enrolls again. [y/N]: y
MFA reset for staff@example.com (id=2ced9167-4e7a-4dc5-8626-72b64ff7e4a7)
Revoked 2 active refresh token(s).
Peça a peça:
- Confirmação obrigatória. Num terminal, o comando pergunta; fora dele (CI, pipe), exige
--yese sai com código 1 sem ele. Recusou a pergunta, nada muda. - O reset é o
UserAuthService.mfa_reset: limpatotp_secret,totp_enabled_atetotp_last_stepe apaga todos os recovery codes do usuário — a mesma limpeza domfa_disable, sem a prova. - As sessões caem por padrão, na mesma transação: quem ficou com o celular perdido pode ter uma sessão viva também.
--keep-sessionsé a saída explícita; a tabela vem de--refresh-token-model(defaultsrc.db.models:UserRefreshTokenModel). --audit-modelgravarecord_eventna mesma transação (actor="cli",changescommfa_enabledesessions_revoked), com o nome de--event(defaultuser.mfa_reset). Se a escrita da auditoria falha, o reset volta junto. Sem--audit-model, o comando segue e avisa no stderr que nenhum evento foi gravado.- Conta sem MFA ativo sai com código 2 e
has no MFA active, nothing to reset.— nada é escrito, nem o evento.
Depois do reset a conta entra só com a senha, até inscrever o fator de novo — e o set-password deixa de pedir código. Se a senha também se perdeu, rode os dois, nessa ordem.
Do código, sem a CLI — num painel de suporte, por exemplo, atrás da sua própria checagem de permissão:
from sqlalchemy.ext.asyncio import AsyncSession
from tempest_fastapi_sdk import UserAuthService
from src.db.models import UserModel, UserRecoveryCodeModel
async def support_mfa_reset(
service: UserAuthService,
session: AsyncSession,
user: UserModel,
) -> int:
"""Remove o segundo fator de uma conta que o perdeu. Retorna as sessões revogadas."""
revoked = await service.mfa_reset(
session,
user=user,
recovery_code_model=UserRecoveryCodeModel,
)
await session.commit()
return revoked
mfa_reset não prova nada
Não confere senha nem código: é um caminho de operador. Nunca o exponha numa rota que o próprio dono da conta alcança — aí ele vira o atalho que o disable existe para impedir. Grave o evento de auditoria na mesma transação.
Conta que não usa link de e-mail (v0.316.0+)¶
Um operador, uma conta de staff, uma conta de serviço: quem lê a caixa de entrada não deveria conseguir, sozinho, trocar a senha ou ativar a conta. Com MFA inscrito o link já não entra (responde o desafio), mas ainda troca a senha — e quem controla a caixa trava o dono para fora. O SDK aceita um predicado por conta que fecha os dois fluxos de link:
# src/api/app.py
from tempest_fastapi_sdk import UserAuthService
from src.core.settings import settings
from src.db.models import UserModel, UserTokenModel
def refuses_email_link(user: UserModel) -> bool:
"""Operadores nunca usam link de e-mail."""
return user.is_admin
auth_service = UserAuthService(
user_model=UserModel,
token_model=UserTokenModel,
auth_settings=settings,
jwt_settings=settings,
refuses_email_link=refuses_email_link,
)
O predicado recebe a instância do seu UserModel (por isso pode ser
tipado contra ele) e devolve bool — True recusa. O default
refuses_email_link=None mantém o comportamento de sempre.
Quando o predicado casa:
POST /auth/password-reset/requestePOST /auth/activation/requestrespondem o mesmo202com o mesmo corpo de um endereço desconhecido, e não gravam token nem mandam e-mail. A recusa não é enumerável.POST /auth/password-reset/confirmePOST /auth/activate/{token}respondem403com o códigoEMAIL_LINK_FORBIDDEN. O token é consumido e o consumo é commitado antes do erro (o link não volta a valer), e a senha e ois_activeficam intactos.-
As páginas HTML de
AUTH_BACKEND_LINKS=true—GET /auth/activate/{token},GETePOST /auth/password-reset/{token}— renderizam a página de erro do fluxo com status403. OGETdo formulário de reset nem mostra o formulário (e, como só espia o token, não o consome). -
POST /auth/signupcomAUTH_AUTO_ACTIVATE=falsecria a conta inativa e não grava token de ativação nem manda e-mail. A resposta é o mesmo201de um cadastro pendente comum (activation_required=true,activation_url=null, sem par de JWT): só ouser_idmuda. Ative essa conta por fora —tempest user activate, ou crie-a já ativa comtempest user create.
O predicado vê a linha recém-inserida, com as colunas do signup_schema
já preenchidas e antes do on_signup: o que ele lê precisa estar no
corpo do cadastro, não ser escrito pelo hook.
Com AUTH_AUTO_ACTIVATE=true o cadastro não consulta o predicado
A conta já nasce ativa e não depende de link nenhum, então o signup
devolve o par de JWT como sempre. Os fluxos de reset continuam fechados
para ela.
No modo dev, o corpo diferencia
Com AUTH_RETURN_TOKEN_IN_RESPONSE=true (ou sem EmailUtils) o
cadastro comum devolve o activation_url no corpo, e o recusado devolve
null — o mesmo que o POST /auth/activation/request já faz nesse
modo. A resposta idêntica vale quando o link vai por e-mail.
Quem chama UserAuthService.signup direto recebe, no segundo item da
tupla, None (conta nasceu ativa), um ActivationToken (link emitido) ou
um ActivationWithheld (link retido). Só o None autoriza emitir JWT.
Trocar o código
Como account_inactive, email_link_forbidden é um campo de
AuthExceptions: passe uma subclasse com o seu code para o
consumidor continuar reagindo ao que já conhece. A tradução do corpo
(EMAIL_LINK_FORBIDDEN, PT-BR e EN no catálogo padrão) continua valendo.
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, eenrollnão é um atalho para ele. Uma sessão sequestrada não consegue desligar MFA sozinha — precisa da senha e de um fator ativo.enrollsobre MFA ativo responde409em vez de apagar os recovery codes, então o access token sozinho não reinicia o fator.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.matching_step(e overify, que o chama) compara o código de cada passo da janela comhmac.compare_digest. - Código TOTP de uso único e limite por conta. Veja Limite de tentativas.
Recap¶
- TOTP é o código de 6 dígitos do app Authenticator: servidor e app compartilham um segredo, e o código é derivado dele mais o relógio.
- O extra é
[mfa](trazpyotp), por cima de[auth], e nada é montado semAUTH_MFA_ENABLED=True. - O router exige
recovery_code_model: sem ele levantaRuntimeErrorno wiring, não em produção — perder o telefone sem código de recuperação é perder a conta. - Com MFA ativo,
POST /auth/logindeixa de devolver o par de JWT: devolvemfa_required=Truee um token intermediário, e o segundo passo troca código por JWT. - Quem monta os próprios endpoints usa os métodos do
UserAuthServicedireto, sem o router.change_password_with_mfatroca a senha provando o fator, sem link de e-mail e sem emitir token. - Quem perdeu o celular e os recovery codes volta pelo operador:
tempest user mfa-reset(oumfa_resetno código) remove o fator sem prova, com confirmação, auditoria na mesma transação e sessões revogadas. - O segredo TOTP fica no
UserModel— considere criptografar a coluna em repouso. /auth/mfa/verifyresponde429depois de cinco códigos errados por conta em 15 minutos, e cada código TOTP vale um login. Com mais de um worker, passemfa_throttle=sobre o Redis.- O access token diz como a sessão começou (
amr), emake_mfa_required_dependencyrecusa com403MFA_REQUIREDa que não passou pelo segundo fator. Confirmar o MFA encerra as outras sessões. refuses_email_link=fecha o fluxo de link por e-mail para contas como operadores: o pedido responde igual a um endereço desconhecido, a confirmação devolve403EMAIL_LINK_FORBIDDENsem trocar a senha, e o cadastro cria a conta inativa sem mandar link.
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.