"""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 threading import time from fastapi import Cookie, HTTPException, Request COOKIE_NAME = "parts_session" DIGEST_SIZE = hashlib.sha256().digest_size def _env(name: str, default: str = "") -> str: return os.environ.get(name, default).strip() def _env_int(name: str, default: int, minimum: int = 1) -> int: try: return max(minimum, int(_env(name, str(default)))) except ValueError: return default 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: return _env_int("PARTS_SESSION_DAYS", 30) def check_password(candidate: str) -> bool: expected = _env("PARTS_PASSWORD") if not expected: return False return hmac.compare_digest(candidate.encode(), expected.encode()) # --- session tokens --------------------------------------------------------- # # The signature is raw HMAC bytes, which can contain any byte value including # the one for ".". An earlier version joined payload and signature with "." and # split on the last occurrence, so roughly 12% of issued tokens (1 - (255/256)^32) # 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: expires = int(time.time()) + session_days() * 86400 payload = str(expires).encode() sig = hmac.new(_secret(), payload, hashlib.sha256).digest() return base64.urlsafe_b64encode(payload + sig).decode() def token_valid(token: str) -> bool: try: raw = base64.urlsafe_b64decode(token.encode()) if len(raw) <= DIGEST_SIZE: return False payload, sig = raw[:-DIGEST_SIZE], raw[-DIGEST_SIZE:] 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 # --- failed-login throttling ------------------------------------------------ # # Deliberately a GLOBAL window rather than per-IP. Caddy appends to # X-Forwarded-For rather than replacing it, so the client-supplied end of that # header is attacker-controlled and a per-IP bucket would be trivially evaded by # forging it. There is exactly one legitimate user, so a global cap costs # nothing in practice and cannot be side-stepped by rotating source addresses. # # The trade-off: someone spraying guesses can lock the login form for the # cooldown. Existing sessions keep working (they carry a valid cookie), so that # is an annoyance rather than a lockout. _failures: list[float] = [] _failures_lock = threading.Lock() def _window() -> int: return _env_int("PARTS_LOGIN_WINDOW", 300) def _max_failures() -> int: return _env_int("PARTS_LOGIN_MAX_FAILURES", 10) def login_retry_after() -> int: """Seconds until the next login attempt is allowed, or 0 if allowed now.""" now = time.time() window = _window() with _failures_lock: recent = [t for t in _failures if now - t < window] _failures[:] = recent if len(recent) < _max_failures(): return 0 return max(1, int(window - (now - recent[0]))) def record_failure(): with _failures_lock: _failures.append(time.time()) def clear_failures(): with _failures_lock: _failures.clear() # --- request helpers -------------------------------------------------------- def is_https(request: Request) -> bool: """Whether the *original* client request used TLS. Caddy terminates TLS and proxies to this app over plain HTTP, so `request.url.scheme` is always "http" in production and can't decide whether the session cookie may carry the Secure flag. X-Forwarded-Proto is what the proxy records the real scheme in. """ override = _env("PARTS_SECURE_COOKIE", "auto").lower() if override in ("on", "1", "true", "yes"): return True if override in ("off", "0", "false", "no"): return False forwarded = request.headers.get("x-forwarded-proto", "") if forwarded: return forwarded.split(",")[0].strip().lower() == "https" return request.url.scheme == "https" 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")