Closes the forgot-login-password gap. A per-profile recovery question + answer lets
a locked-out user reset their own password.
- DB: additive recovery_question + recovery_answer_hash columns (idempotent
migration). set/get-question/verify/has methods; answer is hashed (pbkdf2) and
matched forgivingly (trim + lowercase + collapse whitespace). No recovery set →
never verifies.
- Endpoints (allowlisted in the login gate so they work pre-auth):
GET /api/auth/recovery-question?username= (generic 404 when absent),
POST /api/auth/recovery-reset {username, answer, new_password} — brute-force
limited; a correct answer sets the new password + authenticates the session.
POST /api/profiles/<id>/set-recovery (admin or self) to configure it.
Tests: set/get/verify, forgiving match, hashed-not-plaintext, no-recovery-never-
verifies, full reset flow (wrong answer rejected + password intact; correct answer
resets), unknown-user 404. 25 tests pass. Next: the Settings + login-screen UI.
60 lines
2.2 KiB
Python
60 lines
2.2 KiB
Python
"""Login-password recovery via security question + answer — DB layer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from database.music_database import MusicDatabase
|
|
|
|
|
|
@pytest.fixture
|
|
def db(tmp_path):
|
|
return MusicDatabase(str(tmp_path / "music.db"))
|
|
|
|
|
|
def test_migration_adds_recovery_columns(db):
|
|
with db._get_connection() as conn:
|
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()]
|
|
assert 'recovery_question' in cols and 'recovery_answer_hash' in cols
|
|
|
|
|
|
def test_set_get_verify(db):
|
|
pid = db.create_profile(name='RecUser')
|
|
assert db.profile_has_recovery(pid) is False
|
|
assert db.get_profile_recovery_question(pid) is None
|
|
|
|
db.set_profile_recovery(pid, 'First pet?', 'Rex')
|
|
assert db.profile_has_recovery(pid) is True
|
|
assert db.get_profile_recovery_question(pid) == 'First pet?'
|
|
assert db.verify_profile_recovery_answer(pid, 'Rex') is True
|
|
assert db.verify_profile_recovery_answer(pid, 'Fido') is False
|
|
|
|
|
|
def test_answer_match_is_forgiving(db):
|
|
pid = db.create_profile(name='Forgiving')
|
|
db.set_profile_recovery(pid, 'City?', ' New York ')
|
|
assert db.verify_profile_recovery_answer(pid, 'new york') is True # case + spacing
|
|
assert db.verify_profile_recovery_answer(pid, 'NEW YORK') is True
|
|
|
|
|
|
def test_no_recovery_never_verifies(db):
|
|
pid = db.create_profile(name='NoRec')
|
|
assert db.verify_profile_recovery_answer(pid, '') is False
|
|
assert db.verify_profile_recovery_answer(pid, 'anything') is False
|
|
|
|
|
|
def test_clearing_recovery(db):
|
|
pid = db.create_profile(name='ClearRec')
|
|
db.set_profile_recovery(pid, 'Q?', 'A')
|
|
assert db.profile_has_recovery(pid) is True
|
|
db.set_profile_recovery(pid, '', '')
|
|
assert db.profile_has_recovery(pid) is False
|
|
assert db.get_profile_recovery_question(pid) is None
|
|
|
|
|
|
def test_answer_is_hashed_not_plaintext(db):
|
|
pid = db.create_profile(name='Hashed')
|
|
db.set_profile_recovery(pid, 'Q?', 'secretanswer')
|
|
with db._get_connection() as conn:
|
|
stored = conn.execute("SELECT recovery_answer_hash FROM profiles WHERE id = ?", (pid,)).fetchone()[0]
|
|
assert 'secretanswer' not in stored and stored.startswith('pbkdf2:')
|