From 8e1b678d6f45fc473440a259dc93272f632c01a9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 21:57:44 -0700 Subject: [PATCH] Native login (increment 1/3): per-profile password DB layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- database/music_database.py | 80 ++++++++++++++++++++++++++++++++++ tests/test_profile_password.py | 67 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/test_profile_password.py diff --git a/database/music_database.py b/database/music_database.py index 3c2ebf86..88f23593 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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 diff --git a/tests/test_profile_password.py b/tests/test_profile_password.py new file mode 100644 index 00000000..798e6d91 --- /dev/null +++ b/tests/test_profile_password.py @@ -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