diff --git a/backend/main.py b/backend/main.py index 54792ad..2f025fd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -66,6 +66,21 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static") # Use shared templates configuration with timezone filters from backend.templates_config import templates +# Client-portal auth: an unauthenticated portal request renders the access page +# (HTML routes) or returns 401 JSON (/portal/api/* routes). Centralized so every +# portal route can simply Depends(get_current_client). +from backend.portal_auth import PortalAuthError + +@app.exception_handler(PortalAuthError) +async def portal_auth_handler(request: Request, exc: PortalAuthError): + if request.url.path.startswith("/portal/api"): + return JSONResponse(status_code=401, content={"detail": "Not authenticated"}) + return templates.TemplateResponse( + "portal/access_required.html", + {"request": request, "reason": "required"}, + status_code=401, + ) + # Add custom context processor to inject environment variable into all templates @app.middleware("http") async def add_environment_to_context(request: Request, call_next): @@ -97,6 +112,10 @@ app.include_router(slmm.router) app.include_router(slm_ui.router) app.include_router(slm_dashboard.router) app.include_router(seismo_dashboard.router) + +# Client portal (read-only, scoped client view) — see docs/CLIENT_PORTAL.md +from backend.routers import portal +app.include_router(portal.router) app.include_router(sfm.router) app.include_router(modem_dashboard.router) diff --git a/backend/portal_auth.py b/backend/portal_auth.py new file mode 100644 index 0000000..02933b0 --- /dev/null +++ b/backend/portal_auth.py @@ -0,0 +1,114 @@ +""" +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 base64 +import hashlib +import logging +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 diff --git a/backend/routers/portal.py b/backend/routers/portal.py new file mode 100644 index 0000000..bc96006 --- /dev/null +++ b/backend/routers/portal.py @@ -0,0 +1,67 @@ +""" +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": []}, + ) diff --git a/templates/portal/access_required.html b/templates/portal/access_required.html new file mode 100644 index 0000000..b6d7423 --- /dev/null +++ b/templates/portal/access_required.html @@ -0,0 +1,20 @@ +{% extends "portal/base.html" %} +{% block title %}Access{% endblock %} +{% block content %} +
The access link is expired or has been revoked. + Please contact TMI for a new link.
+ {% else %} +Open the monitoring link TMI sent you to view your locations.
+ {% endif %} +Live sound levels for your active locations.
+ +{# M1 task 4 fleshes this out into location tiles + a map. #} +{% if locations %} +