Add secure username/password login portal

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>
This commit is contained in:
2026-06-25 16:55:54 -05:00
parent a02f7ec511
commit 20afb0e565
18 changed files with 1470 additions and 99 deletions

186
server/auth.py Normal file
View File

@@ -0,0 +1,186 @@
"""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()