From 8c6992035f156543358c68a66f89233061d8939d Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Thu, 27 Aug 2026 13:03:21 -0700 Subject: [PATCH] Add per-user logins alongside the shared password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard had one shared password and no concept of a user. This adds real accounts: a username field on the login page, per-user credentials, and the signed-in user recorded on the session, which is the groundwork for per-user permissions later. Credentials are salted pbkdf2 hashes in settings.json's `users` map. Three deliberate choices there: - settings.json rather than an env var, because a pbkdf2 hash contains '$' and docker compose interpolates that when it reads .env. It's also already the bind-mounted file that survives `up -d --build`. - pbkdf2 rather than Werkzeug's default scrypt, which needs a hashlib built against OpenSSL — the macOS system Python has no hashlib.scrypt and would raise at hash time. - load_users() re-reads the file per check, so adding a user or changing a password is live in every gunicorn worker with no restart. The old _runtime_password was per-worker and only ever half-applied. Multi-user activates only once an account exists. Until then the original LOGIN_PASSWORD path is used unchanged and the username field is hidden, so deploying this can't lock the existing install out. manage_users.py prompts for passwords via getpass — never echoed, never a CLI argument — and only ever writes hashes; it refuses to remove the last account, which would silently drop the dashboard back to the shared password. Two fixes carried along: login no longer requires the LOGIN_PASSWORD env var specifically, so a settings.json-only password works instead of locking you out; and the legacy comparison now uses hmac.compare_digest, which the old docstring claimed but '==' didn't provide. Also gitignores settings.json and data/ — they hold API keys and now password hashes, were excluded from the deploy rsync but not from git. Verified: legacy fallback still signs in with no accounts configured; a 9-case credential matrix (right/wrong user x right/wrong password, plus the retired shared password); per-user change-password scoped to the signed-in account and live without restart; and the add/passwd/remove guards. Co-Authored-By: Claude Opus 5 --- .gitignore | 6 ++ CLAUDE.md | 42 +++++++++++++ app.py | 92 ++++++++++++++++++++++++++--- manage_users.py | 136 +++++++++++++++++++++++++++++++++++++++++++ static/css/login.css | 3 + templates/login.html | 14 ++++- 6 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 manage_users.py diff --git a/.gitignore b/.gitignore index 0a16c91..d01e5c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ __pycache__/ *.pyc .DS_Store + +# Runtime settings — API-key overrides, timezone, and the `users` map of +# password hashes. Already excluded from the deploy rsync; keep it out of +# git too so credentials can't be committed by accident. +settings.json +data/ diff --git a/CLAUDE.md b/CLAUDE.md index 11f6b4b..d832613 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,48 @@ silently discards it. `data/` is likewise excluded from the deploy rsync, which runs `--delete` and would otherwise remove it for not being in the repo. +## Logins + +Two modes, chosen by whether `settings.json` holds a non-empty `users` map: + +- **Multi-user** (once any account exists): username + password, each + password stored as a salted pbkdf2 hash under `users` in `settings.json`. + `verify_credentials()` is the only entry point; `load_users()` re-reads + the file per check, so adding a user or changing a password is live + immediately in every gunicorn worker — no restart, unlike the API keys. +- **Legacy single shared password** (no accounts yet): the original + `LOGIN_PASSWORD` / `settings.json.login_password` path, username ignored. + This fallback exists so deploying multi-user support can't lock an + existing install out; the login page hides the username field entirely + until accounts exist. + +Manage accounts with `manage_users.py`, which prompts for passwords +(never echoed, never a CLI argument, so they stay out of shell history) +and only ever writes hashes: + +```bash +docker exec -it arr-summary python3 manage_users.py list +docker exec -it arr-summary python3 manage_users.py add +docker exec -it arr-summary python3 manage_users.py passwd +docker exec -it arr-summary python3 manage_users.py remove +``` + +**Adding the first account switches the whole dashboard to per-user +logins and the shared password stops working** — verify you can sign in +as that user before closing the shell. `remove` refuses to delete the +last account for the same reason. + +Users live in `settings.json` rather than an env var deliberately: a +pbkdf2 hash contains `$`, which docker compose interpolates when reading +`.env`. `PASSWORD_HASH_METHOD` is pinned to pbkdf2 rather than Werkzeug's +default scrypt, which needs a hashlib built against OpenSSL and is absent +on the macOS system Python. + +**Never put a real password in the repo** — not in `app.py`, not in +`settings.json` committed to git, not in a fixture. `.env` and `data/` +are gitignored and excluded from the deploy rsync precisely so secrets +have somewhere to live; everything else here is pushed to Forgejo. + A key saved through the Settings page overrides the corresponding `.env` value at startup (see the block after the `*_API_KEY` reads in `app.py`); `.env` remains the source of truth for anything never diff --git a/app.py b/app.py index 805fb8a..c675088 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,7 @@ import os import re import json import time +import hmac import hashlib import secrets import requests @@ -11,6 +12,7 @@ from datetime import datetime, timedelta from functools import wraps from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, url_for, session +from werkzeug.security import generate_password_hash, check_password_hash # Suppress SSL warnings for Unraid's self-signed certificate urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) @@ -134,6 +136,12 @@ def inject_version(): LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "") _runtime_password = None # overrides env var after change-password call +# scrypt (Werkzeug's default) needs a hashlib built against OpenSSL, which +# isn't guaranteed — the macOS system Python has no hashlib.scrypt at all. +# pbkdf2 is pure hashlib and available everywhere this runs. +PASSWORD_HASH_METHOD = "pbkdf2:sha256" +_dummy_hash = None # lazily built; see verify_credentials + def get_active_password(): if _runtime_password is not None: @@ -145,9 +153,57 @@ def get_active_password(): return LOGIN_PASSWORD def _hash_password(pw): - """SHA-256 hash for constant-time comparison.""" + """SHA-256 of the legacy single shared password. + + Only used for the pre-multi-user fallback below. New accounts are hashed + with pbkdf2 + a per-user salt via load_users()/verify_credentials. + """ return hashlib.sha256(pw.encode()).hexdigest() + +def load_users(): + """Username -> pbkdf2 hash, read fresh from settings.json. + + Users live in settings.json rather than an env var for two reasons: a + pbkdf2 hash contains '$', which docker compose interpolates when it reads + .env, and settings.json is already the bind-mounted file that survives + `up -d --build`. Reading it per-call also means an added user takes effect + without a restart, and applies to every gunicorn worker at once. + """ + users = load_settings().get("users") or {} + if not isinstance(users, dict): + return {} + return {u: h for u, h in users.items() if isinstance(h, str) and h} + + +def verify_credentials(username, password): + """True if these credentials are valid. + + Multi-user as soon as settings.json holds a non-empty `users` map; + until then this falls back to the original single shared password so + deploying this doesn't lock an existing install out. The username is + ignored on that legacy path. + """ + global _dummy_hash + users = load_users() + if users: + stored = users.get(username) + if not stored: + # Verify against a throwaway hash anyway, so a wrong username + # costs the same time as a wrong password and can't be probed. + if _dummy_hash is None: + _dummy_hash = generate_password_hash( + secrets.token_hex(16), method=PASSWORD_HASH_METHOD + ) + check_password_hash(_dummy_hash, password) + return False + return check_password_hash(stored, password) + + active = get_active_password() + if not active: + return False + return hmac.compare_digest(_hash_password(password), _hash_password(active)) + def login_required(f): @wraps(f) def decorated(*args, **kwargs): @@ -1013,20 +1069,25 @@ def get_rt_tv(): @app.route("/login", methods=["GET", "POST"]) def login(): error = None + multi_user = bool(load_users()) if request.method == "POST": + username = request.form.get("username", "").strip() entered = request.form.get("password", "") - if LOGIN_PASSWORD and _hash_password(entered) == _hash_password(get_active_password()): + if verify_credentials(username, entered): session["authenticated"] = True + session["user"] = username if multi_user else "" session.permanent = True # Always redirect to dashboard — avoids bare-path redirect issues # when running behind a reverse proxy subpath (e.g. /media/) return redirect(url_for("dashboard")) - error = "Incorrect password. Try again." - # If no password is set, auto-login (dev mode) - if not get_active_password(): + # Deliberately doesn't say which half was wrong. + error = ("Incorrect username or password." if multi_user + else "Incorrect password. Try again.") + # If nothing is configured at all, auto-login (dev mode) + if not multi_user and not get_active_password(): session["authenticated"] = True return redirect(url_for("dashboard")) - return render_template("login.html", error=error) + return render_template("login.html", error=error, multi_user=multi_user) @app.route("/logout") @@ -1404,13 +1465,26 @@ def api_change_password(): confirm = data.get("confirm", "") if not current or not new_pw or not confirm: return jsonify({"error": "All fields are required"}), 400 - if _hash_password(current) != _hash_password(get_active_password()): - return jsonify({"error": "Current password is incorrect"}), 403 if new_pw != confirm: return jsonify({"error": "New passwords do not match"}), 400 if len(new_pw) < 6: return jsonify({"error": "Password must be at least 6 characters"}), 400 - # Persist to settings.json and update runtime variable + + users = load_users() + if users: + # Multi-user: change only the signed-in account's own password. + me = session.get("user", "") + if me not in users or not verify_credentials(me, current): + return jsonify({"error": "Current password is incorrect"}), 403 + users[me] = generate_password_hash(new_pw, method=PASSWORD_HASH_METHOD) + save_settings({"users": users}) + # No _runtime_password here: load_users() re-reads settings.json on + # every check, so the change is live in every worker immediately. + return jsonify({"success": True}) + + if not verify_credentials("", current): + return jsonify({"error": "Current password is incorrect"}), 403 + # Legacy single-password install: persist and update the runtime value. save_settings({"login_password": new_pw}) _runtime_password = new_pw return jsonify({"success": True}) diff --git a/manage_users.py b/manage_users.py new file mode 100644 index 0000000..1e64dcd --- /dev/null +++ b/manage_users.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Add, list and remove Media Centre dashboard accounts. + +Passwords are prompted for interactively (never echoed, never taken as a +command-line argument, so they don't land in shell history) and only ever +stored as a salted pbkdf2 hash in settings.json. Nothing here writes a +plaintext password anywhere. + +Run it inside the container, where Werkzeug and the real settings.json are: + + docker exec -it arr-summary python3 manage_users.py list + docker exec -it arr-summary python3 manage_users.py add + docker exec -it arr-summary python3 manage_users.py passwd + docker exec -it arr-summary python3 manage_users.py remove + +The first `add` switches the dashboard from its single shared password to +per-user logins. Until then the old LOGIN_PASSWORD keeps working, so you +can't lock yourself out by deploying this. +""" + +import sys +import getpass + +from app import ( + SETTINGS_FILE, + PASSWORD_HASH_METHOD, + save_settings, + load_users, + generate_password_hash, +) + +MIN_LEN = 6 + + +def _prompt_new_password(username): + """Ask twice, confirm they match, never echo. None if aborted.""" + pw = getpass.getpass(f"New password for {username!r}: ") + if not pw: + print("Aborted: empty password.", file=sys.stderr) + return None + if len(pw) < MIN_LEN: + print(f"Aborted: must be at least {MIN_LEN} characters.", file=sys.stderr) + return None + if pw != getpass.getpass("Confirm: "): + print("Aborted: passwords don't match.", file=sys.stderr) + return None + return pw + + +def cmd_list(_args): + users = load_users() + if not users: + print("No accounts configured — the dashboard is still using the") + print("single shared password (LOGIN_PASSWORD / settings.json).") + return 0 + print(f"{len(users)} account(s) in {SETTINGS_FILE}:") + for name in sorted(users): + print(f" {name}") + return 0 + + +def _set_password(username, require_existing): + users = load_users() + exists = username in users + if require_existing and not exists: + print(f"No such account: {username!r}. Use `add` to create it.", file=sys.stderr) + return 1 + if not require_existing and exists: + print(f"Account {username!r} already exists. Use `passwd` to change it.", + file=sys.stderr) + return 1 + + pw = _prompt_new_password(username) + if pw is None: + return 1 + + users[username] = generate_password_hash(pw, method=PASSWORD_HASH_METHOD) + save_settings({"users": users}) + print(f"{'Updated' if exists else 'Created'} {username!r} in {SETTINGS_FILE}.") + if not exists and len(users) == 1: + print() + print("This is the first account, so the dashboard has just switched to") + print("per-user logins. The old shared password no longer signs in —") + print("make sure you can log in as this user before closing this shell.") + return 0 + + +def cmd_add(args): + if not args: + print("Usage: manage_users.py add ", file=sys.stderr) + return 2 + return _set_password(args[0].strip(), require_existing=False) + + +def cmd_passwd(args): + if not args: + print("Usage: manage_users.py passwd ", file=sys.stderr) + return 2 + return _set_password(args[0].strip(), require_existing=True) + + +def cmd_remove(args): + if not args: + print("Usage: manage_users.py remove ", file=sys.stderr) + return 2 + username = args[0].strip() + users = load_users() + if username not in users: + print(f"No such account: {username!r}", file=sys.stderr) + return 1 + if len(users) == 1: + print("Refusing to remove the last account — that would drop the dashboard", + file=sys.stderr) + print("back to the shared password, which is probably not what you meant.", + file=sys.stderr) + print("Add another account first, or clear `users` from settings.json by hand.", + file=sys.stderr) + return 1 + del users[username] + save_settings({"users": users}) + print(f"Removed {username!r}. {len(users)} account(s) remain.") + return 0 + + +COMMANDS = {"list": cmd_list, "add": cmd_add, "passwd": cmd_passwd, "remove": cmd_remove} + + +def main(argv): + if len(argv) < 2 or argv[1] not in COMMANDS: + print(__doc__.strip()) + return 2 + return COMMANDS[argv[1]](argv[2:]) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/static/css/login.css b/static/css/login.css index d2e306b..4680114 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -54,6 +54,9 @@ input { } input:focus { border-color: var(--accent); } input::placeholder { color: var(--text-3); } +/* Second field in a stacked pair (username above password) needs the gap + the single-field form never had. */ +label.stacked { margin-top: 1rem; } .error-msg { background: var(--surface-2); diff --git a/templates/login.html b/templates/login.html index 2aaecd7..63308c9 100644 --- a/templates/login.html +++ b/templates/login.html @@ -29,9 +29,19 @@ {% endif %}
- + {# The username field only appears once accounts exist in + settings.json. A single-password install never sees a box it + would have to leave blank. #} + {% if multi_user %} + + + {% endif %} + + placeholder="Enter password"{% if not multi_user %} autofocus{% endif %} + autocomplete="current-password">