6c048a9c30
backend/portal_auth.py: stdlib HMAC-signed session cookie carrying the access-
token id (re-validated against the DB each request, so revoke kills live
sessions), hash_token, resolve_token, and the get_current_client dependency
(raises PortalAuthError). SECRET_KEY env (insecure dev default + warning).
routers/portal.py: /portal/enter/{token} mints the cookie -> /portal; /logout;
/access; /portal home stub. main.py registers the router + a PortalAuthError
handler (HTML access page for pages, 401 JSON for /portal/api/*).
Portal shell templates (base, access_required, overview stub), branded dark.
Verified: cookie round-trip + tamper/garbage rejection, token resolution
(valid/bad), get_current_client (valid/no-cookie/revoked) — 8/8 against a temp DB.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
2.2 KiB
Python
68 lines
2.2 KiB
Python
"""
|
|
Client portal — read-only, scoped client view (see docs/CLIENT_PORTAL.md).
|
|
|
|
M1: a client opens a magic URL (/portal/enter/{token}) which mints a signed
|
|
session cookie, then sees their locations (overview) and per-location read-only
|
|
live data sourced from SLMM's cache. Every data route re-checks ownership.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Request, Depends
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import get_db
|
|
from backend.models import Client
|
|
from backend.templates_config import templates
|
|
from backend.portal_auth import (
|
|
get_current_client, make_session_cookie, resolve_token,
|
|
COOKIE_NAME, COOKIE_MAX_AGE,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/portal", tags=["portal"])
|
|
|
|
|
|
@router.get("/enter/{token}")
|
|
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
|
|
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
|
|
tok, client = resolve_token(token, db)
|
|
if not client:
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html",
|
|
{"request": request, "reason": "invalid"},
|
|
status_code=403,
|
|
)
|
|
resp = RedirectResponse(url="/portal", status_code=303)
|
|
resp.set_cookie(
|
|
COOKIE_NAME, make_session_cookie(tok.id),
|
|
max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax",
|
|
)
|
|
logger.info(f"[PORTAL] {client.slug}: session opened via token {tok.id[:8]}")
|
|
return resp
|
|
|
|
|
|
@router.get("/logout")
|
|
def portal_logout():
|
|
resp = RedirectResponse(url="/portal/access", status_code=303)
|
|
resp.delete_cookie(COOKIE_NAME)
|
|
return resp
|
|
|
|
|
|
@router.get("/access")
|
|
def portal_access(request: Request):
|
|
"""Landing for an unauthenticated visitor (no valid link)."""
|
|
return templates.TemplateResponse(
|
|
"portal/access_required.html", {"request": request, "reason": "required"}
|
|
)
|
|
|
|
|
|
@router.get("")
|
|
def portal_home(request: Request, client: Client = Depends(get_current_client)):
|
|
"""Client overview. (M1 task 4 fills in the scoped location list + map.)"""
|
|
return templates.TemplateResponse(
|
|
"portal/overview.html",
|
|
{"request": request, "client": client, "locations": []},
|
|
)
|