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>
341 lines
15 KiB
Python
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()
|