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:
+52
-4
@@ -36,6 +36,16 @@ try:
|
||||
except Exception:
|
||||
_auth_user = None
|
||||
|
||||
try:
|
||||
from erp.notifications import (
|
||||
notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid,
|
||||
)
|
||||
except Exception:
|
||||
def notify_pn_submitted(*a, **k): return {}
|
||||
def notify_pn_approved(*a, **k): return {}
|
||||
def notify_pn_rejected(*a, **k): return {}
|
||||
def notify_pn_paid(*a, **k): return {}
|
||||
|
||||
ADMIN_TOKEN = "admin-pgz-2026"
|
||||
|
||||
def _resolve_user(authorization):
|
||||
@@ -361,7 +371,8 @@ def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(Non
|
||||
WHERE id=%s RETURNING id, status""", (nalog_id,))
|
||||
row = cur.fetchone()
|
||||
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
notif = notify_pn_submitted({**pn, "status": "poslan"})
|
||||
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/odbij")
|
||||
@@ -389,7 +400,8 @@ def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
row = cur.fetchone()
|
||||
audit_putni(user, nalog_id, "reject", field="status",
|
||||
old=pn.get("status"), new=f"odbijen: {razlog}")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog)
|
||||
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/isplati")
|
||||
@@ -437,7 +449,13 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
pay = cur.fetchone()
|
||||
audit_putni(user, nalog_id, "pay", field="status",
|
||||
old=pn.get("status"), new="isplacen")
|
||||
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
|
||||
notif = notify_pn_paid(
|
||||
{**pn, **(row or {}), "id": nalog_id},
|
||||
{"iban_to": iban_to, "iban_from": iban_from, "amount": amount,
|
||||
"reference": reference, "payment_date": paid_date},
|
||||
)
|
||||
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None,
|
||||
"notification": notif}
|
||||
|
||||
|
||||
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
|
||||
@@ -526,6 +544,12 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He
|
||||
if not klub_id:
|
||||
raise HTTPException(400, "klub_id je obavezan")
|
||||
|
||||
user = _resolve_user(authorization)
|
||||
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub
|
||||
if user and not is_pgz_admin(user):
|
||||
if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id:
|
||||
raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub")
|
||||
|
||||
country = body.get("country", "Hrvatska")
|
||||
km = body.get("km_driven", body.get("kilometara", 0)) or 0
|
||||
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
|
||||
@@ -593,6 +617,8 @@ def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = He
|
||||
ct = cur.fetchone()
|
||||
if ct:
|
||||
row["cost_total"] = ct["cost_total"]
|
||||
audit_putni(user, row["id"], "create", field="status",
|
||||
new=f"draft (€{row.get('cost_total')})")
|
||||
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
|
||||
|
||||
|
||||
@@ -647,6 +673,8 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
user = _resolve_user(authorization)
|
||||
approved_by = body.get("approved_by") or (user.get("id") if user else None)
|
||||
if approved_by == 0 or (user and user.get("_synthetic")):
|
||||
approved_by = None # admin token nema realnog user_id u DB
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
|
||||
@@ -666,7 +694,27 @@ def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
row = cur.fetchone()
|
||||
audit_putni(user, nalog_id, "approve", field="status",
|
||||
old=pn.get("status"), new="odobren")
|
||||
return {"ok": True, "putni_nalog": row}
|
||||
notif = notify_pn_approved({**pn, "status": "odobren"})
|
||||
return {"ok": True, "putni_nalog": row, "notification": notif}
|
||||
|
||||
|
||||
# R6.2 — PUT alias za simetriju s briefom
|
||||
@router.put("/putni-nalog/{nalog_id}/odobri")
|
||||
def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
return odobriti_putni_nalog(nalog_id, body, authorization)
|
||||
|
||||
|
||||
@router.put("/putni-nalog/{nalog_id}/odbij")
|
||||
def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
return odbij_putni_nalog(nalog_id, body, authorization)
|
||||
|
||||
|
||||
@router.put("/putni-nalog/{nalog_id}/isplati")
|
||||
def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
return isplati_putni_nalog(nalog_id, body, authorization)
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/zatvori")
|
||||
|
||||
Reference in New Issue
Block a user