loreal-utilisation-dept/backend/app/auth/local.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

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"}