M4-T05: add TOTP service and setup/enable/disable/status API

This commit is contained in:
2026-06-21 21:55:17 +02:00
parent 5026967cee
commit ee3031013e
10 changed files with 1301 additions and 1 deletions
+113
View File
@@ -14,6 +14,12 @@ from app.schemas.session import (
SessionResponse,
SessionUser,
)
from app.schemas.totp import (
TotpDisableRequest,
TotpEnableRequest,
TotpSetupResponse,
TotpStatusResponse,
)
from app.services.auth import (
AuthPasswordChangeError,
AuthenticatedSession,
@@ -23,6 +29,7 @@ from app.services.auth import (
revoke_session,
)
from app.services import login_throttle
from app.services import totp as totp_service
logger = logging.getLogger(__name__)
@@ -179,3 +186,109 @@ def post_change_password(
logger.info("Password updated for user '%s'", auth.user.username)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------------------
# TOTP endpoints (M4-T05)
# ---------------------------------------------------------------------------
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
def post_totp_setup(
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> TotpSetupResponse:
"""
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
False until the user confirms with POST /api/auth/totp/enable).
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
are persisted immediately so enable only needs to flip the enabled flag.
Repeating this call replaces any prior pending secret and regenerates codes.
Requires: session cookie + X-CSRF-Token.
"""
secret, otpauth_uri, recovery_codes = totp_service.setup(
db,
user=auth.user,
issuer=settings.effective_totp_issuer,
)
return TotpSetupResponse(
secret=secret,
otpauth_uri=otpauth_uri,
recovery_codes=recovery_codes,
)
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
def post_totp_enable(
body: TotpEnableRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
exists). On success, totp_enabled becomes True.
Returns 400 if the code is wrong or there is no pending secret.
Requires: session cookie + X-CSRF-Token.
"""
ok = totp_service.enable(db, user=auth.user, code=body.code)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid TOTP code or no pending setup",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
def post_totp_disable(
body: TotpDisableRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Disable TOTP. The caller must provide exactly one of:
- ``password``: the user's current login password, OR
- ``code``: the current 6-digit TOTP code.
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
Returns 400 if neither credential matches or neither is provided.
Requires: session cookie + X-CSRF-Token.
"""
ok = totp_service.disable(
db,
user=auth.user,
password=body.password,
code=body.code,
)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="invalid credential; provide a valid password or TOTP code",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/auth/totp", response_model=TotpStatusResponse)
def get_totp_status(
auth: AuthenticatedSession = Depends(require_session),
) -> TotpStatusResponse:
"""
Return the current TOTP status for the authenticated user.
Response contains only ``{"enabled": bool}``.
Secret and recovery codes are NEVER returned here.
Requires: session cookie only (no CSRF — read-only).
"""
return TotpStatusResponse(enabled=auth.user.totp_enabled)
+7
View File
@@ -39,6 +39,7 @@ class Settings(BaseSettings):
auth_cookie_secure_override: bool | None = True
auth_login_throttle_enabled: bool = True
auth_trust_forwarded_for: bool = False
auth_totp_issuer: str = "" # defaults to app_name when empty
model_config = SettingsConfigDict(
env_file=".env",
@@ -88,6 +89,12 @@ class Settings(BaseSettings):
return self.auth_cookie_secure_override
return not self.is_development
@computed_field
@property
def effective_totp_issuer(self) -> str:
"""The issuer label shown in Authenticator apps. Falls back to app_name."""
return self.auth_totp_issuer.strip() or self.app_name
@lru_cache
def get_settings() -> Settings:
+59
View File
@@ -0,0 +1,59 @@
"""Pydantic schemas for TOTP setup / enable / disable / status endpoints (M4-T05)."""
from __future__ import annotations
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# Setup (POST /api/auth/totp/setup) — response
# ---------------------------------------------------------------------------
class TotpSetupResponse(BaseModel):
"""Returned once after a setup call.
``secret`` and ``recovery_codes`` are **one-time plaintext values**.
They are never returned again by any subsequent API call.
The frontend must display and instruct the user to save them before confirming.
"""
secret: str
otpauth_uri: str
recovery_codes: list[str]
# ---------------------------------------------------------------------------
# Enable (POST /api/auth/totp/enable) — request
# ---------------------------------------------------------------------------
class TotpEnableRequest(BaseModel):
"""The user confirms setup by providing the 6-digit TOTP code."""
code: str
# ---------------------------------------------------------------------------
# Disable (POST /api/auth/totp/disable) — request
# ---------------------------------------------------------------------------
class TotpDisableRequest(BaseModel):
"""Disable TOTP by proving identity.
Exactly one of ``password`` or ``code`` must be provided.
"""
password: str | None = None
code: str | None = None
# ---------------------------------------------------------------------------
# Status (GET /api/auth/totp) — response
# ---------------------------------------------------------------------------
class TotpStatusResponse(BaseModel):
"""Minimal status response — never exposes secret or recovery codes."""
enabled: bool
+221
View File
@@ -0,0 +1,221 @@
"""TOTP service: secret management, recovery-code lifecycle, setup/enable/disable (M4-T05).
State-machine summary
---------------------
DISABLED (default)
totp_secret=None, totp_enabled=False, no recovery codes
PENDING (after setup, before enable)
totp_secret=<base32>, totp_enabled=False, recovery codes stored as hashes
- Re-calling setup replaces the pending secret and regenerates recovery codes.
ENABLED (after enable)
totp_secret=<base32>, totp_enabled=True, recovery codes stored as hashes
After disable:
totp_secret=None, totp_enabled=False, recovery codes deleted → back to DISABLED
Recovery-code timing
--------------------
1. setup → generate 10 plaintext codes, persist their Argon2 hashes immediately,
return plaintext to caller (ONLY time).
2. enable → verify 6-digit TOTP code; if ok, flip totp_enabled=True.
Recovery codes are already in the DB from step 1.
3. disable → clear secret, clear enabled flag, delete all recovery codes.
Security note: ``totp_secret`` is stored as plaintext (same posture as other
secrets in this project: protected by file-system permissions on the SQLite
database). Recovery codes are stored as Argon2 hashes only.
"""
from __future__ import annotations
import logging
import secrets
import string
import pyotp
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.auth import AuthUser, RecoveryCode
from app.services.auth import hash_password, verify_password
logger = logging.getLogger(__name__)
# Number of recovery codes to generate per setup
_RECOVERY_CODE_COUNT = 10
# Alphabet for each 4-character segment: lowercase letters + digits, no ambiguous chars
_CODE_ALPHABET = string.ascii_lowercase + string.digits
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _generate_recovery_code() -> str:
"""Return a single recovery code in ``xxxx-xxxx`` format."""
segment = lambda: "".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) # noqa: E731
return f"{segment()}-{segment()}"
def _verify_totp_code(secret: str, code: str) -> bool:
"""Verify a 6-digit TOTP code against the given base-32 secret (±1 window)."""
return pyotp.TOTP(secret).verify(code, valid_window=1)
def _delete_recovery_codes(db: Session, *, user_id: int) -> None:
"""Delete ALL recovery codes for a user (called on setup re-run and disable)."""
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user_id))
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def setup(
db: Session,
*,
user: AuthUser,
issuer: str,
) -> tuple[str, str, list[str]]:
"""Generate a new pending TOTP secret and recovery codes.
The secret is stored in ``user.totp_secret`` (``totp_enabled`` stays
``False``). Any pre-existing pending secret and recovery codes are
replaced atomically.
Returns
-------
(secret, otpauth_uri, plaintext_recovery_codes)
``plaintext_recovery_codes`` are returned **once** here and MUST NOT be
stored or returned elsewhere.
"""
# Generate new TOTP secret
new_secret = pyotp.random_base32()
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
name=user.username,
issuer_name=issuer,
)
# Generate plaintext recovery codes
plaintext_codes = [_generate_recovery_code() for _ in range(_RECOVERY_CODE_COUNT)]
# --- Persist atomically ---
# 1. Delete any old pending recovery codes (idempotent on re-setup)
assert user.id is not None # mypy guard; always set after DB insertion
_delete_recovery_codes(db, user_id=user.id)
# 2. Update the secret (pending — totp_enabled stays False)
user.totp_secret = new_secret
# 3. Persist new recovery codes as Argon2 hashes
for plaintext in plaintext_codes:
db.add(RecoveryCode(user_id=user.id, code_hash=hash_password(plaintext)))
db.commit()
db.refresh(user)
logger.info("TOTP setup (pending) for user '%s'; %d recovery codes generated.", user.username, _RECOVERY_CODE_COUNT)
return new_secret, otpauth_uri, plaintext_codes
def enable(db: Session, *, user: AuthUser, code: str) -> bool:
"""Enable TOTP after the user proves they can generate the correct code.
Parameters
----------
code:
The current 6-digit TOTP code from the user's authenticator app.
Returns
-------
True on success; False if the code is invalid or no secret is pending.
"""
if not user.totp_secret:
logger.info("TOTP enable rejected for '%s': no pending secret.", user.username)
return False
if not _verify_totp_code(user.totp_secret, code):
logger.info("TOTP enable rejected for '%s': wrong code.", user.username)
return False
user.totp_enabled = True
db.commit()
db.refresh(user)
logger.info("TOTP enabled for user '%s'.", user.username)
return True
def disable(
db: Session,
*,
user: AuthUser,
password: str | None = None,
code: str | None = None,
) -> bool:
"""Disable TOTP. Caller must supply exactly one of ``password`` or ``code``.
On success: ``totp_enabled=False``, ``totp_secret=None``, all recovery
codes deleted.
Returns
-------
True on success; False if neither credential is valid.
"""
if not password and not code:
logger.info("TOTP disable rejected for '%s': no credential provided.", user.username)
return False
if password:
if not verify_password(password, user.password_hash):
logger.info("TOTP disable rejected for '%s': wrong password.", user.username)
return False
elif code:
if not user.totp_secret or not _verify_totp_code(user.totp_secret, code):
logger.info("TOTP disable rejected for '%s': wrong TOTP code.", user.username)
return False
# Clear TOTP state
assert user.id is not None
_delete_recovery_codes(db, user_id=user.id)
user.totp_enabled = False
user.totp_secret = None
db.commit()
db.refresh(user)
logger.info("TOTP disabled for user '%s'.", user.username)
return True
def verify_recovery_code(db: Session, *, user: AuthUser, code: str) -> bool:
"""Verify and consume a one-time recovery code.
Finds the first unused recovery code whose hash matches ``code``, marks it
as consumed (sets ``used_at``), and returns ``True``. Returns ``False`` if
no matching unused code exists.
This function is provided for T06 (login two-factor) but lives here so the
TOTP service owns all recovery-code logic.
"""
from datetime import UTC, datetime
unused = db.execute(
select(RecoveryCode).where(
RecoveryCode.user_id == user.id,
RecoveryCode.used_at.is_(None),
)
).scalars().all()
for rc in unused:
if verify_password(code, rc.code_hash):
rc.used_at = datetime.now(UTC)
db.commit()
logger.info(
"Recovery code consumed for user '%s' (id=%d).", user.username, rc.id
)
return True
return False