Productionize WP Suite: auth, security hardening, sync, dashboard, PWA, email
Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,7 +30,7 @@ from fastapi import Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import get_db
|
||||
from .db import get_db, DATABASE_URL
|
||||
from . import models
|
||||
|
||||
log = logging.getLogger("wpsuite.auth")
|
||||
@@ -40,6 +40,29 @@ JWT_ALG = "HS256"
|
||||
# How long a login lasts before the user must sign in again.
|
||||
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
|
||||
|
||||
# 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 = {
|
||||
@@ -55,13 +78,24 @@ 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.
|
||||
# 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. "
|
||||
"Logins will reset on restart and break across multiple workers. "
|
||||
"Set AUTH_SECRET_KEY in the environment for production."
|
||||
"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)
|
||||
|
||||
@@ -92,6 +126,7 @@ def create_token(user: "models.User") -> str:
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"ver": user.token_version or 0,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=SESSION_HOURS),
|
||||
}
|
||||
@@ -163,6 +198,10 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> "models
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user