0.2.38: optional two-factor authentication (authenticator-app TOTP)

Per-user opt-in 2FA: enroll from the Two-factor option next to Change
password (QR + confirm code + 8 one-time recovery codes), login becomes
two-step for enrolled users, disable requires the account password.
Escape hatch for lost phones: reset-2fa CLI + Reset Two-Factor StartOS
action. Second-factor guesses share the login rate limiter; the pending
login window expires after 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-11 22:08:04 +02:00
co-authored by Claude Fable 5
parent 053bfeab23
commit 0822eca887
20 changed files with 846 additions and 41 deletions
+129 -3
View File
@@ -2,22 +2,38 @@
import os
import secrets
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from ten31portal import config
from ten31portal import config, totp
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, hash_password, verify_password
from ten31portal.database import get_session
from ten31portal.models import User
from ten31portal.ratelimit import SlidingWindowLimiter
from ten31portal.schemas import ChangePasswordRequest, LoginRequest, UserResponse
from ten31portal.schemas import (
ChangePasswordRequest,
LoginPending2FA,
LoginRequest,
TotpConfirmRequest,
TotpConfirmResponse,
TotpDisableRequest,
TotpSetupResponse,
TotpVerifyRequest,
UserResponse,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# Throttle password guessing per client IP: at most 10 failed attempts per 5 minutes.
# Second-factor code guesses count against the same window.
_login_limiter = SlidingWindowLimiter(max_attempts=10, window_seconds=300)
# How long a password-accepted session may wait for its second factor.
_PENDING_2FA_MAX_AGE = 300 # seconds
# A throwaway hash verified when no user matches, so a missing account costs the same argon2
# time as a real one — otherwise the timing difference reveals which usernames exist.
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
@@ -28,7 +44,7 @@ def login(
body: LoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> UserResponse:
) -> UserResponse | LoginPending2FA:
client_ip = request.client.host if request.client else "unknown"
retry_after = _login_limiter.retry_after(client_ip)
if retry_after > 0:
@@ -62,11 +78,61 @@ def login(
raise HTTPException(status_code=401, detail="This account does not have a login yet.")
if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled")
if user.totp_enabled:
# Password accepted, but don't sign the session in yet — park the login until
# /login/verify-totp confirms the second factor.
request.session.clear()
request.session["pending_2fa_user_id"] = user.id
request.session["pending_2fa_at"] = time.time()
return LoginPending2FA()
_login_limiter.reset(client_ip)
request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True)
@router.post("/login/verify-totp")
def verify_totp_login(
body: TotpVerifyRequest,
request: Request,
session: Session = Depends(get_session),
) -> UserResponse:
"""Second login step: accept an authenticator code or an unused recovery code."""
client_ip = request.client.host if request.client else "unknown"
retry_after = _login_limiter.retry_after(client_ip)
if retry_after > 0:
raise HTTPException(
status_code=429,
detail="Too many attempts. Please wait a moment and try again.",
headers={"Retry-After": str(int(retry_after) + 1)},
)
user_id = request.session.get("pending_2fa_user_id")
started = request.session.get("pending_2fa_at", 0)
if user_id is None or time.time() - started > _PENDING_2FA_MAX_AGE:
request.session.clear()
raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.")
user = session.get(User, user_id)
if user is None or not user.is_active or not user.totp_enabled or not user.totp_secret:
request.session.clear()
raise HTTPException(status_code=401, detail="Sign-in expired. Please log in again.")
if not totp.verify_code(user.totp_secret, body.code):
# Not a current authenticator code — maybe a one-time recovery code.
remaining = totp.consume_recovery_code(user.totp_recovery_codes, body.code)
if remaining is None:
_login_limiter.record_failure(client_ip)
raise HTTPException(status_code=401, detail="Invalid code. Try again.")
user.totp_recovery_codes = remaining
session.add(user)
record_audit(session, user.id, "totp_recovery_used", "user", user.id)
session.commit()
_login_limiter.reset(client_ip)
request.session.clear()
request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True)
@router.post("/logout")
def logout(request: Request) -> dict[str, str]:
request.session.clear()
@@ -99,3 +165,63 @@ def change_password(
except OSError:
pass
return {"status": "ok"}
# --- Two-factor enrollment (per-user opt-in) ---
@router.post("/totp/setup")
def totp_setup(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> TotpSetupResponse:
"""Start enrollment: mint a secret and return the QR. Not active until confirmed."""
if user.totp_enabled:
raise HTTPException(
status_code=400, detail="Two-factor is already enabled. Disable it first to re-enroll."
)
secret = totp.new_secret()
user.totp_secret = secret
session.add(user)
session.commit()
uri = totp.otpauth_uri(secret, user.username)
return TotpSetupResponse(secret=secret, otpauth_uri=uri, qr_svg=totp.qr_svg(uri))
@router.post("/totp/confirm")
def totp_confirm(
body: TotpConfirmRequest,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> TotpConfirmResponse:
"""Finish enrollment: prove the authenticator works, then hand out recovery codes."""
if user.totp_enabled:
raise HTTPException(status_code=400, detail="Two-factor is already enabled.")
if not user.totp_secret:
raise HTTPException(status_code=400, detail="Start two-factor setup first.")
if not totp.verify_code(user.totp_secret, body.code):
raise HTTPException(status_code=400, detail="That code didn't match. Try again.")
codes, digests_json = totp.generate_recovery_codes()
user.totp_enabled = True
user.totp_recovery_codes = digests_json
session.add(user)
record_audit(session, user.id, "totp_enabled", "user", user.id)
session.commit()
return TotpConfirmResponse(recovery_codes=codes)
@router.post("/totp/disable")
def totp_disable(
body: TotpDisableRequest,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> dict[str, str]:
"""Turn off two-factor (requires the account password, not just a live session)."""
if not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=400, detail="Password is incorrect.")
user.totp_secret = None
user.totp_enabled = False
user.totp_recovery_codes = None
session.add(user)
record_audit(session, user.id, "totp_disabled", "user", user.id)
session.commit()
return {"status": "ok"}