Ir para o conteúdo

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

  1. Como funciona em 30 segundos — o modelo mental do fluxo de dois passos.
  2. Setup — extra [mfa], colunas novas no UserModel, tabela de recovery codes.
  3. Wiring — passar recovery_code_model pro make_auth_router.
  4. Os quatro endpoints — enroll / confirm / verify / disable.
  5. Login de dois passos — como POST /auth/login muda quando MFA está ativo.
  6. Rotas que exigem MFA — o claim amr e make_mfa_required_dependency.
  7. Settings (AuthSettings) — flag a flag.
  8. Limite de tentativas — 429 depois de cinco erros, e código TOTP de uso único.
  9. Usando só o UserAuthService (sem o router) — pra quem monta os próprios endpoints.
  10. Conta que não usa link de e-mail — refuses_email_link para operador e staff.
  11. Segurança.
  12. 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_token curto. O usuário digita o código do Authenticator; o passo 2 troca mfa_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]:

uv add "tempest-fastapi-sdk[auth,mfa]>=0.151.1"

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:

  1. Conta sem MFA ativo é recusada com MFANotEnrolledException (422, code="MFA_NOT_ENROLLED") — inclusive com o kill-switch AUTH_MFA_ENABLED desligado, que faz toda conta ler como "sem MFA", igual ao login.
  2. Senha fora da política é recusada antes do código ser conferido (ValidationException), então uma senha fraca não queima um recovery code.
  3. O código é conferido pela mesma rotina do mfa_verify: o TOTP é de uso único e o recovery code ganha used_at. Código errado levanta UnauthorizedException ("invalid MFA code").
  4. 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:

  1. O usuário é inserido, o mfa_enroll gera segredo e recovery codes e o mfa_confirm confere 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.
  2. --audit-model grava record_event (actor="cli", changes com email, is_admin e mfa_enrolled) nessa mesma transação. O model precisa de AuditRequestMixin — é a coluna event que 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.
  3. A tabela de recovery codes vem de --recovery-code-model (default src.db.models:UserRecoveryCodeModel), e o UserModel precisa de MFAMixin; 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:

tempest user mfa-reset --email staff@example.com \
    --audit-model src.db.models:AuditLogModel
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:

  1. Confirmação obrigatória. Num terminal, o comando pergunta; fora dele (CI, pipe), exige --yes e sai com código 1 sem ele. Recusou a pergunta, nada muda.
  2. O reset é o UserAuthService.mfa_reset: limpa totp_secret, totp_enabled_at e totp_last_step e apaga todos os recovery codes do usuário — a mesma limpeza do mfa_disable, sem a prova.
  3. 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 (default src.db.models:UserRefreshTokenModel).
  4. --audit-model grava record_event na mesma transação (actor="cli", changes com mfa_enabled e sessions_revoked), com o nome de --event (default user.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.
  5. 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.


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/request e POST /auth/activation/request respondem o mesmo 202 com 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/confirm e POST /auth/activate/{token} respondem 403 com o código EMAIL_LINK_FORBIDDEN. O token é consumido e o consumo é commitado antes do erro (o link não volta a valer), e a senha e o is_active ficam intactos.
  • As páginas HTML de AUTH_BACKEND_LINKS=true — GET /auth/activate/{token}, GET e POST /auth/password-reset/{token} — renderizam a página de erro do fluxo com status 403. O GET do formulário de reset nem mostra o formulário (e, como só espia o token, não o consome).

  • POST /auth/signup com AUTH_AUTO_ACTIVATE=false cria a conta inativa e não grava token de ativação nem manda e-mail. A resposta é o mesmo 201 de um cadastro pendente comum (activation_required=true, activation_url=null, sem par de JWT): só o user_id muda. Ative essa conta por fora — tempest user activate, ou crie-a já ativa com tempest 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 coluna totp_secret em repouso (Postgres pgcrypto ou 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.
  • disable exige senha + código, e enroll não é um atalho para ele. Uma sessão sequestrada não consegue desligar MFA sozinha — precisa da senha e de um fator ativo. enroll sobre MFA ativo responde 409 em 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; carrega purpose: "mfa_pending" + o sub. Tokens de outro propósito são rejeitados em mfa_verify.
  • Verificação em tempo constante. TOTPHelper.matching_step (e o verify, que o chama) compara o código de cada passo da janela com hmac.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] (traz pyotp), por cima de [auth], e nada é montado sem AUTH_MFA_ENABLED=True.
  • O router exige recovery_code_model: sem ele levanta RuntimeError no wiring, não em produção — perder o telefone sem código de recuperação é perder a conta.
  • Com MFA ativo, POST /auth/login deixa de devolver o par de JWT: devolve mfa_required=True e um token intermediário, e o segundo passo troca código por JWT.
  • Quem monta os próprios endpoints usa os métodos do UserAuthService direto, sem o router. change_password_with_mfa troca 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 (ou mfa_reset no 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/verify responde 429 depois de cinco códigos errados por conta em 15 minutos, e cada código TOTP vale um login. Com mais de um worker, passe mfa_throttle= sobre o Redis.
  • O access token diz como a sessão começou (amr), e make_mfa_required_dependency recusa com 403 MFA_REQUIRED a 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 devolve 403 EMAIL_LINK_FORBIDDEN sem trocar a senha, e o cadastro cria a conta inativa sem mandar link.

Próximos passos