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
92
app.py
92
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})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue