"""Named local user accounts — passwords scrypt-hashed at rest (GOV-B7.1).
users.json is a FIXED file beside this module (server-side only; never served; the
name is a constant and every access re-validates containment with resolve() +
relative_to(), so the path can never escape the application folder). First run: an
admin account is created with a random password printed ONCE to the console, or
seeded from ADMIN_USER / ADMIN_PASS environment variables.
"""
import hashlib
import json
import os
import secrets
import time
from pathlib import Path
SCRYPT_N, SCRYPT_R, SCRYPT_P = 2 ** 14, 8, 1
BASE = Path(__file__).resolve().parent
STORE = BASE / "users.json" # constant name inside the app folder
def _contained(p: Path) -> Path:
p = p.resolve()
p.relative_to(BASE) # raises ValueError if it escapes the app folder
return p
def hash_password(password: str, salt: bytes | None = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32)
return salt.hex() + "$" + digest.hex()
def verify_password(password: str, stored: str) -> bool:
try:
salt_hex, digest_hex = stored.split("$", 1)
candidate = hashlib.scrypt(password.encode("utf-8"), salt=bytes.fromhex(salt_hex),
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32)
return secrets.compare_digest(candidate.hex(), digest_hex)
except (ValueError, TypeError):
return False
class UserStore:
"""{username: {"hash": ..., "created": ..., "failures": n, "locked_until": ts}}"""
def __init__(self):
self.users: dict = {}
store = _contained(STORE)
if store.exists():
self.users = json.loads(store.read_text(encoding="utf-8"))
def save(self) -> None:
_contained(STORE).write_text(json.dumps(self.users, indent=2), encoding="utf-8")
def ensure_admin(self) -> str | None:
"""Create the first admin if the store is empty; return the one-time password (env seed or random)."""
if self.users:
return None
username = os.environ.get("ADMIN_USER", "admin")
password = os.environ.get("ADMIN_PASS") or secrets.token_urlsafe(12)
self.users[username] = {"hash": hash_password(password), "created": time.time(),
"failures": 0, "locked_until": 0}
self.save()
return password
def check(self, username: str, password: str) -> bool:
rec = self.users.get(username)
if not rec:
return False
if rec.get("locked_until", 0) > time.time():
return False
if verify_password(password, rec["hash"]):
rec["failures"], rec["locked_until"] = 0, 0
self.save()
return True
rec["failures"] = rec.get("failures", 0) + 1
if rec["failures"] >= 5: # simple lockout: 5 fails -> 60 s
rec["locked_until"] = time.time() + 60
rec["failures"] = 0
self.save()
return False
def has_plaintext(self) -> bool:
"""True if any record looks like an unhashed password (a defect if ever true)."""
for rec in self.users.values():
if "$" not in str(rec.get("hash", "")):
return True
return False