2da9493cb5
No-CLI way to get a real shareable magic link (/portal/enter/<token>) for a
project's client. Project page gets a "Copy client link" button next to the
preview; opens a modal that lists active links (with revoke), generates a fresh
one, and copies it to the clipboard.
Backend (operator, internal /projects/*):
- POST /projects/{id}/portal-link -> mint a fresh token, return the full URL
(built from request.base_url so it uses the operator's host).
- GET /projects/{id}/portal-links -> list active links (label/created/last-used).
- POST /projects/{id}/portal-link/{tid}/revoke -> revoke one (scoped to the
project's client).
Refactor: split ensure_project_client() + mint_link_token() out of
provision_preview_session() so minting a shareable link and the preview cookie
share one provisioning path.
Verified: ensure/mint persistence across commits + sessions, minted link resolves,
token stored hashed, second mint = distinct active link (4/4); compiles; share
script balances; detail.html parses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
6.2 KiB
Python
168 lines
6.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
|
|
|
|
|
|
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
|