bececafe78
Adds a frictionless shareable link so anyone can open a project's client portal
during dev without minting/copying a magic token. GET /portal/open/{project_id}
(gated by PORTAL_OPEN_LINKS) provisions the client session and lands on /portal;
lives under /portal so it works through a proxy exposing only /portal/*.
The project page's "Copy client link" modal now leads with this Quick share link
(amber, host taken from window.location.origin so it always matches the host you
copied it from — no more LAN-vs-public foot-gun). The token-based generate/list/
revoke stays below for the eventual secure path.
PORTAL_OPEN_LINKS defaults ON for the prototype (whole app is open anyway) and logs
a warning; set =false before real clients. The get_current_client seam is
untouched, so M4 auth still layers in front of the same routes regardless.
Verified: compiles, share script balances, detail.html parses, flag default
on / =false off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
6.8 KiB
Python
177 lines
6.8 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
|
|
|
|
# Dev convenience: plain, no-token portal links (/portal/open/{project_id}) so
|
|
# anyone can open a client portal for feedback without minting a magic link.
|
|
# Defaults ON for the current prototype (the whole app is open anyway); set
|
|
# PORTAL_OPEN_LINKS=false before real clients are on the portal.
|
|
PORTAL_OPEN_LINKS = os.getenv("PORTAL_OPEN_LINKS", "true").lower() in ("1", "true", "yes")
|
|
if PORTAL_OPEN_LINKS:
|
|
logger.warning("[PORTAL] open links ENABLED — no-token /portal/open/* shareable links. "
|
|
"Set PORTAL_OPEN_LINKS=false before real clients.")
|
|
|
|
|
|
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()))
|
|
except Exception:
|
|
return None
|
|
return data.get("tid")
|
|
|
|
|
|
# -- 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-{str(project.id)[:8]}"
|
|
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
|