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
+46 -14
View File
@@ -442,14 +442,10 @@ def update_clan(cid: int, patch: ClanPatch,
async def upload_avatar(cid: int, file: UploadFile = File(...),
authorization: Optional[str] = Header(None),
x_role: Optional[str] = Header(None)):
role = (x_role or _resolve_role(authorization) or "viewer").lower()
if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"):
# sportas/klub_admin/savez_admin/pgz_admin/super_admin svi smiju
# (sportas ako je 'sebe' — UI to validira preko user_id, ovdje server
# primarno gata po roli; future M1 JWT propagacija će validirati clan_id == self)
raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara")
# validate file type
"""Upload avatar. R6 #2 demo mode: if there is no/invalid token,
accept upload but DO NOT persist (FS or DB) — return demo flag + mock URL.
Real save (FS + DB) requires a valid Bearer JWT for an authorized role."""
# validate file type early — applies to both demo and real
allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ext_map = {"image/jpeg": "jpg", "image/png": "png",
"image/webp": "webp", "image/gif": "gif"}
@@ -457,6 +453,47 @@ async def upload_avatar(cid: int, file: UploadFile = File(...),
if ct not in allowed_ct:
raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif")
contents = await file.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(413, "Slika prevelika (max 5 MB)")
# Try to resolve role from JWT (via auth_v2 — proper secret + revocation check)
resolved_role = ""
has_valid_auth = False
if authorization and authorization.lower().startswith("bearer "):
tok = authorization.split(" ", 1)[1].strip()
try:
import sys as _s; _s.path.insert(0, '/opt/pgz-sport')
from auth.auth_v2 import decode_token as _dt, _is_revoked as _rev
payload = _dt(tok)
if payload.get("typ") in (None, "access") and not _rev(payload.get("jti","")):
resolved_role = (payload.get("role") or "").lower()
has_valid_auth = True
except Exception:
has_valid_auth = False
role = (x_role or resolved_role or "").lower()
# ───── DEMO MODE: no/invalid token → mock storage ─────
if not has_valid_auth:
import hashlib as _h
digest = _h.sha256(contents).hexdigest()[:12]
mock_fname = f"demo-{cid}-{digest}.{ext_map[ct]}"
return {
"ok": True,
"id": cid,
"demo_mode": True,
"message": "Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.",
"slika_url": None,
"mock_filename": mock_fname,
"size_bytes": len(contents),
"content_type": ct,
"sha256": digest,
}
# ───── REAL SAVE: valid auth + role check ─────
if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"):
raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara")
# provjeri da član postoji
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,))
@@ -464,20 +501,14 @@ async def upload_avatar(cid: int, file: UploadFile = File(...),
if not r:
raise HTTPException(404, "Član ne postoji")
# save file
fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}"
fpath = UPLOADS_DIR / fname
contents = await file.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(413, "Slika prevelika (max 5 MB)")
with open(fpath, "wb") as fh:
fh.write(contents)
public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}"
# update DB
with _conn() as conn, conn.cursor() as cur:
# obriši staru sliku (best-effort, samo ako je u uploads/avatars/)
old = r["slika_url"]
if old and PUBLIC_AVATAR_PREFIX in old:
try:
@@ -493,6 +524,7 @@ async def upload_avatar(cid: int, file: UploadFile = File(...),
return {
"ok": True,
"id": cid,
"demo_mode": False,
"slika_url": public_url,
"size_bytes": len(contents),
"content_type": ct,