fe7cf91488
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated, proxy-reachable session-minting path (and a linked project's open link grants the whole client's scope), so it must be explicitly enabled in dev. - Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was browser-only) and guard a non-dict signed body (was an uncaught AttributeError → 500, reachable if SECRET_KEY is the insecure default). - Escape operator-set strings (location/rule/event names) before innerHTML + Leaflet tooltips — they're client-facing, so a name with markup was stored XSS in the client's browser. Global esc() helper applied at every injection point. - WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history rows now whitelisted like the other scoped endpoints. - Preview-client slug uses the full project id (an 8-char prefix could collide two projects onto one client). Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default off; templates parse; scoped scrubbing intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
"""
|
|
Client-portal auth — the swappable gate (see docs/CLIENT_PORTAL.md).
|
|
|
|
M1-M3 ride on an interim signed "magic URL": an unguessable token in the link
|
|
mints a signed session cookie. Every portal route depends on get_current_client();
|
|
M4 replaces the backing (magic-link / accounts) without touching routes/templates.
|
|
|
|
The cookie carries the ACCESS-TOKEN id (not the client id) and is re-validated
|
|
against the DB on every request, so revoking a link (revoked_at) kills its live
|
|
sessions on the next request — not just future clicks.
|
|
|
|
No new dependency: the cookie is signed with stdlib HMAC-SHA256 over a SECRET_KEY.
|
|
"""
|
|
|
|
import os
|
|
import hmac
|
|
import json
|
|
import time
|
|
import uuid
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime
|
|
|
|
from fastapi import Request, Depends
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import get_db
|
|
from backend.models import Client, ClientAccessToken
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Signing secret for portal session cookies. MUST be set to a real secret in prod
|
|
# (env). The insecure default only exists so dev/test boots without config.
|
|
SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-change-me")
|
|
if SECRET_KEY == "dev-insecure-change-me":
|
|
logger.warning("[PORTAL] SECRET_KEY is the insecure default — set SECRET_KEY in prod.")
|
|
|
|
COOKIE_NAME = "portal_session"
|
|
COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days
|
|
|
|
# Plain, no-token portal links (/portal/open/{project_id}). These are an
|
|
# UNAUTHENTICATED, proxy-reachable session-minting path (and a linked project's
|
|
# open link grants the *whole* client's scope), so they default OFF and must be
|
|
# explicitly enabled — set PORTAL_OPEN_LINKS=true only in a dev/prototype env.
|
|
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "false").lower() in ("1", "true", "yes")
|
|
if PORTAL_OPEN_LINKS:
|
|
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
|
|
"Keep this OFF in any internet-facing / production deployment.")
|
|
|
|
|
|
class PortalAuthError(Exception):
|
|
"""Raised by get_current_client when there's no valid portal session.
|
|
Handled centrally in main.py: HTML routes get the access-required page,
|
|
/portal/api/* routes get a 401 JSON."""
|
|
|
|
|
|
# -- token + cookie primitives ----------------------------------------------
|
|
|
|
def hash_token(raw: str) -> str:
|
|
"""sha256 hex of a raw access-token secret (what we store + look up by)."""
|
|
return hashlib.sha256(raw.encode()).hexdigest()
|
|
|
|
|
|
def _sign(body: str) -> str:
|
|
return hmac.new(SECRET_KEY.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def make_session_cookie(token_id: str) -> str:
|
|
body = base64.urlsafe_b64encode(
|
|
json.dumps({"tid": token_id, "iat": int(time.time())}).encode()
|
|
).decode()
|
|
return f"{body}.{_sign(body)}"
|
|
|
|
|
|
def _read_session_cookie(value: str):
|
|
"""Return the token id from a signed cookie, or None if missing/tampered."""
|
|
try:
|
|
body, sig = value.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()))
|
|
if not isinstance(data, dict):
|
|
return None
|
|
# Server-side expiry: a leaked cookie isn't valid forever (max_age is only a
|
|
# browser hint). iat is set by make_session_cookie.
|
|
iat = data.get("iat")
|
|
if not isinstance(iat, (int, float)) or (time.time() - iat) > COOKIE_MAX_AGE:
|
|
return None
|
|
return data.get("tid")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# -- the dependency every portal route uses ---------------------------------
|
|
|
|
def client_from_cookie(cookie_value, db: Session):
|
|
"""Resolve a Client from a raw session-cookie value, or None. Re-validates the
|
|
access token against the DB each call, so a revoked link / disabled client
|
|
drops immediately. Shared by the HTTP dependency and the WebSocket handler
|
|
(which can't use Request-based Depends)."""
|
|
token_id = _read_session_cookie(cookie_value) if cookie_value else None
|
|
if not token_id:
|
|
return None
|
|
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
|
|
if not tok:
|
|
return None
|
|
return db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
|
|
|
|
|
def get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
|
|
"""Resolve the authenticated client, or raise PortalAuthError."""
|
|
client = client_from_cookie(request.cookies.get(COOKIE_NAME), db)
|
|
if client is None:
|
|
raise PortalAuthError()
|
|
return client
|
|
|
|
|
|
def resolve_token(raw_token: str, db: Session):
|
|
"""Validate a raw magic-URL token. Returns (ClientAccessToken, Client) on
|
|
success, or (None, None). Also stamps last_used_at."""
|
|
tok = db.query(ClientAccessToken).filter_by(
|
|
token_hash=hash_token(raw_token), revoked_at=None
|
|
).first()
|
|
if not tok:
|
|
return None, None
|
|
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
|
if not client:
|
|
return None, None
|
|
tok.last_used_at = datetime.utcnow()
|
|
db.commit()
|
|
return tok, client
|
|
|
|
|
|
def ensure_project_client(project, db) -> Client:
|
|
"""Find or create the Client for a project. Reuses the project's linked client
|
|
if it has one; otherwise creates/uses a per-project 'preview-<id>' client and
|
|
sets project.client_id (only when unset, so it never clobbers a real link)."""
|
|
client = None
|
|
if project.client_id:
|
|
client = db.query(Client).filter_by(id=project.client_id, active=True).first()
|
|
if client is None:
|
|
slug = f"preview-{project.id}" # full id — an 8-char prefix can collide across projects
|
|
client = db.query(Client).filter_by(slug=slug).first()
|
|
if client is None:
|
|
client = Client(id=str(uuid.uuid4()),
|
|
name=(project.client_name or project.name or "Preview"),
|
|
slug=slug, active=True)
|
|
db.add(client)
|
|
db.flush()
|
|
if not project.client_id:
|
|
project.client_id = client.id
|
|
return client
|
|
|
|
|
|
def mint_link_token(client, db, label=None) -> str:
|
|
"""Mint a fresh access token for a client and return the RAW secret (caller
|
|
builds the /portal/enter/<raw> URL and shows it once). Only the hash is stored."""
|
|
raw = secrets.token_urlsafe(32)
|
|
db.add(ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
|
token_hash=hash_token(raw), label=label))
|
|
db.commit()
|
|
return raw
|
|
|
|
|
|
def provision_preview_session(project, db) -> str:
|
|
"""Operator preview shortcut: ensure a Client + access token exist for a project
|
|
and return a token id to seal into a session cookie (no shared link). Reuses an
|
|
existing token so repeat previews don't accumulate clutter; the raw secret is
|
|
discarded (preview rides the cookie)."""
|
|
client = ensure_project_client(project, db)
|
|
tok = db.query(ClientAccessToken).filter_by(client_id=client.id, revoked_at=None).first()
|
|
if tok is None:
|
|
tok = ClientAccessToken(id=str(uuid.uuid4()), client_id=client.id,
|
|
token_hash=hash_token(secrets.token_urlsafe(32)),
|
|
label="preview")
|
|
db.add(tok)
|
|
db.commit()
|
|
return tok.id
|