Gate the suite behind a self-contained login (no external IdP): - User model with bcrypt-hashed passwords; admin/user roles - /api/auth endpoints: login, logout, me, change-password, and admin-only user management (list/create/delete/reset/enable) - Stateless JWT session in an HttpOnly, SameSite=Lax, auto-Secure cookie; middleware refuses every /api data route without a session - login.html + auth-guard.js: login page and per-page guard with a top-right "name / Admin / Sign out" pill - Admin Console now gated on admin role (passphrase gate removed) with a User administration card - manage_users.py CLI to bootstrap the first admin - Rebuilt help.js into a searchable, multi-topic help center - Local-dev convenience: app serves html/ so the site + API share one origin under uvicorn (inactive in the prod container) - Docs/env: AUTH_SECRET_KEY, requirements (bcrypt, PyJWT), README Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
7.3 KiB
Python
187 lines
7.3 KiB
Python
"""Authentication for the Work Package Suite.
|
|
|
|
A self-contained username/password login. Passwords are stored only as bcrypt
|
|
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
|
|
(`wp_session`). Because the token is signed and self-validating, there is no
|
|
server-side session store — every request is checked by verifying the cookie's
|
|
signature and expiry (see `auth_gate` and `get_current_user`).
|
|
|
|
Security model:
|
|
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data
|
|
route is refused with 401 unless a valid session cookie is present.
|
|
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
|
|
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
|
|
HTTPS (detected via X-Forwarded-Proto behind NGINX).
|
|
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
|
|
set; if it is missing we fall back to a random per-process key (which logs a
|
|
warning and invalidates every session on restart) so dev still works.
|
|
|
|
Roles: 'admin' (may manage users) and 'user'.
|
|
"""
|
|
import os
|
|
import secrets
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
import bcrypt
|
|
import jwt
|
|
from fastapi import Depends, HTTPException, Request, Response, status
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .db import get_db
|
|
from . import models
|
|
|
|
log = logging.getLogger("wpsuite.auth")
|
|
|
|
COOKIE_NAME = "wp_session"
|
|
JWT_ALG = "HS256"
|
|
# How long a login lasts before the user must sign in again.
|
|
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
|
|
|
# Paths under /api that do NOT require a session (login itself, health, docs).
|
|
_EXEMPT_PREFIXES = ("/api/auth/",)
|
|
_EXEMPT_EXACT = {
|
|
"/api/health",
|
|
"/api/docs",
|
|
"/api/openapi.json",
|
|
"/api/docs/oauth2-redirect",
|
|
"/api/redoc",
|
|
}
|
|
|
|
|
|
def _load_secret() -> str:
|
|
s = os.getenv("AUTH_SECRET_KEY")
|
|
if s:
|
|
return s
|
|
# No secret configured: generate an ephemeral one so the app still runs in
|
|
# dev. Sessions won't survive a restart, and this is unsafe across multiple
|
|
# workers — production must set AUTH_SECRET_KEY.
|
|
log.warning(
|
|
"AUTH_SECRET_KEY is not set — using a random ephemeral key. "
|
|
"Logins will reset on restart and break across multiple workers. "
|
|
"Set AUTH_SECRET_KEY in the environment for production."
|
|
)
|
|
return secrets.token_urlsafe(48)
|
|
|
|
|
|
SECRET_KEY = _load_secret()
|
|
|
|
|
|
# ── password hashing ──────────────────────────────────────────────────────────
|
|
def hash_password(plain: str) -> str:
|
|
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
|
|
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
|
|
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
|
|
|
|
|
|
def verify_password(plain: str, hashed: str) -> bool:
|
|
if not hashed:
|
|
return False
|
|
try:
|
|
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
# ── tokens ──────────────────────────────────────────────────────────────────
|
|
def create_token(user: "models.User") -> str:
|
|
now = datetime.now(timezone.utc)
|
|
payload = {
|
|
"sub": user.id,
|
|
"username": user.username,
|
|
"role": user.role,
|
|
"iat": now,
|
|
"exp": now + timedelta(hours=SESSION_HOURS),
|
|
}
|
|
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
|
|
|
|
|
|
def decode_token(token: str) -> Optional[dict]:
|
|
"""Return the token claims if the signature and expiry are valid, else None."""
|
|
try:
|
|
return jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
|
|
except jwt.PyJWTError:
|
|
return None
|
|
|
|
|
|
# ── cookie helpers ────────────────────────────────────────────────────────────
|
|
def _is_https(request: Request) -> bool:
|
|
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,
|
|
# so trust X-Forwarded-Proto (set in nginx-wp-suite.conf) when present.
|
|
xfp = request.headers.get("x-forwarded-proto", "")
|
|
if xfp:
|
|
return xfp.split(",")[0].strip().lower() == "https"
|
|
return request.url.scheme == "https"
|
|
|
|
|
|
def set_session_cookie(response: Response, request: Request, token: str) -> None:
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=token,
|
|
max_age=SESSION_HOURS * 3600,
|
|
httponly=True,
|
|
secure=_is_https(request),
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
|
|
|
|
def clear_session_cookie(response: Response) -> None:
|
|
response.delete_cookie(COOKIE_NAME, path="/")
|
|
|
|
|
|
# ── request gate (used as middleware in app.py) ─────────────────────────────────
|
|
def _needs_auth(path: str) -> bool:
|
|
if not path.startswith("/api/"):
|
|
return False # static assets are served by NGINX, not this app
|
|
if path in _EXEMPT_EXACT:
|
|
return False
|
|
return not any(path.startswith(p) for p in _EXEMPT_PREFIXES)
|
|
|
|
|
|
def is_request_authenticated(request: Request) -> Optional[dict]:
|
|
"""Validate the session cookie on a raw request. Returns claims or None.
|
|
Used by the middleware gate, which has no dependency-injection context."""
|
|
token = request.cookies.get(COOKIE_NAME)
|
|
if not token:
|
|
return None
|
|
return decode_token(token)
|
|
|
|
|
|
# ── dependencies (used inside route handlers) ───────────────────────────────────
|
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models.User":
|
|
"""Resolve the logged-in user from the session cookie, or raise 401.
|
|
|
|
Unlike the middleware gate (which only checks the token signature), this also
|
|
confirms the account still exists and is active — so disabling a user takes
|
|
effect on their next request."""
|
|
claims = is_request_authenticated(request)
|
|
if not claims:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
|
user = db.get(models.User, claims.get("sub"))
|
|
if not user or not user.is_active:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account is inactive")
|
|
return user
|
|
|
|
|
|
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
|
|
if user.role != "admin":
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
|
return user
|
|
|
|
|
|
# ── account helpers (shared by routes and the CLI) ──────────────────────────────
|
|
def find_user(db: Session, username: str) -> Optional["models.User"]:
|
|
"""Look up by username, case-insensitively (also matches on email)."""
|
|
uname = (username or "").strip().lower()
|
|
if not uname:
|
|
return None
|
|
return db.scalars(
|
|
select(models.User).where(
|
|
(func.lower(models.User.username) == uname)
|
|
| (func.lower(models.User.email) == uname)
|
|
)
|
|
).first()
|