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:
+135
-26
@@ -246,32 +246,32 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
||||
if user and not can_view_putni_nalog(user, row):
|
||||
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
|
||||
|
||||
# Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments)
|
||||
att = row.get("attachments") or {}
|
||||
if isinstance(att, str):
|
||||
try: att = json.loads(att)
|
||||
except Exception: att = {}
|
||||
invoice_ids = att.get("invoice_ids") or []
|
||||
invoices = []
|
||||
if invoice_ids:
|
||||
cur.execute(
|
||||
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
|
||||
invoice_date, amount_gross, payment_status, currency, category
|
||||
FROM pgz_sport.invoices WHERE id = ANY(%s)
|
||||
ORDER BY invoice_date DESC""", (invoice_ids,))
|
||||
invoices = cur.fetchall()
|
||||
else:
|
||||
# Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak
|
||||
cur.execute(
|
||||
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
|
||||
invoice_date, amount_gross, payment_status, currency, category
|
||||
FROM pgz_sport.invoices
|
||||
WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s
|
||||
AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo')
|
||||
ORDER BY invoice_date DESC LIMIT 50""",
|
||||
(row.get("klub_id"), row.get("date_from"), row.get("date_to")),
|
||||
)
|
||||
invoices = cur.fetchall()
|
||||
# Vezani računi iz m2m tablice
|
||||
cur.execute(
|
||||
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
|
||||
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category,
|
||||
pnr.kategorija AS attached_kategorija, pnr.attached_at
|
||||
FROM pgz_sport.putni_nalog_racuni pnr
|
||||
JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id
|
||||
WHERE pnr.putni_nalog_id=%s
|
||||
ORDER BY i.invoice_date DESC""", (nalog_id,))
|
||||
invoices = cur.fetchall()
|
||||
|
||||
# Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani
|
||||
cur.execute(
|
||||
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
|
||||
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category
|
||||
FROM pgz_sport.invoices i
|
||||
LEFT JOIN pgz_sport.putni_nalog_racuni pnr
|
||||
ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s
|
||||
WHERE i.klub_id=%s
|
||||
AND i.invoice_date BETWEEN %s AND %s
|
||||
AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo')
|
||||
AND pnr.id IS NULL
|
||||
ORDER BY i.invoice_date DESC LIMIT 50""",
|
||||
(nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")),
|
||||
)
|
||||
suggested = cur.fetchall()
|
||||
|
||||
# Payments za ovaj putni nalog
|
||||
cur.execute(
|
||||
@@ -284,9 +284,64 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
||||
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
|
||||
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
|
||||
return {"ok": True, "putni_nalog": row, "invoices": invoices,
|
||||
"suggested_invoices": suggested,
|
||||
"payments": payments, "audit": audit, "actions": actions}
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/attach-invoice")
|
||||
def attach_invoice(nalog_id: int, body: dict = Body(...),
|
||||
authorization: Optional[str] = Header(None)):
|
||||
"""Veži postojeći račun na putni nalog (m2m)."""
|
||||
user = _resolve_user(authorization)
|
||||
inv_id = body.get("invoice_id")
|
||||
kategorija = body.get("kategorija") or body.get("category")
|
||||
if not inv_id:
|
||||
raise HTTPException(400, "invoice_id je obavezan")
|
||||
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,))
|
||||
pn = cur.fetchone()
|
||||
if not pn:
|
||||
raise HTTPException(404, "Putni nalog ne postoji")
|
||||
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
|
||||
raise HTTPException(403, "Nemate ovlasti za vezivanje računa")
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"""INSERT INTO pgz_sport.putni_nalog_racuni
|
||||
(putni_nalog_id, invoice_id, kategorija, attached_by)
|
||||
VALUES (%s,%s,%s,%s)
|
||||
ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija
|
||||
RETURNING id, attached_at""",
|
||||
(nalog_id, inv_id, kategorija, (user.get("id") if user else None)),
|
||||
)
|
||||
link = cur.fetchone()
|
||||
audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id)
|
||||
return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]}
|
||||
|
||||
|
||||
@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}")
|
||||
def detach_invoice(nalog_id: int, invoice_id: int,
|
||||
authorization: Optional[str] = Header(None)):
|
||||
user = _resolve_user(authorization)
|
||||
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,))
|
||||
pn = cur.fetchone()
|
||||
if not pn:
|
||||
raise HTTPException(404, "Putni nalog ne postoji")
|
||||
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
|
||||
raise HTTPException(403, "Nemate ovlasti")
|
||||
with _db() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s",
|
||||
(nalog_id, invoice_id),
|
||||
)
|
||||
audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/putni-nalog/{nalog_id}/posalji")
|
||||
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
||||
"""Voditelj/klub_admin šalje draft → poslan."""
|
||||
@@ -385,6 +440,60 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
|
||||
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
|
||||
|
||||
|
||||
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
|
||||
def putni_hub3(nalog_id: int, iban: Optional[str] = None,
|
||||
authorization: Optional[str] = Header(None)):
|
||||
"""HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju."""
|
||||
user = _resolve_user(authorization)
|
||||
with _db() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"""SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa
|
||||
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,))
|
||||
pn = cur.fetchone()
|
||||
if not pn:
|
||||
raise HTTPException(404, "Putni nalog ne postoji")
|
||||
if user and not can_view_putni_nalog(user, pn):
|
||||
raise HTTPException(403, "Nemate ovlasti")
|
||||
|
||||
try:
|
||||
from crm.payments import build_hub3_pdf
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}")
|
||||
from fastapi.responses import Response
|
||||
|
||||
att = pn.get("attachments") or {}
|
||||
if isinstance(att, str):
|
||||
try: att = json.loads(att)
|
||||
except Exception: att = {}
|
||||
voditelj = att.get("voditelj") or "Voditelj putovanja"
|
||||
iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000"
|
||||
iznos = float(pn.get("cost_total") or 0)
|
||||
if iznos <= 0:
|
||||
raise HTTPException(400, "Iznos isplate mora biti veći od 0")
|
||||
|
||||
poziv = f"{nalog_id:08d}"
|
||||
opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}–{pn.get('date_to')})"[:140]
|
||||
|
||||
pdf = build_hub3_pdf(
|
||||
platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub",
|
||||
platitelj_adresa=pn.get("klub_adresa") or "—",
|
||||
primatelj_naziv=voditelj,
|
||||
primatelj_adresa="—",
|
||||
iban=iban_to,
|
||||
amount_eur=iznos,
|
||||
model="HR99",
|
||||
poziv_na_broj=poziv,
|
||||
opis=opis,
|
||||
sifra_namjene="SALA",
|
||||
datum=date.today(),
|
||||
)
|
||||
return Response(content=pdf, media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'})
|
||||
|
||||
|
||||
@router.get("/putni-nalog/{nalog_id}/audit")
|
||||
def putni_audit(nalog_id: int, limit: int = 100,
|
||||
authorization: Optional[str] = Header(None)):
|
||||
|
||||
Reference in New Issue
Block a user