M4-T06: add TOTP second factor to login (totp_code + recovery codes)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user