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:
+50
-31
@@ -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}')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user