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>
51 lines
1.7 KiB
Python
51 lines
1.7 KiB
Python
"""Local auth — bcrypt-verified admin credential plus session-cookie check.
|
|
|
|
Decisions:
|
|
- passlib's bcrypt backend is used so we don't depend on the (deprecated)
|
|
bcrypt package interface details directly.
|
|
- DEV_AUTH_BYPASS short-circuits to a synthetic user; main.py logs a
|
|
WARNING at startup if it's enabled.
|
|
- verify_password and verify_session are both safe to call when
|
|
ADMIN_PASSWORD_BCRYPT is empty — they just return False / 401.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, Request, status
|
|
from passlib.context import CryptContext
|
|
|
|
from app.auth.session import COOKIE_NAME, verify_signed
|
|
from app.config import settings
|
|
|
|
|
|
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
|
|
|
|
def verify_password(username: str, password: str) -> bool:
|
|
if not settings.ADMIN_PASSWORD_BCRYPT:
|
|
return False
|
|
if username != settings.ADMIN_USERNAME:
|
|
return False
|
|
try:
|
|
return _pwd_context.verify(password, settings.ADMIN_PASSWORD_BCRYPT)
|
|
except Exception:
|
|
# passlib raises on malformed hashes — treat as auth failure.
|
|
return False
|
|
|
|
|
|
def verify_session(request: Request) -> dict[str, Any]:
|
|
"""FastAPI dependency: returns {"username": ..., "mode": ...} or 401."""
|
|
if settings.DEV_AUTH_BYPASS:
|
|
return {"username": "dev", "mode": "bypass"}
|
|
|
|
cookie = request.cookies.get(COOKIE_NAME)
|
|
if not cookie:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
|
|
username = verify_signed(cookie)
|
|
if not username:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
|
|
|
|
return {"username": username, "mode": "local"}
|