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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user