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:
co-authored by
Claude Fable 5
parent
053bfeab23
commit
0822eca887
@@ -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"
|
||||
Reference in New Issue
Block a user