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)