Acts on the site comments from 8/3 plus the follow-ups. Foundation work first — four of the comments all needed the project team to resolve to real user accounts. Permissions vs project role (new) - User.role is now the PERMISSIONS role: admin | project_admin | project_user. project_admin may delete work packages, change a SOP after it is complete, and delete a project; project_user may not (archiving a WP is still open to them). Enforced by require_project_admin() server-side; the UI only hides dead ends. - New User.project_role holds the person's JOB FUNCTION on the project. It grants nothing — it feeds the SOP team pickers and notification routing. - Admin console shows both columns and explains the difference. Migration rewrites the legacy role 'user' to 'project_user'. - Deleting a project was previously open to any member and unaudited; it now needs project_admin and writes an audit event. ProjectData.remove no longer drops the project from the local cache when the server refuses. SOP project team from user accounts - PM/APM/CM/QM and additional team members are pickers over the project's members, storing the account id next to the display name. A name from an older SOP with no matching account is kept and flagged rather than dropped. - The WP Creator lists the SOP team first in the Owner picker, and a new package defaults to whoever is creating it. Critical constraints - SOP constraints carry a Critical flag; buildConstraints() now copies the whole definition through to the package (it previously reduced them to names, losing description too), and critical rows are marked in the WP form. The email on reopen-after-release is wave 3. Password reset by email - login.html gains Forgot password and a set-a-new-password view, offered only when the server reports email is actually configured. - Single-use signed token (AUTH_RESET_MINUTES, default 60) bound to token_version, sent immediately rather than through the notifications outbox so a reset link is never persisted. Identical response for unknown accounts; per-account send cooldown; a completed reset clears any login lockout. - Session and reset tokens are no longer interchangeable. BIM kill-switch - New admin Features card with bim_enabled, OFF by default. The SOP creator hides the BIM section and the Creator treats every package as install-only while it is off; a SOP that already has BIM keeps its data untouched. Verified with two throwaway-database test scripts: 44 checks on the permissions matrix and token handling, 22 on the reset flow end-to-end against a local SMTP sink (real message captured, link extracted and used). Front-end files parse-checked in headless Chrome. Not yet exercised in a browser against a real login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
13 KiB
Python
312 lines
13 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_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.
|
|
|
|
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_ADMIN = "project_admin"
|
|
ROLE_PROJECT_USER = "project_user"
|
|
ROLES = (ROLE_ADMIN, ROLE_PROJECT_ADMIN, ROLE_PROJECT_USER)
|
|
ROLE_LABELS = {
|
|
ROLE_ADMIN: "Administrator",
|
|
ROLE_PROJECT_ADMIN: "Project Admin",
|
|
ROLE_PROJECT_USER: "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 app admins and project admins — the two roles allowed to delete
|
|
work packages and change a completed SOP."""
|
|
return normalize_role(user.role) in (ROLE_ADMIN, ROLE_PROJECT_ADMIN)
|
|
|
|
# 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()
|