feat(auth): generic HMAC signed-cookie module for operator auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
@@ -0,0 +1,49 @@
|
||||
# tests/test_operator_cookies.py
|
||||
import time
|
||||
import base64
|
||||
import json
|
||||
from backend.auth_cookies import sign, read
|
||||
|
||||
|
||||
def test_sign_then_read_round_trips():
|
||||
now = int(time.time())
|
||||
raw = sign({"uid": "abc", "iat": now})
|
||||
data = read(raw, max_age=3600)
|
||||
assert data == {"uid": "abc", "iat": now}
|
||||
|
||||
|
||||
def test_tampered_signature_is_rejected():
|
||||
raw = sign({"uid": "abc", "iat": int(time.time())})
|
||||
body, _sig = raw.rsplit(".", 1)
|
||||
assert read(body + ".deadbeef", max_age=3600) is None
|
||||
|
||||
|
||||
def test_tampered_body_is_rejected():
|
||||
raw = sign({"uid": "abc", "iat": int(time.time())})
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
forged = base64.urlsafe_b64encode(json.dumps({"uid": "evil", "iat": int(time.time())}).encode()).decode()
|
||||
assert read(forged + "." + sig, max_age=3600) is None
|
||||
|
||||
|
||||
def test_expired_by_iat_is_rejected():
|
||||
raw = sign({"uid": "abc", "iat": int(time.time()) - 10_000})
|
||||
assert read(raw, max_age=3600) is None
|
||||
|
||||
|
||||
def test_garbage_input_is_none_not_raise():
|
||||
assert read("not-a-cookie", max_age=3600) is None
|
||||
assert read("", max_age=3600) is None
|
||||
assert read(None, max_age=3600) is None
|
||||
|
||||
|
||||
def test_wrong_secret_is_rejected(monkeypatch):
|
||||
import backend.auth_cookies as ac
|
||||
monkeypatch.setattr(ac, "SECRET_KEY", "secret-A")
|
||||
raw = ac.sign({"uid": "x", "iat": int(time.time())})
|
||||
monkeypatch.setattr(ac, "SECRET_KEY", "secret-B")
|
||||
assert ac.read(raw, max_age=3600) is None
|
||||
|
||||
|
||||
def test_future_dated_iat_is_rejected():
|
||||
raw = sign({"uid": "x", "iat": int(time.time()) + 10_000})
|
||||
assert read(raw, max_age=3600) is None
|
||||
Reference in New Issue
Block a user