766f64f35f
- delete dead magic-link helpers (resolve_token, ensure_project_client, mint_link_token, provision_preview_session) + now-unused datetime import - key brute-force lockout on link_token alone (IP term only enabled a source-IP-rotation bypass; behind the proxy all clients share one IP) - drop unused PORTAL_BASE_URL from the retired CLI - add WebSocket ownership tests (unauth + cross-project both close 1008)
194 lines
7.3 KiB
Python
194 lines
7.3 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 fastapi import Request, Depends
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import get_db
|
|
from backend.models import Client, ClientAccessToken, Project
|
|
|
|
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
|
|
# Set COOKIE_SECURE=true once the portal is served over HTTPS (TLS terminates at
|
|
# the Synology reverse proxy). Default false so plain-HTTP dev still works.
|
|
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- Phase-1 per-project password gate -------------------------------------------
|
|
# A portal-enabled project gets its OWN dedicated client (slug "portal-<project.id>")
|
|
# owning exactly that project. The project is linked to it via project.client_id so
|
|
# the existing client-scoped routes (which resolve projects by Project.client_id ==
|
|
# client.id) surface exactly this one project for the portal session — per-project
|
|
# isolation with no route changes. (Phase 1 repurposes project.client_id for this; a
|
|
# real per-client model is the deferred multi-tenant work.)
|
|
|
|
|
|
def portal_client_for_project(project, db) -> Client:
|
|
"""Get-or-create the dedicated 1:1 portal client for a project, and link the
|
|
project to it so the client-scoped routes resolve exactly this project."""
|
|
slug = f"portal-{project.id}"
|
|
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 "Client"),
|
|
slug=slug, active=True)
|
|
db.add(client)
|
|
db.flush()
|
|
if project.client_id != client.id:
|
|
project.client_id = client.id # without this, the client owns no projects
|
|
db.flush()
|
|
return client
|
|
|
|
|
|
def mint_portal_session(project, db) -> str:
|
|
"""Ensure the project's portal client + an access token exist; return the token
|
|
id to seal into a session cookie. Reuses an existing token to avoid clutter."""
|
|
client = portal_client_for_project(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="portal")
|
|
db.add(tok)
|
|
db.commit()
|
|
return tok.id
|
|
|
|
|
|
def resolve_project_by_link_token(link_token: str, db):
|
|
"""Return the portal-enabled Project for a link token, or None."""
|
|
if not link_token:
|
|
return None
|
|
return db.query(Project).filter_by(
|
|
portal_link_token=link_token, portal_enabled=True).first()
|
|
|
|
|
|
# In-memory brute-force lockout, keyed per link_token (the password is shared per
|
|
# project, so per-IP granularity buys nothing and an IP term only lets an attacker
|
|
# reset the budget by rotating source IPs). Resets on restart; adequate for a
|
|
# read-only surface behind the UniFi edge. Single-worker dev; multi-worker would
|
|
# need a shared store.
|
|
MAX_ATTEMPTS = 5
|
|
LOCK_SECONDS = 15 * 60
|
|
_failures: dict = {} # key -> (count, first_failure_epoch)
|
|
|
|
|
|
def is_locked(key: str) -> bool:
|
|
rec = _failures.get(key)
|
|
if not rec:
|
|
return False
|
|
count, first = rec
|
|
if count < MAX_ATTEMPTS:
|
|
return False
|
|
if (time.time() - first) > LOCK_SECONDS:
|
|
_failures.pop(key, None) # window expired
|
|
return False
|
|
return True
|
|
|
|
|
|
def register_failure(key: str) -> None:
|
|
count, first = _failures.get(key, (0, time.time()))
|
|
_failures[key] = (count + 1, first)
|
|
|
|
|
|
def clear_failures(key: str) -> None:
|
|
_failures.pop(key, None)
|