Files
Project-SDE-WP-Suite/server/auth.py
n.siegfried 4ace2afb1c Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.

server/auth.py, server/app.py
  New permissions role `project_super_user`, between admin and project_admin:
  everything a project admin may do, plus user administration SCOPED to the
  projects they hold the role on. Four limits make it safe to hand out, all
  enforced server-side:

    * Scope comes from projects, not the job title. It resolves per membership
      (managed_project_ids), so an ordinary account can hold it on one job via
      ProjectMember.role, and a super user demoted on one job administers
      nobody there. No projects, no authority.
    * Account-level changes (password, disable, rename, permissions, delete)
      require EXCLUSIVE scope: refused when the target is also on a project the
      caller does not administer, because those changes are global. The
      directory renders such rows read-only with the reason.
    * No admin or super-user targets, and neither role can be granted by a
      super user -- that is the line that stops it becoming app-wide control.
    * PUT .../projects rebuilds only the caller's own slice; memberships on
      projects they do not administer are left untouched. A payload that simply
      omits them must not cut someone off a job the caller cannot see.

  Creating requires naming at least one of your own projects: an account with
  none would be one the creator instantly cannot manage.

  /api/auth/users is now scoped rather than admin-only, and carries a per-row
  `manageable` verdict plus the reason. Non-managers get a contact card only --
  a project user has no business reading colleagues' login history. New
  /api/auth/user-scope tells the page what it may offer. Administrative
  password resets are now audited; they were the one account change that left
  no trace. Settings, feature flags and the auto-add rule stay admin-only.

  While here: one definition of "is a user manager", derived from the managed
  set. An account-role-only version disagreed with the scoped one and locked
  per-project super users out of routes they were entitled to.

html/users.html, html/users.js
  The directory: three renderings from one page -- admin (everything), super
  user (controls per row, read-only where scope is shared), everyone else (a
  read-only directory of the people on their own projects).

html/console.css, html/console-util.js
  Extracted from admin.html/admin.js so both console pages share them. A
  divergent jsq() is an XSS and a divergent role list offers permissions the
  server refuses, so neither may exist twice.

html/wp-sidenav.{js,css}
  Global nav drawer, role-gated, carrying ?project= across links. Mounted on
  the field view (which had no way to anywhere) plus both console pages.

No migration: users.role is already String(20) and the new value fits.

Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.

server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:36:14 -07:00

341 lines
15 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.
Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing:
• admin application administrator: user administration, app settings,
and implicit access to every project.
• project_super_user
everything a project_admin may do, plus USER ADMINISTRATION
scoped to the projects they hold the role on: they create and
manage the accounts on their own jobs without an app admin
having to do it for them. They cannot reach app settings, and
they cannot create or alter an admin / super-user account.
• project_admin within their assigned projects: may delete work packages,
modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP.
The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
"""
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, DATABASE_URL
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"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
ROLE_PROJECT_SUPER = "project_super_user"
ROLE_PROJECT_ADMIN = "project_admin"
ROLE_PROJECT_USER = "project_user"
# Ordered most- to least-privileged; the console renders dropdowns in this order.
ROLES = (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
ROLE_LABELS = {
ROLE_ADMIN: "Administrator",
ROLE_PROJECT_SUPER: "Project Super User",
ROLE_PROJECT_ADMIN: "Project Admin",
ROLE_PROJECT_USER: "Project User",
}
# Roles that may be held ON A SINGLE PROJECT via ProjectMember.role, so someone can
# run the users on one job and be an ordinary member of the next. '' means "inherit
# the account's own role" and is always allowed alongside these.
PROJECT_SCOPED_ROLES = (ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
# Job functions offered in the admin console. Free text underneath, so a project
# can use a title that isn't on this list.
PROJECT_ROLES = (
"Project Manager", "Assistant Project Manager", "Construction Manager",
"Quality Manager", "Superintendent", "General Foreman", "Foreman",
"Planner / Scheduler", "BIM / VDC Coordinator", "Engineer",
"Safety (HSE)", "Warehouse / Materials", "Commissioning", "Field Technician",
)
def normalize_role(role: Optional[str]) -> str:
"""Map a stored/incoming role onto the current vocabulary.
Accounts created before permissions roles existed carry the legacy value
'user', which means exactly what 'project_user' means now."""
r = (role or "").strip()
if r == "user":
return ROLE_PROJECT_USER
return r if r in ROLES else ROLE_PROJECT_USER
def is_admin(user: "models.User") -> bool:
return normalize_role(user.role) == ROLE_ADMIN
def is_project_admin(user: "models.User") -> bool:
"""True for the roles allowed to delete work packages and change a completed
SOP. A super user is a project admin with user administration on top, so it is
included here — never enumerate the two roles by hand."""
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_SUPER, ROLE_PROJECT_ADMIN)
# NOTE: "may this account administer users?" is deliberately NOT answered here. The
# super-user role can be held per project (ProjectMember.role), so the question needs
# membership rows to answer and lives in app.py — `is_user_manager` /
# `require_user_manager` / `managed_project_ids`. An account-role-only version of the
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# 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 key configured. In production (a real database is configured via
# POSTGRES_* / DATABASE_URL) this is FATAL — refuse to start rather than sign
# sessions with a throwaway key that silently rotates on every restart. In
# local dev (SQLite, no DB env) fall back to an ephemeral key so the app still
# runs zero-config.
# "Prod" = a real (non-SQLite) database is in use — matches exactly the
# condition db.py uses to pick Postgres, so we don't wrongly block a
# zero-config SQLite dev run just because a stray POSTGRES_USER is exported.
is_prod = not str(DATABASE_URL).startswith("sqlite")
if is_prod:
raise RuntimeError(
"AUTH_SECRET_KEY is not set. Refusing to start in production with an "
"ephemeral signing key — set a strong fixed AUTH_SECRET_KEY "
"(see server/.env.example / DEPLOYMENT.md)."
)
log.warning(
"AUTH_SECRET_KEY is not set — using a random ephemeral key for local dev. "
"Logins reset on restart. Set AUTH_SECRET_KEY for anything non-dev."
)
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,
"ver": user.token_version or 0,
"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.
Session cookies only — a token of any other type is rejected."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
# A password-reset token must never be usable as a session cookie.
if claims.get("typ"):
return None
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── 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")
# Session revocation: a mismatch means the token was invalidated (e.g. the
# password was changed after this token was issued).
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
return user
def require_admin(user: "models.User" = Depends(get_current_user)) -> "models.User":
if not is_admin(user):
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()