M4-T06: add TOTP second factor to login (totp_code + recovery codes)

This commit is contained in:
2026-06-21 22:08:55 +02:00
parent ee3031013e
commit 81bd32f613
8 changed files with 613 additions and 2 deletions
+110
View File
@@ -417,3 +417,113 @@ def test_disable_missing_csrf_returns_403(totp_client):
def test_status_unauthenticated_returns_401(client: TestClient):
resp = client.get("/api/auth/totp")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# M4-T06 — verify_recovery_code service-level tests
# ---------------------------------------------------------------------------
def _full_setup_and_enable(client: TestClient) -> tuple[str, list[str]]:
"""Log in, run setup, enable TOTP, return (secret, plaintext_recovery_codes)."""
csrf = _login(client)
setup_body = _setup(client, csrf).json()
secret = setup_body["secret"]
recovery_codes = setup_body["recovery_codes"]
_enable(client, csrf, pyotp.TOTP(secret).now())
return secret, recovery_codes
class TestVerifyRecoveryCodeService:
"""Unit-level checks for totp_service.verify_recovery_code (used in T06 login)."""
def test_verify_recovery_code_returns_true_on_valid_code(self, totp_client):
"""verify_recovery_code returns True for an unused code."""
from app.models.auth import AuthUser
from sqlalchemy import select
from app.services import totp as totp_svc
client, engine = totp_client
_, recovery_codes = _full_setup_and_enable(client)
code = recovery_codes[0]
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
result = totp_svc.verify_recovery_code(db, user=user, code=code)
assert result is True
def test_verify_recovery_code_returns_false_on_used_code(self, totp_client):
"""verify_recovery_code returns False when the code was already consumed."""
from app.models.auth import AuthUser
from sqlalchemy import select
from app.services import totp as totp_svc
client, engine = totp_client
_, recovery_codes = _full_setup_and_enable(client)
code = recovery_codes[0]
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
# First call consumes
assert totp_svc.verify_recovery_code(db, user=user, code=code) is True
# Reload user after commit
db.expire_all()
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
# Second call must fail
assert totp_svc.verify_recovery_code(db, user=user, code=code) is False
def test_verify_recovery_code_returns_false_on_invalid_code(self, totp_client):
"""verify_recovery_code returns False for a completely wrong code."""
from app.models.auth import AuthUser
from sqlalchemy import select
from app.services import totp as totp_svc
client, engine = totp_client
_full_setup_and_enable(client)
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
result = totp_svc.verify_recovery_code(db, user=user, code="xxxx-xxxx")
assert result is False
def test_verify_totp_code_correct(self, totp_client):
"""verify_totp_code returns True for the current TOTP code."""
from app.models.auth import AuthUser
from sqlalchemy import select
from app.services import totp as totp_svc
client, engine = totp_client
secret, _ = _full_setup_and_enable(client)
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
result = totp_svc.verify_totp_code(user, pyotp.TOTP(secret).now())
assert result is True
def test_verify_totp_code_wrong(self, totp_client):
"""verify_totp_code returns False for a wrong code."""
from app.models.auth import AuthUser
from sqlalchemy import select
from app.services import totp as totp_svc
client, engine = totp_client
_full_setup_and_enable(client)
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
result = totp_svc.verify_totp_code(user, "000000")
assert result is False
def test_verify_totp_code_no_secret(self, totp_client):
"""verify_totp_code returns False if user has no secret (TOTP not set up)."""
from app.models.auth import AuthUser
from sqlalchemy import select
from app.services import totp as totp_svc
client, engine = totp_client
with Session(engine) as db:
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
assert user.totp_secret is None
result = totp_svc.verify_totp_code(user, "123456")
assert result is False