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
+123
View File
@@ -0,0 +1,123 @@
"""TOTP two-factor: enrollment, two-step login, recovery codes, disable, CLI-style reset."""
import pyotp
from tests.conftest import make_user
from ten31portal.models import User, UserRole
def _enroll(client):
"""Run the full setup+confirm flow for the signed-in user; return (secret, recovery_codes)."""
setup = client.post("/api/auth/totp/setup")
assert setup.status_code == 200, setup.text
secret = setup.json()["secret"]
assert setup.json()["qr_svg"].lstrip().startswith("<?xml") or "<svg" in setup.json()["qr_svg"]
confirm = client.post(
"/api/auth/totp/confirm", json={"code": pyotp.TOTP(secret).now()}
)
assert confirm.status_code == 200, confirm.text
codes = confirm.json()["recovery_codes"]
assert len(codes) == 8
return secret, codes
def test_enroll_then_login_requires_code(client, session):
make_user(session, username="mp", role=UserRole.approver)
assert client.post("/api/auth/login", json={"login": "mp", "password": "password123"}).status_code == 200
secret, _ = _enroll(client)
client.post("/api/auth/logout")
# Password alone no longer signs in — it parks the session pending the code.
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json() == {"requires_2fa": True}
assert client.get("/api/auth/me").status_code == 401
# Wrong code is rejected; the right code completes sign-in.
bad = client.post("/api/auth/login/verify-totp", json={"code": "000000"})
assert bad.status_code == 401
good = client.post(
"/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()}
)
assert good.status_code == 200, good.text
assert good.json()["username"] == "mp"
assert good.json()["totp_enabled"] is True
assert client.get("/api/auth/me").status_code == 200
def test_recovery_code_works_once(client, session):
make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
_, codes = _enroll(client)
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
first = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
assert first.status_code == 200, first.text
client.post("/api/auth/logout")
# The same recovery code is spent and cannot be used again.
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
again = client.post("/api/auth/login/verify-totp", json={"code": codes[0]})
assert again.status_code == 401
other = client.post("/api/auth/login/verify-totp", json={"code": codes[1]})
assert other.status_code == 200
def test_verify_without_pending_login_fails(client, session):
make_user(session, username="mp", role=UserRole.approver)
resp = client.post("/api/auth/login/verify-totp", json={"code": "123456"})
assert resp.status_code == 401
def test_confirm_requires_valid_first_code(client, session):
make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
setup = client.post("/api/auth/totp/setup")
assert setup.status_code == 200
bad = client.post("/api/auth/totp/confirm", json={"code": "000000"})
assert bad.status_code == 400
# Enrollment never completed, so login stays single-step.
client.post("/api/auth/logout")
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json()["username"] == "mp"
def test_disable_restores_single_step_login(client, session):
make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
secret, _ = _enroll(client)
client.post("/api/auth/logout")
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
client.post("/api/auth/login/verify-totp", json={"code": pyotp.TOTP(secret).now()})
wrong = client.post("/api/auth/totp/disable", json={"password": "not-it"})
assert wrong.status_code == 400
ok = client.post("/api/auth/totp/disable", json={"password": "password123"})
assert ok.status_code == 200
client.post("/api/auth/logout")
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json()["username"] == "mp"
def test_admin_style_reset_clears_enrollment(client, session):
"""Clearing the totp fields (what the reset-2fa CLI does) restores password-only login."""
user = make_user(session, username="mp", role=UserRole.approver)
client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
_enroll(client)
client.post("/api/auth/logout")
db_user = session.get(User, user.id)
session.refresh(db_user)
assert db_user.totp_enabled is True
db_user.totp_secret = None
db_user.totp_enabled = False
db_user.totp_recovery_codes = None
session.add(db_user)
session.commit()
resp = client.post("/api/auth/login", json={"login": "mp", "password": "password123"})
assert resp.status_code == 200
assert resp.json()["username"] == "mp"