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
+28
View File
@@ -87,6 +87,28 @@ def reset_password(args: argparse.Namespace) -> None:
print(f"Password reset for {user.name} ({user.username}).")
def reset_2fa(args: argparse.Namespace) -> None:
"""Clear a user's two-factor enrollment so they can sign in with password alone."""
run_migrations()
with Session(engine) as session:
user = _find_user(session, args.username, args.email)
if user is None:
who = args.username or args.email
print(f"Error: no user found for '{who}'.", file=sys.stderr)
sys.exit(1)
if not user.totp_enabled and not user.totp_secret:
print(f"{user.name} ({user.username}) does not have two-factor enabled.")
return
user.totp_secret = None
user.totp_enabled = False
user.totp_recovery_codes = None
session.add(user)
session.commit()
print(f"Two-factor disabled for {user.name} ({user.username}). "
"They can sign in with just their password and re-enroll from the app.")
def enable_investor_logins(args: argparse.Namespace) -> None:
"""Give every no-login investor account the shared default password and enable sign-in.
@@ -325,6 +347,10 @@ def main() -> None:
reset.add_argument("--email", required=False, default=None)
reset.add_argument("--password", required=True)
r2fa = sub.add_parser("reset-2fa", help="Clear a user's two-factor enrollment (lost phone)")
r2fa.add_argument("--username", required=False, default=None)
r2fa.add_argument("--email", required=False, default=None)
sub.add_parser("list-users", help="List all user accounts")
sub.add_parser(
@@ -357,6 +383,8 @@ def main() -> None:
create_user(args)
elif args.command == "reset-password":
reset_password(args)
elif args.command == "reset-2fa":
reset_2fa(args)
elif args.command == "list-users":
list_users(args)
elif args.command == "enable-investor-logins":
+6
View File
@@ -79,6 +79,12 @@ class User(SQLModel, table=True):
# When this investor last loaded their documents list — docs newer than this get a "New"
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
docs_seen_at: datetime | None = Field(default=None)
# Two-factor auth (optional, per-user opt-in). The secret is set at setup time but only
# counts once totp_enabled is True (enrollment is confirmed with a first valid code).
totp_secret: str | None = Field(default=None)
totp_enabled: bool = Field(default=False)
# JSON list of sha256 hex digests of unused one-time recovery codes.
totp_recovery_codes: str | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
+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"}
+28
View File
@@ -26,9 +26,37 @@ class UserResponse(BaseModel):
is_active: bool
is_service_admin: bool = False
primary_account_id: int | None = None # set when this account logs in under another
totp_enabled: bool = False
created_at: datetime
class LoginPending2FA(BaseModel):
"""Password accepted, waiting on the second factor before the session is signed in."""
requires_2fa: bool = True
class TotpVerifyRequest(BaseModel):
code: str # 6-digit authenticator code, or a one-time recovery code
class TotpSetupResponse(BaseModel):
secret: str
otpauth_uri: str
qr_svg: str
class TotpConfirmRequest(BaseModel):
code: str
class TotpConfirmResponse(BaseModel):
recovery_codes: list[str] # shown exactly once
class TotpDisableRequest(BaseModel):
password: str
# --- User administration ---
class UserCreate(BaseModel):
+71
View File
@@ -0,0 +1,71 @@
"""TOTP two-factor helpers: secrets, QR enrollment, code checks, recovery codes.
Recovery codes are random (80 bits each), so a fast sha256 digest is enough at rest —
unlike passwords they can't be dictionary-attacked. Each code is one-time: a successful
match removes its hash from the stored list.
"""
import hashlib
import io
import json
import secrets
import pyotp
import qrcode
import qrcode.image.svg
ISSUER = "Ten31 Portal"
RECOVERY_CODE_COUNT = 8
def new_secret() -> str:
return pyotp.random_base32()
def otpauth_uri(secret: str, account_name: str) -> str:
return pyotp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=ISSUER)
def qr_svg(uri: str) -> str:
"""The enrollment QR as a standalone SVG document (no raster deps needed)."""
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage, box_size=14)
buf = io.BytesIO()
img.save(buf)
return buf.getvalue().decode()
def verify_code(secret: str, code: str) -> bool:
# valid_window=1 accepts the neighbouring 30s steps, tolerating clock drift.
return pyotp.TOTP(secret).verify(code.strip().replace(" ", ""), valid_window=1)
def _normalize_recovery(code: str) -> str:
return code.strip().replace("-", "").replace(" ", "").lower()
def _digest(code: str) -> str:
return hashlib.sha256(_normalize_recovery(code).encode()).hexdigest()
def generate_recovery_codes() -> tuple[list[str], str]:
"""Return (plaintext codes to show once, JSON of their digests to store)."""
codes = []
for _ in range(RECOVERY_CODE_COUNT):
raw = secrets.token_hex(10) # 20 hex chars, 80 bits
codes.append(f"{raw[:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:]}")
return codes, json.dumps([_digest(c) for c in codes])
def consume_recovery_code(stored_json: str | None, code: str) -> str | None:
"""If ``code`` matches an unused recovery code, return the updated JSON without it.
Returns None when the code doesn't match (or none are stored).
"""
if not stored_json:
return None
digests: list[str] = json.loads(stored_json)
d = _digest(code)
if d not in digests:
return None
digests.remove(d)
return json.dumps(digests)