CC2 R5: defense-in-depth JWT + invite/reset token flows + audit

#1 JWT middleware:
- pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before
  every /api/admin/* route. Even routes that lack Depends(require_user)
  cannot be reached without a valid Bearer token (verifies signature,
  exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS.

#2 Invitation flow:
- pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at,
  used_at, created_by, ip, meta). Single-use, raw token never persisted.
- POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d),
  marks must_change_pwd, revokes existing sessions, returns invite_link.
- GET  /api/auth/setup-password?token=X — preflight (no consume).
- POST /api/auth/setup-password — consumes token, sets password, sets
  email_verified=true.

#3 Password reset flow:
- POST /api/auth/forgot-password — generic 'ako račun postoji' response;
  issues 'reset' token (TTL 2h) only for active users. Token returned in
  response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1.
- GET  /api/auth/reset-password?token=X — preflight.
- POST /api/auth/reset-password — consumes token, sets new password,
  revokes all active sessions.

#4 Audit coverage (auth events):
- login.ok, login.fail (with reason), login.locked, login.2fa_required,
  login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok,
  password.reset.fail, password.forgot.issue, password.forgot.miss,
  invite.consume.ok, invite.consume.fail, user.invite, user.create,
  user.update, user.delete, user.role.change, user.suspend, user.unsuspend,
  user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable.

#5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user).
   Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
This commit is contained in:
Damir Radulić
2026-05-05 01:28:29 +02:00
parent 8dce58c5f9
commit 0046b8d695
24 changed files with 15419 additions and 72 deletions
+291
View File
@@ -816,6 +816,297 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
@router.post("/invoices/bulk-pay")
def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Bulk označi listu računa kao plaćene.
Body: {ids: [int], paid_date?, payment_method?, iban_from?, iban_to?, reference?, tx_id?}"""
user = _resolve_user(authorization)
ids = body.get("ids") or []
if not ids or not isinstance(ids, list):
raise HTTPException(400, "ids je obavezna ne-prazna lista")
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method") or "transfer"
iban_from = body.get("iban_from")
iban_to = body.get("iban_to")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
results = {"paid": [], "skipped": [], "forbidden": [], "errors": []}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id = ANY(%s)""", (ids,))
rows = cur.fetchall()
for inv in rows:
if (inv.get("payment_status") or "").lower() == "paid":
results["skipped"].append(inv["id"]); continue
if user and not can_pay_invoice(user, inv):
results["forbidden"].append(inv["id"]); continue
try:
with _db() as c:
cur = c.cursor()
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from),
iban_to=COALESCE(%s,iban_to),
updated_at=NOW()
WHERE id=%s""",
(paid_date, payment_method, iban_from, iban_to, inv["id"]),
)
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
iban_from, iban_to, reference, bank_transaction_id, matched_status)
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')""",
(inv.get("klub_id"), inv["id"], paid_date, inv.get("amount_gross"),
inv.get("currency"), payment_method, iban_from, iban_to, reference, tx_id),
)
audit_invoice(user, inv["id"], "bulk_pay",
field="payment_status", old=inv.get("payment_status"), new="paid")
results["paid"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
@router.post("/invoices/bulk-cancel")
def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Bulk otkaži (status='cancelled') — samo pgz_admin ili klub_admin svog kluba."""
user = _resolve_user(authorization)
ids = body.get("ids") or []
razlog = body.get("razlog") or body.get("reason") or "(bulk cancel)"
if not ids:
raise HTTPException(400, "ids je obavezna ne-prazna lista")
results = {"cancelled": [], "skipped": [], "forbidden": [], "errors": []}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id = ANY(%s)""", (ids,))
rows = cur.fetchall()
for inv in rows:
if (inv.get("payment_status") or "").lower() in ("paid", "cancelled"):
results["skipped"].append(inv["id"]); continue
if user and not can_edit_invoice(user, inv):
results["forbidden"].append(inv["id"]); continue
try:
with _db() as c:
c.cursor().execute(
"""UPDATE pgz_sport.invoices
SET payment_status='cancelled',
notes = COALESCE(notes,'') || E'\n[CANCEL] ' || %s,
updated_at=NOW() WHERE id=%s""",
(razlog, inv["id"]),
)
audit_invoice(user, inv["id"], "bulk_cancel",
field="payment_status", old=inv.get("payment_status"),
new=f"cancelled: {razlog}")
results["cancelled"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
# ── R5.4 XLSX EXPORT ───────────────────────────────────────────────────
@router.get("/invoices/export.xlsx")
def invoices_export_xlsx(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
od: Optional[str] = Query(None, description="datum od YYYY-MM-DD"),
do: Optional[str] = Query(None, description="datum do YYYY-MM-DD"),
status: Optional[str] = None,
kind: Optional[str] = None,
authorization: Optional[str] = Header(None),
):
"""XLSX export računa za knjigovodstvo. Stupci: ID, datum, vrsta, broj,
izdavatelj, OIB, klub, neto, PDV, brutto, valuta, status, IBAN, opis."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from fastapi.responses import StreamingResponse
user = _resolve_user(authorization)
sql = """SELECT i.id, i.invoice_date, i.invoice_kind, i.invoice_no,
i.vendor_name, i.vendor_oib, i.customer_oib,
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
i.currency, i.payment_status, i.payment_method,
i.iban_to, i.description, i.category,
i.paid_date, i.tenant_id, i.klub_id,
k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE 1=1"""
args: list = []
if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id)
if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id)
if od: sql += " AND i.invoice_date >= %s"; args.append(od)
if do: sql += " AND i.invoice_date <= %s"; args.append(do)
if status: sql += " AND i.payment_status=%s"; args.append(status)
if kind: sql += " AND i.invoice_kind=%s"; args.append(kind)
sql += " ORDER BY i.invoice_date DESC, i.id DESC"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
# Filter po user permissions
if user and not is_pgz_admin(user):
rows = [r for r in rows if can_view_invoice(user, r)]
wb = Workbook()
ws = wb.active
ws.title = "Računi"
headers = ["ID", "Datum", "Vrsta", "Broj računa", "Izdavatelj", "OIB",
"Klub", "Iznos neto", "PDV", "Brutto", "Stopa PDV",
"Valuta", "Status", "Datum uplate", "IBAN primatelja",
"Opis", "Kategorija"]
bold = Font(bold=True, color="FFFFFF")
fill = PatternFill("solid", fgColor="003087")
for col_idx, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=h)
cell.font = bold; cell.fill = fill
cell.alignment = Alignment(horizontal="center")
for r_idx, r in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=r.get("id"))
ws.cell(row=r_idx, column=2, value=str(r.get("invoice_date") or ""))
ws.cell(row=r_idx, column=3, value=r.get("invoice_kind"))
ws.cell(row=r_idx, column=4, value=r.get("invoice_no"))
ws.cell(row=r_idx, column=5, value=r.get("vendor_name"))
ws.cell(row=r_idx, column=6, value=r.get("vendor_oib"))
ws.cell(row=r_idx, column=7, value=r.get("klub_naziv"))
ws.cell(row=r_idx, column=8, value=float(r["amount_net"]) if r.get("amount_net") is not None else None)
ws.cell(row=r_idx, column=9, value=float(r["amount_vat"]) if r.get("amount_vat") is not None else None)
ws.cell(row=r_idx, column=10, value=float(r["amount_gross"]) if r.get("amount_gross") is not None else None)
ws.cell(row=r_idx, column=11, value=float(r["vat_rate"]) if r.get("vat_rate") is not None else None)
ws.cell(row=r_idx, column=12, value=r.get("currency"))
ws.cell(row=r_idx, column=13, value=r.get("payment_status"))
ws.cell(row=r_idx, column=14, value=str(r.get("paid_date") or ""))
ws.cell(row=r_idx, column=15, value=r.get("iban_to"))
ws.cell(row=r_idx, column=16, value=r.get("description"))
ws.cell(row=r_idx, column=17, value=r.get("category"))
# Auto width
widths = [6, 12, 12, 18, 28, 14, 24, 12, 12, 12, 8, 6, 11, 12, 22, 30, 12]
for i, w in enumerate(widths, 1):
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
ws.freeze_panes = "A2"
buf = BytesIO()
wb.save(buf); buf.seek(0)
fname = f"racuni_{date.today().isoformat()}.xlsx"
return StreamingResponse(
buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ── R5.6 STATS ─────────────────────────────────────────────────────────
@router.get("/stats")
def erp_stats(
klub_id: Optional[int] = Query(None),
tenant_id: Optional[int] = Query(None),
authorization: Optional[str] = Header(None),
):
"""Statistika ERP-a: ukupno troškova mjesec/kvartal/godina po klubu/savezu,
breakdown po vrstama (gorivo/cestarina/hotel/oprema/ostalo)."""
user = _resolve_user(authorization)
today = date.today()
month_start = today.replace(day=1).isoformat()
qmonth = ((today.month - 1) // 3) * 3 + 1
quarter_start = today.replace(month=qmonth, day=1).isoformat()
year_start = today.replace(month=1, day=1).isoformat()
where = ["1=1"]; args: list = []
if klub_id is not None:
where.append("klub_id=%s"); args.append(klub_id)
if tenant_id is not None:
where.append("tenant_id=%s"); args.append(tenant_id)
where_sql = " AND ".join(where)
def q_sum(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT COUNT(*) AS n,
COALESCE(SUM(amount_gross),0)::float AS total,
COALESCE(SUM(CASE WHEN payment_status='paid' THEN amount_gross END),0)::float AS paid,
COALESCE(SUM(CASE WHEN payment_status<>'paid' THEN amount_gross END),0)::float AS unpaid
FROM pgz_sport.invoices
WHERE {where_sql} AND invoice_date >= %s""",
args + [date_from],
)
return cur.fetchone()
def q_breakdown(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT invoice_kind, COUNT(*) AS n,
COALESCE(SUM(amount_gross),0)::float AS total
FROM pgz_sport.invoices
WHERE {where_sql} AND invoice_date >= %s
GROUP BY invoice_kind ORDER BY total DESC""",
args + [date_from],
)
return cur.fetchall()
def q_top(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT i.klub_id, k.naziv AS klub_naziv,
COUNT(*) AS n, COALESCE(SUM(i.amount_gross),0)::float AS total
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE {where_sql} AND i.invoice_date >= %s
GROUP BY i.klub_id, k.naziv ORDER BY total DESC LIMIT 10""",
args + [date_from],
)
return cur.fetchall()
# Putni nalozi totals
def q_pn(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
pn_where = ["report_type='putni_nalog'"]; pn_args: list = []
if klub_id is not None:
pn_where.append("klub_id=%s"); pn_args.append(klub_id)
if tenant_id is not None:
pn_where.append("tenant_id=%s"); pn_args.append(tenant_id)
cur.execute(
f"""SELECT COUNT(*) AS n,
COALESCE(SUM(cost_total),0)::float AS total,
COALESCE(SUM(dnevnice_amount),0)::float AS dnevnice,
COALESCE(SUM(cost_transport),0)::float AS transport
FROM pgz_sport.expense_reports
WHERE {' AND '.join(pn_where)} AND date_from >= %s""",
pn_args + [date_from],
)
return cur.fetchone()
return {
"ok": True,
"as_of": today.isoformat(),
"filters": {"klub_id": klub_id, "tenant_id": tenant_id},
"invoices": {
"month": {"since": month_start, **q_sum(month_start), "by_kind": q_breakdown(month_start)},
"quarter": {"since": quarter_start, **q_sum(quarter_start), "by_kind": q_breakdown(quarter_start)},
"year": {"since": year_start, **q_sum(year_start), "by_kind": q_breakdown(year_start)},
},
"top_klubovi_godina": q_top(year_start),
"putni_nalozi": {
"month": {"since": month_start, **q_pn(month_start)},
"quarter": {"since": quarter_start, **q_pn(quarter_start)},
"year": {"since": year_start, **q_pn(year_start)},
},
}
@router.get("/invoices/uploads/list")
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,