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:
+112
-9
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user