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:
BoulderBadgeDad 2026-06-10 21:57:44 -07:00
parent fb8c8a71c6
commit 8e1b678d6f
2 changed files with 147 additions and 0 deletions

View file

@ -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

View 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