loreal-utilisation-dept/backend/app/auth/session.py
DJP 04edbfdd2c Initial commit: dockerised FastAPI backend + React/Vite frontend rewrite
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>
2026-05-16 12:37:04 -04:00

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,
)