"""Single-user login gate. The app sits on a public hostname, so it needs a door. One shared password from the environment buys a signed, expiring cookie — no user table, no password reset flow, nothing to administer. Set PARTS_AUTH=off for LAN-only use. """ import base64 import hashlib import hmac import os import time from fastapi import Cookie, HTTPException COOKIE_NAME = "parts_session" def _env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() def auth_enabled() -> bool: return _env("PARTS_AUTH", "on").lower() not in ("off", "0", "false", "no") def _secret() -> bytes: secret = _env("PARTS_SECRET") if not secret: # Without a configured secret, derive one from the password so sessions # are still unforgeable — they just don't survive a password change. secret = "fallback:" + _env("PARTS_PASSWORD") return hashlib.sha256(secret.encode()).digest() def session_days() -> int: try: return max(1, int(_env("PARTS_SESSION_DAYS", "30"))) except ValueError: return 30 def check_password(candidate: str) -> bool: expected = _env("PARTS_PASSWORD") if not expected: return False return hmac.compare_digest(candidate.encode(), expected.encode()) def issue_token() -> str: expires = int(time.time()) + session_days() * 86400 payload = str(expires).encode() sig = hmac.new(_secret(), payload, hashlib.sha256).digest() return base64.urlsafe_b64encode(payload + b"." + sig).decode() def token_valid(token: str) -> bool: try: raw = base64.urlsafe_b64decode(token.encode()) payload, sig = raw.rsplit(b".", 1) expected = hmac.new(_secret(), payload, hashlib.sha256).digest() if not hmac.compare_digest(sig, expected): return False return int(payload) > time.time() except Exception: return False def require_auth(parts_session: str | None = Cookie(default=None)): """FastAPI dependency guarding every data route.""" if not auth_enabled(): return True if parts_session and token_valid(parts_session): return True raise HTTPException(status_code=401, detail="Not authenticated")