docs: client portal design + milestone plan (M1 live view → M4 full auth) #61
@@ -66,6 +66,21 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
|||||||
# Use shared templates configuration with timezone filters
|
# Use shared templates configuration with timezone filters
|
||||||
from backend.templates_config import templates
|
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
|
# Add custom context processor to inject environment variable into all templates
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def add_environment_to_context(request: Request, call_next):
|
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_ui.router)
|
||||||
app.include_router(slm_dashboard.router)
|
app.include_router(slm_dashboard.router)
|
||||||
app.include_router(seismo_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(sfm.router)
|
||||||
app.include_router(modem_dashboard.router)
|
app.include_router(modem_dashboard.router)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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": []},
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "portal/base.html" %}
|
||||||
|
{% block title %}Access{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto mt-16 text-center">
|
||||||
|
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-slate-800 border border-slate-700 mb-5">
|
||||||
|
<svg class="w-7 h-7 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% if reason == "invalid" %}
|
||||||
|
<h1 class="text-xl font-semibold mb-2">This link isn't valid</h1>
|
||||||
|
<p class="text-gray-400 text-sm">The access link is expired or has been revoked.
|
||||||
|
Please contact TMI for a new link.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="text-xl font-semibold mb-2">Access link required</h1>
|
||||||
|
<p class="text-gray-400 text-sm">Open the monitoring link TMI sent you to view your locations.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: { extend: { colors: { seismo: {
|
||||||
|
orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d'
|
||||||
|
} } } }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
||||||
|
<meta name="theme-color" content="#142a66">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-slate-900 text-gray-100 antialiased">
|
||||||
|
<header class="border-b border-slate-700/70 bg-slate-800/60 backdrop-blur">
|
||||||
|
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<a href="/portal" class="flex items-center gap-2 font-semibold">
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full bg-seismo-orange"></span>
|
||||||
|
TMI Monitoring{% if client %} <span class="text-gray-500 font-normal">·</span>
|
||||||
|
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% if client %}
|
||||||
|
<a href="/portal/logout" class="text-sm text-gray-400 hover:text-gray-200">Sign out</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-4 py-6">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="max-w-5xl mx-auto px-4 py-8 text-xs text-gray-600">
|
||||||
|
Read-only monitoring view. Data is provided as-is for informational purposes.
|
||||||
|
</footer>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "portal/base.html" %}
|
||||||
|
{% block title %}Your locations{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-2xl font-semibold mb-1">Your monitoring locations</h1>
|
||||||
|
<p class="text-gray-400 text-sm mb-6">Live sound levels for your active locations.</p>
|
||||||
|
|
||||||
|
{# M1 task 4 fleshes this out into location tiles + a map. #}
|
||||||
|
{% if locations %}
|
||||||
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for loc in locations %}
|
||||||
|
<a href="/portal/location/{{ loc.id }}" class="block rounded-xl border border-slate-700 bg-slate-800/50 p-4 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-semibold">{{ loc.name }}</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">{{ loc.address or loc.project_name }}</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
|
||||||
|
No active monitoring locations yet.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user