Native login (increment 1/3): per-profile password DB layer
Opt-in username/password login — profiles become real accounts. This is the data layer: a per-profile login password, kept SEPARATE from the quick-switch PIN (different security purpose; a 4-digit PIN must not become the password guarding a public instance). - Additive migration: profiles.password_hash column (idempotent, metadata-flagged). - set_profile_password / verify_profile_password / profile_has_password / get_profile_by_name (the login username = profile name, unique + case-insensitive). - Security default: a profile with NO password is NOT loginable (verify returns False) — unlike the PIN where "no PIN = always valid". You can't authenticate to an account with no credential. Tests: migration adds the column; set/verify; no-password-never-loginable; clearing; name lookup; and password is fully independent of the PIN. 6 tests pass. Next: the login endpoint + require_login gate (increment 2).
This commit is contained in:
parent
fb8c8a71c6
commit
8e1b678d6f
2 changed files with 147 additions and 0 deletions
|
|
@ -460,6 +460,7 @@ class MusicDatabase:
|
|||
self._add_profile_support_v4(cursor)
|
||||
self._add_profile_settings(cursor)
|
||||
self._add_profile_listenbrainz_support(cursor)
|
||||
self._add_profile_password_support(cursor)
|
||||
self._add_profile_service_credentials(cursor)
|
||||
self._add_service_credential_sets(cursor)
|
||||
self._add_soul_id_columns(cursor)
|
||||
|
|
@ -5375,6 +5376,85 @@ class MusicDatabase:
|
|||
logger.error(f"Error verifying PIN for profile {profile_id}: {e}")
|
||||
return False
|
||||
|
||||
# ── Per-profile LOGIN password (opt-in username/password mode) ────────────
|
||||
# Separate from the quick-switch PIN on purpose: the PIN is a low-stakes
|
||||
# convenience on a trusted LAN; the password authenticates an account for
|
||||
# public exposure. Conflating them would make logins as weak as a 4-digit PIN.
|
||||
|
||||
def set_profile_password(self, profile_id: int, password: str) -> bool:
|
||||
"""Set (or clear, when password is falsy) a profile's login password."""
|
||||
try:
|
||||
from werkzeug.security import generate_password_hash
|
||||
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') if password else None
|
||||
with self._get_connection() as conn:
|
||||
conn.execute("UPDATE profiles SET password_hash = ? WHERE id = ?", (pwd_hash, profile_id))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting password for profile {profile_id}: {e}")
|
||||
return False
|
||||
|
||||
def verify_profile_password(self, profile_id: int, password: str) -> bool:
|
||||
"""Verify a profile's login password. Unlike the PIN, a profile with NO
|
||||
password set is NOT loginable (returns False) — you can't authenticate to
|
||||
an account that has no credential."""
|
||||
try:
|
||||
from werkzeug.security import check_password_hash
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT password_hash FROM profiles WHERE id = ?", (profile_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row or not row['password_hash']:
|
||||
return False # no password set → cannot log in
|
||||
return check_password_hash(row['password_hash'], password)
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying password for profile {profile_id}: {e}")
|
||||
return False
|
||||
|
||||
def profile_has_password(self, profile_id: int) -> bool:
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT password_hash FROM profiles WHERE id = ?", (profile_id,))
|
||||
row = cursor.fetchone()
|
||||
return bool(row and row['password_hash'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking password for profile {profile_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_profile_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Look up a profile by name (the login username), case-insensitive."""
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id, name, is_admin FROM profiles WHERE LOWER(name) = LOWER(?)",
|
||||
(name or '',))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {'id': row['id'], 'name': row['name'], 'is_admin': bool(row['is_admin'])}
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up profile by name '{name}': {e}")
|
||||
return None
|
||||
|
||||
def _add_profile_password_support(self, cursor):
|
||||
"""Add a per-profile login password column (separate from pin_hash)."""
|
||||
try:
|
||||
cursor.execute("SELECT value FROM metadata WHERE key = 'profiles_password_v1' LIMIT 1")
|
||||
if cursor.fetchone():
|
||||
return # Already migrated
|
||||
logger.info("Applying per-profile login-password migration...")
|
||||
try:
|
||||
cursor.execute("ALTER TABLE profiles ADD COLUMN password_hash TEXT DEFAULT NULL")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO metadata (key, value) VALUES ('profiles_password_v1', '1')")
|
||||
logger.info("Per-profile login-password migration completed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in login-password migration: {e}")
|
||||
|
||||
def close(self):
|
||||
"""Close database connection (no-op since we create connections per operation)"""
|
||||
# Each operation creates and closes its own connection, so nothing to do here
|
||||
|
|
|
|||
67
tests/test_profile_password.py
Normal file
67
tests/test_profile_password.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Per-profile LOGIN password (opt-in username/password mode) — DB layer.
|
||||
|
||||
Separate from the quick-switch PIN: a profile with no password set is NOT
|
||||
loginable (you can't authenticate to an account with no credential), unlike the
|
||||
PIN where 'no PIN = always valid'.
|
||||
"""
|
||||
|
||||
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_password_hash_column(db):
|
||||
with db._get_connection() as conn:
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(profiles)").fetchall()]
|
||||
assert 'password_hash' in cols
|
||||
|
||||
|
||||
def test_set_and_verify_password(db):
|
||||
pid = db.create_profile(name='Brock')
|
||||
assert db.profile_has_password(pid) is False # none yet
|
||||
assert db.verify_profile_password(pid, 'hunter2') is False # no password → not loginable
|
||||
|
||||
db.set_profile_password(pid, 'hunter2')
|
||||
assert db.profile_has_password(pid) is True
|
||||
assert db.verify_profile_password(pid, 'hunter2') is True
|
||||
assert db.verify_profile_password(pid, 'wrong') is False
|
||||
|
||||
|
||||
def test_no_password_is_never_loginable(db):
|
||||
# Unlike the PIN (no PIN = always valid), a passwordless account can't log in.
|
||||
pid = db.create_profile(name='NoPass')
|
||||
assert db.verify_profile_password(pid, '') is False
|
||||
assert db.verify_profile_password(pid, 'anything') is False
|
||||
|
||||
|
||||
def test_clearing_password(db):
|
||||
pid = db.create_profile(name='Temp')
|
||||
db.set_profile_password(pid, 'pw')
|
||||
assert db.profile_has_password(pid) is True
|
||||
db.set_profile_password(pid, '') # clear
|
||||
assert db.profile_has_password(pid) is False
|
||||
assert db.verify_profile_password(pid, 'pw') is False
|
||||
|
||||
|
||||
def test_get_profile_by_name_case_insensitive(db):
|
||||
pid = db.create_profile(name='Daughter')
|
||||
assert db.get_profile_by_name('daughter')['id'] == pid
|
||||
assert db.get_profile_by_name('DAUGHTER')['id'] == pid
|
||||
assert db.get_profile_by_name('nobody') is None
|
||||
|
||||
|
||||
def test_password_is_independent_of_pin(db):
|
||||
# Setting a password must not touch the PIN and vice-versa (separate creds).
|
||||
from werkzeug.security import generate_password_hash
|
||||
pid = db.create_profile(name='Both', pin_hash=generate_password_hash('1234', method='pbkdf2:sha256'))
|
||||
db.set_profile_password(pid, 'longpassword')
|
||||
assert db.verify_profile_pin(pid, '1234') is True # PIN still works
|
||||
assert db.verify_profile_password(pid, 'longpassword') is True # password works
|
||||
assert db.verify_profile_password(pid, '1234') is False # PIN is NOT the password
|
||||
Loading…
Reference in a new issue