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. Settings (AuthSettings) — flag a flag.
  7. Usando só o UserAuthService (sem o router) — pra quem monta os próprios endpoints.
  8. Segurança.
  9. 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.35.0"

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 MFAVerifySchemaLoginResponseSchema 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 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. 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; carrega purpose: "mfa_pending" + o sub. Tokens de outro propósito são rejeitados em mfa_verify.
  • Verificação em tempo constante. TOTPHelper.verify delega ao pyotp, que compara o código com hmac.compare_digest.

Próximos passos