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
+53 -2
View File
@@ -45,6 +45,15 @@ try:
except Exception:
_auth_user = None
try:
from erp.notifications import (
notify_invoice_created, notify_invoice_paid, notify_invoice_cancelled,
)
except Exception:
def notify_invoice_created(*a, **k): return {}
def notify_invoice_paid(*a, **k): return {}
def notify_invoice_cancelled(*a, **k): return {}
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
# === Config ===
@@ -324,6 +333,11 @@ async def ocr_upload(
authorization: Optional[str] = Header(None),
):
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
user = _resolve_user(authorization)
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub (ako je naveden)
if user and not is_pgz_admin(user):
if klub_id and user.get("klub_id") != klub_id:
raise HTTPException(403, "Nemate ovlasti uploadati za ovaj klub")
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
@@ -354,6 +368,18 @@ async def ocr_upload(
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
)
row = cur.fetchone()
# Audit log za OCR upload
try:
with _db() as c:
c.cursor().execute(
"""INSERT INTO pgz_sport.audit_log
(tablica, operacija, record_id, korisnik, promijenjeno_polje, nova_vrijednost)
VALUES ('pgz_sport.invoice_uploads','create',%s,%s,'file_name',%s)""",
(row["id"], (user.get("email") if user else "anon"),
f"{file.filename} ({len(raw)} B, sha={sha256[:12]})"),
)
except Exception:
pass
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
@@ -646,11 +672,17 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
if body.get(k) in (None, ""):
raise HTTPException(400, f"Nedostaje polje: {k}")
user = _resolve_user(authorization)
klub_id = body.get("klub_id")
tenant_id = body.get("tenant_id", 1)
upload_id = body.get("upload_id")
lines = body.get("lines") or []
# Permission: pgz_admin uvijek; klub_admin samo za vlastiti klub
if user and not is_pgz_admin(user):
if not (user.get("user_type") == "klub_admin" and klub_id == user.get("klub_id")):
raise HTTPException(403, "Nemate ovlasti kreirati račun za ovaj klub")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
@@ -715,7 +747,10 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
(inv_id, upload_id),
)
return {"ok": True, "invoice": inv}
audit_invoice(user, inv_id, "create", field="invoice_no",
new=f"{body.get('invoice_no')}{body.get('amount_gross')}")
notif = notify_invoice_created({**body, "id": inv_id, "klub_id": klub_id})
return {"ok": True, "invoice": inv, "notification": notif}
@router.put("/invoices/{invoice_id}")
@@ -813,7 +848,13 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
pay = cur.fetchone()
audit_invoice(user, invoice_id, "pay", field="payment_status",
old=inv.get("payment_status"), new="paid")
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
notif = notify_invoice_paid(
{**inv, **(row or {}), "id": invoice_id},
{"iban_to": iban_to, "iban_from": iban_from, "reference": reference,
"payment_date": paid_date, "amount": amount},
)
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None,
"notification": notif}
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
@@ -868,6 +909,14 @@ def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Hea
)
audit_invoice(user, inv["id"], "bulk_pay",
field="payment_status", old=inv.get("payment_status"), new="paid")
try:
notify_invoice_paid(
{**inv, "paid_date": paid_date},
{"iban_to": iban_to, "iban_from": iban_from, "reference": reference,
"payment_date": paid_date, "amount": inv.get("amount_gross")},
)
except Exception:
pass
results["paid"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
@@ -907,6 +956,8 @@ def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] =
audit_invoice(user, inv["id"], "bulk_cancel",
field="payment_status", old=inv.get("payment_status"),
new=f"cancelled: {razlog}")
try: notify_invoice_cancelled(inv, razlog)
except Exception: pass
results["cancelled"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})