Add per-user logins alongside the shared password
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 <noreply@anthropic.com>
This commit is contained in:
parent
01ca334f6b
commit
8c6992035f
6 changed files with 282 additions and 11 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -2,3 +2,9 @@
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.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/
|
||||||
|
|
|
||||||
42
CLAUDE.md
42
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
|
rsync, which runs `--delete` and would otherwise remove it for not
|
||||||
being in the repo.
|
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 <username>
|
||||||
|
docker exec -it arr-summary python3 manage_users.py passwd <username>
|
||||||
|
docker exec -it arr-summary python3 manage_users.py remove <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
A key saved through the Settings page overrides the corresponding
|
||||||
`.env` value at startup (see the block after the `*_API_KEY` reads in
|
`.env` value at startup (see the block after the `*_API_KEY` reads in
|
||||||
`app.py`); `.env` remains the source of truth for anything never
|
`app.py`); `.env` remains the source of truth for anything never
|
||||||
|
|
|
||||||
92
app.py
92
app.py
|
|
@ -2,6 +2,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -11,6 +12,7 @@ from datetime import datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for, session
|
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
|
# Suppress SSL warnings for Unraid's self-signed certificate
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
@ -134,6 +136,12 @@ def inject_version():
|
||||||
LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "")
|
LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "")
|
||||||
_runtime_password = None # overrides env var after change-password call
|
_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():
|
def get_active_password():
|
||||||
if _runtime_password is not None:
|
if _runtime_password is not None:
|
||||||
|
|
@ -145,9 +153,57 @@ def get_active_password():
|
||||||
return LOGIN_PASSWORD
|
return LOGIN_PASSWORD
|
||||||
|
|
||||||
def _hash_password(pw):
|
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()
|
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):
|
def login_required(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
|
@ -1013,20 +1069,25 @@ def get_rt_tv():
|
||||||
@app.route("/login", methods=["GET", "POST"])
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
error = None
|
error = None
|
||||||
|
multi_user = bool(load_users())
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
entered = request.form.get("password", "")
|
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["authenticated"] = True
|
||||||
|
session["user"] = username if multi_user else ""
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
# Always redirect to dashboard — avoids bare-path redirect issues
|
# Always redirect to dashboard — avoids bare-path redirect issues
|
||||||
# when running behind a reverse proxy subpath (e.g. /media/)
|
# when running behind a reverse proxy subpath (e.g. /media/)
|
||||||
return redirect(url_for("dashboard"))
|
return redirect(url_for("dashboard"))
|
||||||
error = "Incorrect password. Try again."
|
# Deliberately doesn't say which half was wrong.
|
||||||
# If no password is set, auto-login (dev mode)
|
error = ("Incorrect username or password." if multi_user
|
||||||
if not get_active_password():
|
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
|
session["authenticated"] = True
|
||||||
return redirect(url_for("dashboard"))
|
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")
|
@app.route("/logout")
|
||||||
|
|
@ -1404,13 +1465,26 @@ def api_change_password():
|
||||||
confirm = data.get("confirm", "")
|
confirm = data.get("confirm", "")
|
||||||
if not current or not new_pw or not confirm:
|
if not current or not new_pw or not confirm:
|
||||||
return jsonify({"error": "All fields are required"}), 400
|
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:
|
if new_pw != confirm:
|
||||||
return jsonify({"error": "New passwords do not match"}), 400
|
return jsonify({"error": "New passwords do not match"}), 400
|
||||||
if len(new_pw) < 6:
|
if len(new_pw) < 6:
|
||||||
return jsonify({"error": "Password must be at least 6 characters"}), 400
|
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})
|
save_settings({"login_password": new_pw})
|
||||||
_runtime_password = new_pw
|
_runtime_password = new_pw
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
|
||||||
136
manage_users.py
Normal file
136
manage_users.py
Normal file
|
|
@ -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 <username>
|
||||||
|
docker exec -it arr-summary python3 manage_users.py passwd <username>
|
||||||
|
docker exec -it arr-summary python3 manage_users.py remove <username>
|
||||||
|
|
||||||
|
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 <username>", 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 <username>", 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 <username>", 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))
|
||||||
|
|
@ -54,6 +54,9 @@ input {
|
||||||
}
|
}
|
||||||
input:focus { border-color: var(--accent); }
|
input:focus { border-color: var(--accent); }
|
||||||
input::placeholder { color: var(--text-3); }
|
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 {
|
.error-msg {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,19 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
|
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
|
||||||
<label for="password">Password</label>
|
{# 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 %}
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username"
|
||||||
|
placeholder="Enter username" autofocus autocomplete="username"
|
||||||
|
autocapitalize="none" autocorrect="off" spellcheck="false">
|
||||||
|
{% endif %}
|
||||||
|
<label for="password"{% if multi_user %} class="stacked"{% endif %}>Password</label>
|
||||||
<input type="password" id="password" name="password"
|
<input type="password" id="password" name="password"
|
||||||
placeholder="Enter password" autofocus autocomplete="current-password">
|
placeholder="Enter password"{% if not multi_user %} autofocus{% endif %}
|
||||||
|
autocomplete="current-password">
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue