3fc20e104a
Adds a "View client portal" button on the project detail page that opens the
client portal scoped to that project — no CLI. GET /projects/{id}/portal-preview
auto-provisions a client + access token for the project (provision_preview_session)
and seals a portal session cookie, then redirects to /portal.
- Reuses the project's linked client if it has one; otherwise creates/reuses a
per-project 'preview-<id>' client. Only sets project.client_id when unset, so it
never clobbers a real client link. Idempotent — repeat clicks reuse the same
client/token.
- Lives under /projects (not /portal), so a future public proxy exposing only
/portal/* won't expose this operator shortcut.
Verified: provisioning (unlinked creates+links, idempotent, linked-no-clobber) 7/7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
150 lines
5.4 KiB
Python
150 lines
5.4 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 get_current_client(request: Request, db: Session = Depends(get_db)) -> Client:
|
|
"""Resolve the authenticated client, or raise PortalAuthError.
|
|
|
|
Re-validates the access token on every request so a revoked link / disabled
|
|
client drops the session immediately."""
|
|
cookie = request.cookies.get(COOKIE_NAME)
|
|
token_id = _read_session_cookie(cookie) if cookie else None
|
|
if not token_id:
|
|
raise PortalAuthError()
|
|
tok = db.query(ClientAccessToken).filter_by(id=token_id, revoked_at=None).first()
|
|
if not tok:
|
|
raise PortalAuthError()
|
|
client = db.query(Client).filter_by(id=tok.client_id, active=True).first()
|
|
if not client:
|
|
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 provision_preview_session(project, db) -> str:
|
|
"""Testing convenience (operator-side): ensure a Client + access token exist for
|
|
a project so an operator can preview the client portal without the CLI. Returns
|
|
the token id to seal into a session cookie.
|
|
|
|
Reuses the project's linked client if it has one; otherwise creates/uses a
|
|
per-project 'preview-<id>' client. Only sets project.client_id when it's unset,
|
|
so previewing never clobbers a real client link. The token's raw secret is
|
|
discarded (preview rides the cookie, not a magic 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
|
|
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
|