Replaces a static SPA that shipped an Airtable PAT in the JS bundle.
The new architecture holds all secrets server-side, fronts the app
behind Apache on optical-dev with the shared-vhost split-build pattern,
and is designed for a later Azure AD/MSAL swap-in.
- backend/ FastAPI + uvicorn, local auth (Azure AD stub), Airtable
proxy with TTL cache, Zoho .xlsx/.csv parser, merge
service for utilisation summaries. 28 pytest tests.
- frontend/ React + Vite + TS + Tailwind + Recharts SPA. Login entry
chunk 12.83 KB gzipped; Recharts lazy-loaded. No tokens
or Airtable URLs in the built bundle.
- deploy/ Idempotent deploy.sh (port auto-pick 8200-8299,
.env-persisted) + split-build Apache include template.
- docker-compose.yml pins name: utilisation-dept and binds 127.0.0.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
58 lines
1.6 KiB
Python
58 lines
1.6 KiB
Python
"""Session cookie signing helpers.
|
|
|
|
Decisions:
|
|
- Uses itsdangerous.TimestampSigner so we can enforce max-age server-side
|
|
even if the cookie's own Max-Age is tampered with (defence in depth).
|
|
- The cookie value is "<username>" signed; no JWT, no JSON. Sessions don't
|
|
need to carry data here — the user is single-tenant admin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from itsdangerous import BadSignature, SignatureExpired, TimestampSigner
|
|
from fastapi import Response
|
|
|
|
from app.config import settings
|
|
|
|
|
|
def _signer() -> TimestampSigner:
|
|
return TimestampSigner(settings.SESSION_SECRET, salt="ud-session-v1")
|
|
|
|
|
|
def sign_username(username: str) -> str:
|
|
return _signer().sign(username.encode("utf-8")).decode("utf-8")
|
|
|
|
|
|
def verify_signed(value: str, max_age: int | None = None) -> str | None:
|
|
"""Return username if signature valid and not expired; else None."""
|
|
try:
|
|
max_age = max_age if max_age is not None else settings.SESSION_MAX_AGE
|
|
unsigned = _signer().unsign(value, max_age=max_age)
|
|
return unsigned.decode("utf-8")
|
|
except SignatureExpired:
|
|
return None
|
|
except BadSignature:
|
|
return None
|
|
|
|
|
|
COOKIE_NAME = "ud_session"
|
|
|
|
|
|
def set_session_cookie(response: Response, username: str) -> None:
|
|
signed = sign_username(username)
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=signed,
|
|
max_age=settings.SESSION_MAX_AGE,
|
|
path=settings.cookie_path,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="lax",
|
|
)
|
|
|
|
|
|
def clear_session_cookie(response: Response) -> None:
|
|
response.delete_cookie(
|
|
key=COOKIE_NAME,
|
|
path=settings.cookie_path,
|
|
)
|