CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit

#1 JWT middleware extended:
- Was: /api/admin/* only
- Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT
- Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password,
  /password/reset, /reset-password, /setup-password, /google;
  /api/gdpr/consent; any path ending /avatar
- 14 mutating endpoints verified to return 401 without token

#2 Avatar upload demo mode (routers/clan_panel_router.py):
- Anonymous → returns {demo_mode:true, slika_url:null,
  message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'},
  no FS write, no DB write
- Authenticated (valid JWT, allowed role) → real save as before
- Auth check now uses auth.auth_v2.decode_token (proper secret + revocation)
  instead of the broken local _resolve_role

#3 Mock mailer (auth/mailer.py):
- send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl
- send_password_reset, send_invite helpers with HR text + HTML alt
- Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default)
- forgot-password and admin invite both call mailer; audit logs mail status

#5 Rate limiting on /api/auth/login:
- Per-user: 5 wrong attempts → 5-minute DB-backed lockout
  (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES.
- Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429
  Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful
  login clears the IP counter.
- Failed attempts respond '(N/5) — račun je zaključan na 5 minuta'
- New audit actions: login.ratelimit.ip; login.fail meta now
  includes fails count, locked, lock_minutes

#4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14
   mutating endpoints, public path whitelist, demo-mode avatar +
   real save, forgot-password e-mail to mailbox, no-leak unknown email,
   5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
Damir Radulić
2026-05-05 01:42:53 +02:00
parent 3a79965899
commit f9ebcddf28
38 changed files with 24709 additions and 92 deletions
+50 -31
View File
@@ -76,37 +76,55 @@ 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.
# ─── R5 #1 + R6 #1: Defense-in-depth JWT enforcement ───
# Mutating requests (POST/PUT/PATCH/DELETE) under /api/* require a valid
# Bearer JWT, except for explicitly-public auth & consent endpoints.
# All /api/admin/* requests (any method) also require auth.
_PUBLIC_MUTATING_PATHS = {
"/api/auth/login", "/api/auth/refresh", "/api/auth/forgot-password",
"/api/auth/password/reset", "/api/auth/reset-password",
"/api/auth/setup-password", "/api/auth/google",
"/api/gdpr/consent",
}
_PUBLIC_MUTATING_SUFFIXES = (
"/avatar", # /api/crm/clanovi/{id}/avatar — demo mode handled in handler
)
@app.middleware("http")
async def require_jwt_on_admin(request, call_next):
async def require_jwt_middleware(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}")
method = request.method.upper()
if method == "OPTIONS":
return await call_next(request)
admin_gate = p.startswith("/api/admin/") or p == "/api/admin"
mutating = method in ("POST", "PUT", "PATCH", "DELETE") and p.startswith("/api/")
if mutating and (p in _PUBLIC_MUTATING_PATHS or
any(p.endswith(s) for s in _PUBLIC_MUTATING_SUFFIXES)):
mutating = False
if not (admin_gate or mutating):
return await call_next(request)
try:
from auth.auth_v2 import decode_token, _is_revoked
except Exception as e:
print(f"[JWT-MW import WARN] {e}")
return await call_next(request)
from starlette.responses import JSONResponse as _JR
auth_h = request.headers.get("authorization", "")
if not auth_h.lower().startswith("bearer "):
return _JR({"detail": "Authentication required"}, status_code=401)
token = auth_h.split(" ", 1)[1].strip()
try:
payload = decode_token(token)
except Exception:
return _JR({"detail": "Invalid or expired token"}, status_code=401)
if payload.get("typ") not in (None, "access"):
return _JR({"detail": "Wrong token type"}, status_code=401)
if _is_revoked(payload.get("jti", "")):
return _JR({"detail": "Token revoked"}, status_code=401)
return await call_next(request)
@@ -1395,9 +1413,10 @@ except Exception as e:
print(f'[CRM/PANEL] clan_panel router fail: {e}')
try:
from crm_extras_router import router as crm_extras_router
from crm_extras_router import router as crm_extras_router, alias_router as crm_extras_alias_router
app.include_router(crm_extras_router)
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)')
app.include_router(crm_extras_alias_router)
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications + ZIP + email tpl + /me)')
except Exception as e:
print(f'[CRM/R5] extras router fail: {e}')