8e817ec48d
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
65 lines
2.3 KiB
Python
65 lines
2.3 KiB
Python
# backend/auth_cookies.py
|
|
"""Generic HMAC-signed cookie payloads, shared by operator auth (and, optionally
|
|
later, the portal). A signed value is f"{b64url(json)}.{hmac_sha256(b64)}"; read()
|
|
verifies the signature in constant time and enforces a server-side iat expiry.
|
|
|
|
The signing secret is the same SECRET_KEY the portal already reads, so a single
|
|
env var protects both cookies. Never store or log raw secrets."""
|
|
import os
|
|
import hmac
|
|
import json
|
|
import time
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Same env var the portal cookie uses — one secret protects both. The insecure
|
|
# default only exists so dev/test boots without config; set a real SECRET_KEY in prod.
|
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
|
|
# Set COOKIE_SECURE=true once served over HTTPS; leave false on plain HTTP or the
|
|
# browser won't send the cookie.
|
|
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _sign(body: str) -> str:
|
|
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def sign(payload: dict) -> str:
|
|
"""Serialize + sign a payload dict into a cookie-safe string."""
|
|
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
|
return f"{body}.{_sign(body)}"
|
|
|
|
|
|
def read(raw, max_age: int):
|
|
"""Verify a signed value and return its payload dict, or None if missing,
|
|
tampered, or older than max_age seconds (by its own `iat`)."""
|
|
if not raw or not isinstance(raw, str):
|
|
return None
|
|
try:
|
|
body, sig = raw.rsplit(".", 1)
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
if not hmac.compare_digest(sig, _sign(body)):
|
|
return None
|
|
try:
|
|
data = json.loads(base64.urlsafe_b64decode(body.encode()))
|
|
except Exception:
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
iat = data.get("iat")
|
|
if not isinstance(iat, (int, float)):
|
|
return None
|
|
now = time.time()
|
|
# Reject implausibly future-dated tokens: the same server signs and verifies,
|
|
# so there's no real clock skew — a far-future iat (e.g. to dodge max_age or
|
|
# outlive a sessions_valid_from bump) is bogus. 60s of slack is generous.
|
|
if iat - now > 60:
|
|
return None
|
|
if (now - iat) > max_age:
|
|
return None
|
|
return data
|