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:
+53
-2
@@ -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]})
|
||||
|
||||
Reference in New Issue
Block a user