Manage the password in the app; close remaining audit findings

Password management moves out of the CLI entirely. The credential is now a
salted scrypt hash in the database (so it survives rebuilds, living in the /data
volume) rather than an environment variable; PARTS_PASSWORD is demoted to a
bootstrap value that stops working the moment a password is set in the UI. Every
token carries a session epoch, so changing the password — or "sign out other
devices" — invalidates outstanding cookies while keeping the browser that made
the change signed in. A banner nags until the handed-over password is replaced.
app/admin.py remains for the one case the UI cannot cover, a forgotten password.

Audit findings:
- Taxonomy update and delete scanned affected parts before taking the write
  lock, so a concurrent rename could leave the search index matching a name the
  UI no longer showed. All four routes now lock first; removing the lock again
  makes the new test fail exactly that way.
- History of a missing part returned 200 with an empty list; now 404.
- Infinity and NaN passed ge=0 and failed at the database. They are rejected as
  422 now, and the validation error handler no longer chokes trying to echo a
  non-finite value back.

Checks go from 134 to 181.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 10:31:06 -04:00
parent 7bdf276342
commit ab82b5e9a9
11 changed files with 626 additions and 49 deletions
+112 -9
View File
@@ -9,10 +9,13 @@ import base64
import hashlib
import hmac
import os
import secrets
import threading
import time
from fastapi import Cookie, HTTPException, Request
from fastapi import Cookie, Depends, HTTPException, Request
from . import db
COOKIE_NAME = "parts_session"
DIGEST_SIZE = hashlib.sha256().digest_size
@@ -46,13 +49,103 @@ def session_days() -> int:
return _env_int("PARTS_SESSION_DAYS", 30)
def check_password(candidate: str) -> bool:
# --- stored password --------------------------------------------------------
#
# The password lives in the database, not the environment, so it can be changed
# from the UI and survives a container rebuild (it is in the /data volume).
# PARTS_PASSWORD is only the bootstrap credential: it works until a password has
# been set through the app, and is ignored from then on — otherwise "changing"
# the password would leave the old one working.
PASSWORD_KEY = "password_hash"
EPOCH_KEY = "session_epoch"
MIN_PASSWORD_LENGTH = 8
_SCRYPT_N = 1 << 14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_MAXMEM = 64 * 1024 * 1024
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.scrypt(
password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=32, maxmem=_SCRYPT_MAXMEM,
)
return "scrypt${}${}${}${}${}".format(
_SCRYPT_N, _SCRYPT_R, _SCRYPT_P,
base64.b64encode(salt).decode(), base64.b64encode(digest).decode(),
)
def verify_hash(password: str, stored: str) -> bool:
try:
scheme, n, r, pp, salt_b64, digest_b64 = stored.split("$")
if scheme != "scrypt":
return False
digest = hashlib.scrypt(
password.encode(), salt=base64.b64decode(salt_b64),
n=int(n), r=int(r), p=int(pp), dklen=len(base64.b64decode(digest_b64)),
maxmem=_SCRYPT_MAXMEM,
)
return hmac.compare_digest(digest, base64.b64decode(digest_b64))
except Exception:
return False
def stored_hash(conn) -> str | None:
return db.get_setting(conn, PASSWORD_KEY)
def using_bootstrap_password(conn) -> bool:
"""True while the env-provided password is still the live one."""
return stored_hash(conn) is None
def check_password(conn, candidate: str) -> bool:
stored = stored_hash(conn)
if stored:
return verify_hash(candidate, stored)
expected = _env("PARTS_PASSWORD")
if not expected:
return False
return hmac.compare_digest(candidate.encode(), expected.encode())
def set_password(conn, new_password: str):
"""Store a new password and invalidate every outstanding session."""
db.set_setting(conn, PASSWORD_KEY, hash_password(new_password))
bump_epoch(conn)
def password_problem(new_password: str) -> str | None:
if len(new_password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
if not new_password.strip():
return "Password must not be blank"
return None
# --- session epoch ----------------------------------------------------------
#
# Baked into every token. Bumping it invalidates all outstanding cookies at
# once, which is what makes "change my password" and "sign out everywhere"
# work without having to rotate PARTS_SECRET by hand on the host.
def current_epoch(conn) -> int:
try:
return int(db.get_setting(conn, EPOCH_KEY, "0") or "0")
except ValueError:
return 0
def bump_epoch(conn) -> int:
epoch = current_epoch(conn) + 1
db.set_setting(conn, EPOCH_KEY, epoch)
return epoch
# --- session tokens ---------------------------------------------------------
#
# The signature is raw HMAC bytes, which can contain any byte value including
@@ -61,14 +154,14 @@ def check_password(candidate: str) -> bool:
# split in the wrong place and failed their own validator. The digest is a fixed
# 32 bytes, so slice by length instead of looking for a delimiter.
def issue_token() -> str:
def issue_token(conn) -> str:
expires = int(time.time()) + session_days() * 86400
payload = str(expires).encode()
payload = f"{expires}:{current_epoch(conn)}".encode()
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(payload + sig).decode()
def token_valid(token: str) -> bool:
def token_valid(token: str, conn=None) -> bool:
try:
raw = base64.urlsafe_b64decode(token.encode())
if len(raw) <= DIGEST_SIZE:
@@ -77,7 +170,12 @@ def token_valid(token: str) -> bool:
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return False
return int(payload) > time.time()
expires_s, _, epoch_s = payload.decode().partition(":")
if int(expires_s) <= time.time():
return False
if conn is not None and int(epoch_s or 0) != current_epoch(conn):
return False
return True
except Exception:
return False
@@ -149,10 +247,15 @@ def is_https(request: Request) -> bool:
return request.url.scheme == "https"
def require_auth(parts_session: str | None = Cookie(default=None)):
"""FastAPI dependency guarding every data route."""
def require_auth(parts_session: str | None = Cookie(default=None), conn=Depends(db.get_db)):
"""FastAPI dependency guarding every data route.
Takes the same request-scoped connection the route uses (FastAPI caches
dependency results per request), so checking the session epoch costs one
indexed lookup rather than a second connection.
"""
if not auth_enabled():
return True
if parts_session and token_valid(parts_session):
if parts_session and token_valid(parts_session, conn):
return True
raise HTTPException(status_code=401, detail="Not authenticated")