diff --git a/.gitignore b/.gitignore index d01e5c7..0a16c91 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,3 @@ __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 d832613..11f6b4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,48 +232,6 @@ 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 35dff5e..805fb8a 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,6 @@ import os import re import json import time -import hmac import hashlib import secrets import requests @@ -12,7 +11,6 @@ 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) @@ -22,7 +20,7 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # Docker tab caches stale container IDs, so the footer is the reliable way # to confirm a `docker compose up -d --build` actually took effect. # Both are env-overridable if the deploy pipeline ever injects them. -APP_VERSION = os.environ.get("APP_VERSION", "1.6.0") +APP_VERSION = os.environ.get("APP_VERSION", "1.5.1") APP_VERSION_DATE = os.environ.get("APP_VERSION_DATE", "2026-08-27") # Must point at a path that survives `docker compose up --build` — the @@ -136,12 +134,6 @@ 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: @@ -153,57 +145,9 @@ def get_active_password(): return LOGIN_PASSWORD def _hash_password(pw): - """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. - """ + """SHA-256 hash for constant-time comparison.""" 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): @@ -1069,25 +1013,20 @@ 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 verify_credentials(username, entered): + if LOGIN_PASSWORD and _hash_password(entered) == _hash_password(get_active_password()): 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")) - # 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(): + error = "Incorrect password. Try again." + # If no password is set, auto-login (dev mode) + if not get_active_password(): session["authenticated"] = True return redirect(url_for("dashboard")) - return render_template("login.html", error=error, multi_user=multi_user) + return render_template("login.html", error=error) @app.route("/logout") @@ -1465,26 +1404,13 @@ 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 - - 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. + # Persist to settings.json and update runtime variable save_settings({"login_password": new_pw}) _runtime_password = new_pw return jsonify({"success": True}) diff --git a/manage_users.py b/manage_users.py deleted file mode 100644 index 1e64dcd..0000000 --- a/manage_users.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/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 4680114..d2e306b 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -54,9 +54,6 @@ 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 63308c9..2aaecd7 100644 --- a/templates/login.html +++ b/templates/login.html @@ -29,19 +29,9 @@ {% 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" autofocus autocomplete="current-password">