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
+1 -1
View File
@@ -199,7 +199,7 @@ Phase BTOTP,可选配)
- **Reviewer checklist**: 现有用户迁移后默认未启用 TOTP(不破坏现有登录)。
### M4-T05 — TOTP service + setup/enable/disable/status API
- **Status**: `todo` · **Depends**: M4-T04
- **Status**: `done` · **Depends**: M4-T04
- **Files**: `create app/services/totp.py``app/schemas/totp.py``modify app/api/routes/api/session.py`(或新 `auth_totp.py` 路由)、`app/config.py`(+`auth_totp_issuer`)`modify requirements.in/.txt`(+`pyotp`)`create tests/test_api_totp.py`
- **Steps**: pyotp 生成 secret + `otpauth://` URIissuer=`auth_totp_issuer`);恢复码生成(N=10、`xxxx-xxxx`Argon2 哈希存、明文仅 setup 返回一次);`setup`pending,不启用)/`enable`(带当前码确认)/`disable`(带密码或当前码)/`GET status`;全部 session+CSRF。
- **Out of scope / 不要碰**: 不改 login 校验(T06)。
+262
View File
@@ -795,6 +795,184 @@
}
}
},
"/api/auth/totp/setup": {
"post": {
"tags": [
"api-session"
],
"summary": "Post Totp Setup",
"description": "Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.\n\nThe secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays\nFalse until the user confirms with POST /api/auth/totp/enable).\n\nRecovery codes are returned here as plaintext exactly once; their Argon2 hashes\nare persisted immediately so enable only needs to flip the enabled flag.\n\nRepeating this call replaces any prior pending secret and regenerates codes.\n\nRequires: session cookie + X-CSRF-Token.",
"operationId": "post_totp_setup_api_auth_totp_setup_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TotpSetupResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/totp/enable": {
"post": {
"tags": [
"api-session"
],
"summary": "Post Totp Enable",
"description": "Enable TOTP by confirming with the current 6-digit code from the authenticator app.\n\nRequires a prior call to POST /api/auth/totp/setup (so that a pending secret\nexists). On success, totp_enabled becomes True.\n\nReturns 400 if the code is wrong or there is no pending secret.\nRequires: session cookie + X-CSRF-Token.",
"operationId": "post_totp_enable_api_auth_totp_enable_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TotpEnableRequest"
}
}
}
},
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/totp/disable": {
"post": {
"tags": [
"api-session"
],
"summary": "Post Totp Disable",
"description": "Disable TOTP. The caller must provide exactly one of:\n- ``password``: the user's current login password, OR\n- ``code``: the current 6-digit TOTP code.\n\nOn success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.\nReturns 400 if neither credential matches or neither is provided.\nRequires: session cookie + X-CSRF-Token.",
"operationId": "post_totp_disable_api_auth_totp_disable_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TotpDisableRequest"
}
}
}
},
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/totp": {
"get": {
"tags": [
"api-session"
],
"summary": "Get Totp Status",
"description": "Return the current TOTP status for the authenticated user.\n\nResponse contains only ``{\"enabled\": bool}``.\nSecret and recovery codes are NEVER returned here.\nRequires: session cookie only (no CSRF — read-only).",
"operationId": "get_totp_status_api_auth_totp_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TotpStatusResponse"
}
}
}
}
}
}
},
"/homeassistant/publish": {
"post": {
"tags": [
@@ -1549,6 +1727,90 @@
],
"title": "StatusResponse"
},
"TotpDisableRequest": {
"properties": {
"password": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Password"
},
"code": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Code"
}
},
"type": "object",
"title": "TotpDisableRequest",
"description": "Disable TOTP by proving identity.\n\nExactly one of ``password`` or ``code`` must be provided."
},
"TotpEnableRequest": {
"properties": {
"code": {
"type": "string",
"title": "Code"
}
},
"type": "object",
"required": [
"code"
],
"title": "TotpEnableRequest",
"description": "The user confirms setup by providing the 6-digit TOTP code."
},
"TotpSetupResponse": {
"properties": {
"secret": {
"type": "string",
"title": "Secret"
},
"otpauth_uri": {
"type": "string",
"title": "Otpauth Uri"
},
"recovery_codes": {
"items": {
"type": "string"
},
"type": "array",
"title": "Recovery Codes"
}
},
"type": "object",
"required": [
"secret",
"otpauth_uri",
"recovery_codes"
],
"title": "TotpSetupResponse",
"description": "Returned once after a setup call.\n\n``secret`` and ``recovery_codes`` are **one-time plaintext values**.\nThey are never returned again by any subsequent API call.\nThe frontend must display and instruct the user to save them before confirming."
},
"TotpStatusResponse": {
"properties": {
"enabled": {
"type": "boolean",
"title": "Enabled"
}
},
"type": "object",
"required": [
"enabled"
],
"title": "TotpStatusResponse",
"description": "Minimal status response — never exposes secret or recovery codes."
},
"ValidationError": {
"properties": {
"loc": {
+216
View File
@@ -577,6 +577,157 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/totp/setup:
post:
tags:
- api-session
summary: Post Totp Setup
description: '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.'
operationId: post_totp_setup_api_auth_totp_setup_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/TotpSetupResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/totp/enable:
post:
tags:
- api-session
summary: Post Totp Enable
description: '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.'
operationId: post_totp_enable_api_auth_totp_enable_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TotpEnableRequest'
responses:
'204':
description: Successful Response
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/totp/disable:
post:
tags:
- api-session
summary: Post Totp Disable
description: '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.'
operationId: post_totp_disable_api_auth_totp_disable_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TotpDisableRequest'
responses:
'204':
description: Successful Response
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/totp:
get:
tags:
- api-session
summary: Get Totp Status
description: '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).'
operationId: get_totp_status_api_auth_totp_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/TotpStatusResponse'
/homeassistant/publish:
post:
tags:
@@ -1079,6 +1230,71 @@ components:
required:
- status
title: StatusResponse
TotpDisableRequest:
properties:
password:
anyOf:
- type: string
- type: 'null'
title: Password
code:
anyOf:
- type: string
- type: 'null'
title: Code
type: object
title: TotpDisableRequest
description: 'Disable TOTP by proving identity.
Exactly one of ``password`` or ``code`` must be provided.'
TotpEnableRequest:
properties:
code:
type: string
title: Code
type: object
required:
- code
title: TotpEnableRequest
description: The user confirms setup by providing the 6-digit TOTP code.
TotpSetupResponse:
properties:
secret:
type: string
title: Secret
otpauth_uri:
type: string
title: Otpauth Uri
recovery_codes:
items:
type: string
type: array
title: Recovery Codes
type: object
required:
- secret
- otpauth_uri
- recovery_codes
title: TotpSetupResponse
description: '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.'
TotpStatusResponse:
properties:
enabled:
type: boolean
title: Enabled
type: object
required:
- enabled
title: TotpStatusResponse
description: Minimal status response — never exposes secret or recovery codes.
ValidationError:
properties:
loc:
+1
View File
@@ -4,6 +4,7 @@ argon2-cffi>=25.1,<26.0
fastapi>=0.115,<0.116
httpx>=0.28,<1.0
pydantic-settings>=2.6,<3.0
pyotp>=2.9,<3.0
python-multipart>=0.0.12,<1.0
pyyaml>=6.0,<7.0
sqlalchemy>=2.0,<3.0
+2
View File
@@ -59,6 +59,8 @@ pydantic-core==2.46.2
# via pydantic
pydantic-settings==2.13.1
# via -r requirements.in
pyotp==2.10.0
# via -r requirements.in
python-dotenv==1.2.2
# via
# pydantic-settings
+419
View File
@@ -0,0 +1,419 @@
"""Tests for M4-T05: TOTP service + setup/enable/disable/status API."""
from __future__ import annotations
import pyotp
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.db import reset_db_caches
from app.models.auth import AuthUser, RecoveryCode
from app.services.auth import verify_password
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _login(client: TestClient) -> str:
"""Log in and return the CSRF token from the response."""
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, resp.text
return resp.json()["csrf_token"]
def _setup(client: TestClient, csrf: str) -> dict:
resp = client.post(
"/api/auth/totp/setup",
headers={"X-CSRF-Token": csrf},
)
return resp
def _enable(client: TestClient, csrf: str, code: str): # noqa: ANN201
return client.post(
"/api/auth/totp/enable",
json={"code": code},
headers={"X-CSRF-Token": csrf},
)
def _disable(client: TestClient, csrf: str, *, password: str | None = None, code: str | None = None):
body: dict = {}
if password is not None:
body["password"] = password
if code is not None:
body["code"] = code
return client.post(
"/api/auth/totp/disable",
json=body,
headers={"X-CSRF-Token": csrf},
)
def _status(client: TestClient) -> dict:
resp = client.get("/api/auth/totp")
assert resp.status_code == 200, resp.text
return resp.json()
# ---------------------------------------------------------------------------
# Fixtures — reuse conftest app/client via the session-aware totp_client fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def totp_client(auth_database):
"""TestClient + a direct SQLAlchemy engine so tests can inspect DB state."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as tc:
yield tc, engine
engine.dispose()
reset_db_caches()
# ---------------------------------------------------------------------------
# POST /api/auth/totp/setup
# ---------------------------------------------------------------------------
def test_setup_returns_secret_uri_and_recovery_codes(totp_client):
client, engine = totp_client
csrf = _login(client)
resp = _setup(client, csrf)
assert resp.status_code == 200, resp.text
body = resp.json()
assert "secret" in body
assert "otpauth_uri" in body
assert "recovery_codes" in body
assert len(body["recovery_codes"]) == 10
# Each code must match xxxx-xxxx pattern
for rc in body["recovery_codes"]:
parts = rc.split("-")
assert len(parts) == 2
assert all(len(p) == 4 for p in parts)
# URI must start with otpauth://totp/
assert body["otpauth_uri"].startswith("otpauth://totp/")
# Secret must be a valid base32 string (pyotp will accept it)
assert len(body["secret"]) >= 16
def test_setup_does_not_enable_totp(totp_client):
"""After setup, totp_enabled must still be False."""
client, engine = totp_client
csrf = _login(client)
_setup(client, csrf)
status_body = _status(client)
assert status_body["enabled"] is False
def test_setup_persists_secret_and_code_hashes(totp_client):
"""After setup, DB has totp_secret set and recovery code hashes stored."""
client, engine = totp_client
csrf = _login(client)
resp = _setup(client, csrf)
body = resp.json()
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
# Secret is stored
assert user.totp_secret == body["secret"]
# Enabled is still False
assert user.totp_enabled is False
# Recovery codes are stored as hashes
codes = db.execute(select(RecoveryCode).where(RecoveryCode.user_id == user.id)).scalars().all()
assert len(codes) == 10
# Hashes are not the same as plaintext
for i, rc in enumerate(codes):
assert rc.code_hash != body["recovery_codes"][i]
# But they must verify correctly with Argon2
assert verify_password(body["recovery_codes"][i], rc.code_hash)
def test_setup_twice_replaces_pending_state(totp_client):
"""Re-running setup clears old pending secret + codes and issues new ones."""
client, engine = totp_client
csrf = _login(client)
first = _setup(client, csrf).json()
second = _setup(client, csrf).json()
# Different secret each time
assert first["secret"] != second["secret"]
# Different codes
assert first["recovery_codes"] != second["recovery_codes"]
# DB has exactly 10 codes (not 20)
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
codes = db.execute(
select(RecoveryCode).where(RecoveryCode.user_id == user.id)
).scalars().all()
assert len(codes) == 10
# ---------------------------------------------------------------------------
# POST /api/auth/totp/enable
# ---------------------------------------------------------------------------
def test_enable_with_wrong_code_fails(totp_client):
client, engine = totp_client
csrf = _login(client)
_setup(client, csrf)
resp = _enable(client, csrf, "000000")
assert resp.status_code == 400
def test_enable_with_wrong_code_does_not_enable(totp_client):
client, engine = totp_client
csrf = _login(client)
_setup(client, csrf)
_enable(client, csrf, "000000")
assert _status(client)["enabled"] is False
def test_enable_with_correct_code_succeeds(totp_client):
"""Using pyotp.TOTP(secret).now() to generate the correct code."""
client, engine = totp_client
csrf = _login(client)
setup_body = _setup(client, csrf).json()
secret = setup_body["secret"]
correct_code = pyotp.TOTP(secret).now()
resp = _enable(client, csrf, correct_code)
assert resp.status_code == 204, resp.text
def test_enable_with_correct_code_sets_totp_enabled(totp_client):
client, engine = totp_client
csrf = _login(client)
setup_body = _setup(client, csrf).json()
secret = setup_body["secret"]
_enable(client, csrf, pyotp.TOTP(secret).now())
assert _status(client)["enabled"] is True
def test_enable_persists_recovery_code_hashes(totp_client):
"""Recovery codes are already hashed in DB after enable (not plaintext)."""
client, engine = totp_client
csrf = _login(client)
setup_body = _setup(client, csrf).json()
secret = setup_body["secret"]
_enable(client, csrf, pyotp.TOTP(secret).now())
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
codes = db.execute(select(RecoveryCode).where(RecoveryCode.user_id == user.id)).scalars().all()
assert len(codes) == 10
for i, rc in enumerate(codes):
plaintext = setup_body["recovery_codes"][i]
# code_hash must NOT be the plaintext
assert rc.code_hash != plaintext
# But verify_password must return True (Argon2 hash matches)
assert verify_password(plaintext, rc.code_hash)
# ---------------------------------------------------------------------------
# POST /api/auth/totp/disable
# ---------------------------------------------------------------------------
def _do_full_enable(client: TestClient, csrf: str) -> str:
"""Perform setup + enable and return the secret."""
setup_body = _setup(client, csrf).json()
secret = setup_body["secret"]
_enable(client, csrf, pyotp.TOTP(secret).now())
return secret
def test_disable_with_password_succeeds(totp_client):
client, engine = totp_client
csrf = _login(client)
_do_full_enable(client, csrf)
assert _status(client)["enabled"] is True
resp = _disable(client, csrf, password="test-password")
assert resp.status_code == 204, resp.text
def test_disable_clears_enabled_flag(totp_client):
client, engine = totp_client
csrf = _login(client)
_do_full_enable(client, csrf)
_disable(client, csrf, password="test-password")
assert _status(client)["enabled"] is False
def test_disable_clears_secret_and_recovery_codes(totp_client):
client, engine = totp_client
csrf = _login(client)
_do_full_enable(client, csrf)
_disable(client, csrf, password="test-password")
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user is not None
assert user.totp_secret is None
assert user.totp_enabled is False
codes = db.execute(select(RecoveryCode).where(RecoveryCode.user_id == user.id)).scalars().all()
assert len(codes) == 0
def test_disable_with_totp_code_succeeds(totp_client):
client, engine = totp_client
csrf = _login(client)
secret = _do_full_enable(client, csrf)
current_code = pyotp.TOTP(secret).now()
resp = _disable(client, csrf, code=current_code)
assert resp.status_code == 204, resp.text
assert _status(client)["enabled"] is False
def test_disable_with_wrong_password_fails(totp_client):
client, engine = totp_client
csrf = _login(client)
_do_full_enable(client, csrf)
resp = _disable(client, csrf, password="wrong-password")
assert resp.status_code == 400
assert _status(client)["enabled"] is True
def test_disable_with_no_credential_fails(totp_client):
client, engine = totp_client
csrf = _login(client)
_do_full_enable(client, csrf)
resp = _disable(client, csrf) # neither password nor code
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/auth/totp — status endpoint
# ---------------------------------------------------------------------------
def test_status_does_not_return_secret(totp_client):
client, engine = totp_client
csrf = _login(client)
setup_body = _setup(client, csrf).json()
secret = setup_body["secret"]
_enable(client, csrf, pyotp.TOTP(secret).now())
status_resp = client.get("/api/auth/totp")
assert status_resp.status_code == 200
body = status_resp.json()
# Must contain ONLY enabled
assert set(body.keys()) == {"enabled"}
assert "secret" not in str(body)
assert "recovery_codes" not in str(body)
assert "otpauth" not in str(body)
def test_status_initially_disabled(totp_client):
client, _ = totp_client
_login(client)
body = _status(client)
assert body["enabled"] is False
def test_status_after_enable(totp_client):
client, _ = totp_client
csrf = _login(client)
setup_body = _setup(client, csrf).json()
_enable(client, csrf, pyotp.TOTP(setup_body["secret"]).now())
body = _status(client)
assert body["enabled"] is True
# ---------------------------------------------------------------------------
# Security: unauthenticated / missing CSRF
# ---------------------------------------------------------------------------
def test_setup_unauthenticated_returns_401(client: TestClient):
resp = client.post("/api/auth/totp/setup", headers={"X-CSRF-Token": "x"})
assert resp.status_code == 401
def test_setup_missing_csrf_returns_403(totp_client):
client, _ = totp_client
_login(client)
# No X-CSRF-Token header
resp = client.post("/api/auth/totp/setup")
assert resp.status_code == 403
def test_enable_unauthenticated_returns_401(client: TestClient):
resp = client.post(
"/api/auth/totp/enable",
json={"code": "123456"},
headers={"X-CSRF-Token": "x"},
)
assert resp.status_code == 401
def test_enable_missing_csrf_returns_403(totp_client):
client, _ = totp_client
_login(client)
resp = client.post("/api/auth/totp/enable", json={"code": "123456"})
assert resp.status_code == 403
def test_disable_unauthenticated_returns_401(client: TestClient):
resp = client.post(
"/api/auth/totp/disable",
json={"password": "test-password"},
headers={"X-CSRF-Token": "x"},
)
assert resp.status_code == 401
def test_disable_missing_csrf_returns_403(totp_client):
client, _ = totp_client
_login(client)
resp = client.post("/api/auth/totp/disable", json={"password": "test-password"})
assert resp.status_code == 403
def test_status_unauthenticated_returns_401(client: TestClient):
resp = client.get("/api/auth/totp")
assert resp.status_code == 401