CC2 R5: defense-in-depth JWT + invite/reset token flows + audit

#1 JWT middleware:
- pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before
  every /api/admin/* route. Even routes that lack Depends(require_user)
  cannot be reached without a valid Bearer token (verifies signature,
  exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS.

#2 Invitation flow:
- pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at,
  used_at, created_by, ip, meta). Single-use, raw token never persisted.
- POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d),
  marks must_change_pwd, revokes existing sessions, returns invite_link.
- GET  /api/auth/setup-password?token=X — preflight (no consume).
- POST /api/auth/setup-password — consumes token, sets password, sets
  email_verified=true.

#3 Password reset flow:
- POST /api/auth/forgot-password — generic 'ako račun postoji' response;
  issues 'reset' token (TTL 2h) only for active users. Token returned in
  response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1.
- GET  /api/auth/reset-password?token=X — preflight.
- POST /api/auth/reset-password — consumes token, sets new password,
  revokes all active sessions.

#4 Audit coverage (auth events):
- login.ok, login.fail (with reason), login.locked, login.2fa_required,
  login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok,
  password.reset.fail, password.forgot.issue, password.forgot.miss,
  invite.consume.ok, invite.consume.fail, user.invite, user.create,
  user.update, user.delete, user.role.change, user.suspend, user.unsuspend,
  user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable.

#5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user).
   Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
This commit is contained in:
Damir Radulić
2026-05-05 01:28:29 +02:00
parent 8dce58c5f9
commit 0046b8d695
24 changed files with 15419 additions and 72 deletions
+40
View File
@@ -76,6 +76,39 @@ def apply_privacy(rows, admin):
app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ─── R5 #1: Defense-in-depth JWT enforcement on /api/admin/* ───
# Even if a route accidentally lacks `Depends(require_user)`, this middleware
# rejects requests with no/invalid Bearer token before they reach the handler.
@app.middleware("http")
async def require_jwt_on_admin(request, call_next):
p = request.url.path
# Only gate admin endpoints — leave /api/auth/*, public /api/v2/* etc. alone
if p.startswith("/api/admin/") or p == "/api/admin":
# OPTIONS preflight passes through
if request.method == "OPTIONS":
return await call_next(request)
try:
from auth.auth_v2 import decode_token, _is_revoked
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Authentication required"}, status_code=401)
token = auth.split(" ", 1)[1].strip()
try:
payload = decode_token(token)
except Exception:
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Invalid or expired token"}, status_code=401)
if payload.get("typ") not in (None, "access"):
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Wrong token type"}, status_code=401)
if _is_revoked(payload.get("jti", "")):
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Token revoked"}, status_code=401)
except Exception as e:
print(f"[JWT-MW WARN] {e}")
return await call_next(request)
# === URL rewrite middleware - convert direct external image URLs to /img-proxy ===
import json as _json_mw
@@ -1361,6 +1394,13 @@ try:
except Exception as e:
print(f'[CRM/PANEL] clan_panel router fail: {e}')
try:
from crm_extras_router import router as crm_extras_router
app.include_router(crm_extras_router)
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)')
except Exception as e:
print(f'[CRM/R5] extras router fail: {e}')
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
try:
from auth.auth_v2 import router as auth_v2_router