M1+M2+M10 (CC2 R3): JWT auth + admin users + GDPR backend
- auth/auth_v2.py: JWT login/refresh/logout/me + bcrypt + tenant_id/role/tier claims - auth/admin_users.py: /api/admin/users CRUD + invite/role/suspend + bulk CSV - auth/gdpr.py: cookie consent + Art.20 export + Art.17 erasure + admin queue - auth/seed_demo.py: 3 demo tenants + 4 users (damir@pgz.hr / PGZ2026!) - Removed legacy /api/auth/login + /api/auth/me from pgz_sport_api.py - Wired auth/admin/gdpr routers into FastAPI 5/5 live curl tests pass: damir@pgz.hr login → JWT with tenant_id=1, role=pgz_admin, tier=0
This commit is contained in:
@@ -0,0 +1 @@
|
||||
rinet-pgz-sggepY_ZLyxrXdziPAXsVx8WzZ5tRREVdeOgJlWgV2jrsPi35eH-w6q88RddJTgl
|
||||
@@ -0,0 +1,2 @@
|
||||
# PGŽ Sport — auth package
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
@@ -0,0 +1,446 @@
|
||||
#!/usr/bin/env python3
|
||||
# admin_users.py — /api/admin/users CRUD endpoints
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
GET /api/admin/users?tenant_type=&tenant_id=&q=
|
||||
POST /api/admin/users
|
||||
PUT /api/admin/users/{id}
|
||||
DELETE /api/admin/users/{id}
|
||||
POST /api/admin/users/{id}/invite
|
||||
POST /api/admin/users/{id}/role
|
||||
POST /api/admin/users/{id}/suspend
|
||||
GET /api/admin/audit?user_id=&action=&limit=
|
||||
GET /api/admin/tenants
|
||||
POST /api/admin/users/bulk-csv
|
||||
"""
|
||||
import csv, io, secrets, json
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Body, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .auth_v2 import (
|
||||
db_query, db_one, db_exec, hash_password,
|
||||
require_user, audit, _client,
|
||||
_resolve_tenant, _tier_for,
|
||||
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
VALID_USER_TYPES = (PGZ_USER_TYPES | SAVEZ_USER_TYPES | KLUB_USER_TYPES |
|
||||
{"viewer", "guest"})
|
||||
|
||||
# ─────────────────────────── Permission helpers ───────────────────────────
|
||||
def _is_pgz_admin(u: Dict) -> bool:
|
||||
return (u.get("user_type") or "").lower() in ("super_admin", "pgz_admin")
|
||||
|
||||
def _is_savez_admin(u: Dict) -> bool:
|
||||
return (u.get("user_type") or "").lower() == "savez_admin"
|
||||
|
||||
def _is_klub_admin(u: Dict) -> bool:
|
||||
return (u.get("user_type") or "").lower() == "klub_admin"
|
||||
|
||||
def _can_manage(actor: Dict, target_user_type: str,
|
||||
target_klub_id: Optional[int], target_savez_id: Optional[int]) -> bool:
|
||||
"""Hierarchical management:
|
||||
- super_admin / pgz_admin → manage everyone
|
||||
- savez_admin → manage savez_*, klub_admin in their savez
|
||||
- klub_admin → manage klub_user/klub_trener/klub_clan in their klub
|
||||
"""
|
||||
if _is_pgz_admin(actor): return True
|
||||
tut = (target_user_type or "").lower()
|
||||
if _is_savez_admin(actor):
|
||||
if tut in PGZ_USER_TYPES: return False
|
||||
if tut in SAVEZ_USER_TYPES and (target_savez_id == actor.get("savez_id")): return True
|
||||
if tut == "klub_admin" and target_savez_id and target_savez_id == actor.get("savez_id"):
|
||||
return True
|
||||
# any klub user that belongs to this savez
|
||||
if tut in KLUB_USER_TYPES and target_savez_id == actor.get("savez_id"):
|
||||
return True
|
||||
return False
|
||||
if _is_klub_admin(actor):
|
||||
if tut not in {"klub_user", "klub_trener", "klub_clan", "viewer"}:
|
||||
return False
|
||||
return target_klub_id and target_klub_id == actor.get("klub_id")
|
||||
return False
|
||||
|
||||
def _scoped_where(actor: Dict) -> tuple:
|
||||
"""Filter user list by actor's scope."""
|
||||
if _is_pgz_admin(actor): return ("", [])
|
||||
if _is_savez_admin(actor):
|
||||
sid = actor.get("savez_id")
|
||||
if not sid: return ("AND 1=0", [])
|
||||
return ("AND (u.savez_id=%s OR u.klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s))",
|
||||
[sid, sid])
|
||||
if _is_klub_admin(actor):
|
||||
kid = actor.get("klub_id")
|
||||
if not kid: return ("AND 1=0", [])
|
||||
return ("AND u.klub_id=%s", [kid])
|
||||
return ("AND u.id=%s", [actor["id"]])
|
||||
|
||||
# ─────────────────────────── List / read ───────────────────────────
|
||||
@router.get("/users")
|
||||
def list_users(
|
||||
q: Optional[str] = None,
|
||||
user_type: Optional[str] = None,
|
||||
tenant_type: Optional[str] = None,
|
||||
tenant_id: Optional[int] = None,
|
||||
klub_id: Optional[int] = None,
|
||||
savez_id: Optional[int] = None,
|
||||
aktivan: Optional[bool] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor = Depends(require_user),
|
||||
):
|
||||
if not (_is_pgz_admin(actor) or _is_savez_admin(actor) or _is_klub_admin(actor)):
|
||||
raise HTTPException(403, "Forbidden — admin required")
|
||||
where = ["1=1"]; args: List[Any] = []
|
||||
sw, sp = _scoped_where(actor)
|
||||
if sw: where.append(sw.replace("AND ", "")); args.extend(sp)
|
||||
if q:
|
||||
where.append("(LOWER(u.email) LIKE %s OR LOWER(u.full_name) LIKE %s OR LOWER(COALESCE(u.ime,'')) LIKE %s OR LOWER(COALESCE(u.prezime,'')) LIKE %s)")
|
||||
like = f"%{q.lower()}%"; args.extend([like]*4)
|
||||
if user_type: where.append("u.user_type=%s"); args.append(user_type)
|
||||
if klub_id: where.append("u.klub_id=%s"); args.append(klub_id)
|
||||
if savez_id: where.append("u.savez_id=%s"); args.append(savez_id)
|
||||
if aktivan is not None: where.append("u.aktivan=%s"); args.append(aktivan)
|
||||
if tenant_type and tenant_id is not None:
|
||||
if tenant_type == "klub": where.append("u.klub_id=%s"); args.append(tenant_id)
|
||||
elif tenant_type == "savez": where.append("u.savez_id=%s"); args.append(tenant_id)
|
||||
base_args = list(args)
|
||||
args.extend([limit, offset])
|
||||
rows = db_query(f"""SELECT u.id, u.email, u.full_name, u.ime, u.prezime, u.user_type,
|
||||
u.klub_id, u.savez_id, u.aktivan, u.status, u.must_change_pwd,
|
||||
u.last_login, u.locked_until, u.failed_login_count, u.telefon,
|
||||
u.created_at, u.gdpr_consent_at,
|
||||
k.naziv AS klub_naziv, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.users u
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY u.id DESC LIMIT %s OFFSET %s""", tuple(args))
|
||||
total = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.users u WHERE {' AND '.join(where)}",
|
||||
tuple(base_args))["c"]
|
||||
return {"count": len(rows), "total": total, "results": rows}
|
||||
|
||||
@router.get("/users/{uid}")
|
||||
def get_user(uid: int, actor = Depends(require_user)):
|
||||
u = db_one("""SELECT u.*, k.naziv AS klub_naziv, s.naziv AS savez_naziv
|
||||
FROM pgz_sport.users u
|
||||
LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id
|
||||
LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id
|
||||
WHERE u.id=%s""", (uid,))
|
||||
if not u: raise HTTPException(404, "User not found")
|
||||
if not (_is_pgz_admin(actor) or
|
||||
_can_manage(actor, u.get("user_type"), u.get("klub_id"), u.get("savez_id")) or
|
||||
actor["id"] == uid):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
# Strip sensitive
|
||||
u.pop("password_hash", None)
|
||||
u.pop("two_factor_secret", None)
|
||||
return u
|
||||
|
||||
# ─────────────────────────── Create ───────────────────────────
|
||||
class CreateUserReq(BaseModel):
|
||||
email: str
|
||||
full_name: Optional[str] = None
|
||||
ime: Optional[str] = None
|
||||
prezime: Optional[str] = None
|
||||
user_type: str = "klub_user"
|
||||
klub_id: Optional[int] = None
|
||||
savez_id: Optional[int] = None
|
||||
telefon: Optional[str] = None
|
||||
oib: Optional[str] = None
|
||||
password: Optional[str] = None # if absent → temp pwd + must_change
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(req: CreateUserReq, request: Request, actor = Depends(require_user)):
|
||||
if req.user_type not in VALID_USER_TYPES:
|
||||
raise HTTPException(400, f"Invalid user_type: {req.user_type}")
|
||||
if not _can_manage(actor, req.user_type, req.klub_id, req.savez_id):
|
||||
raise HTTPException(403, "Forbidden — out of management scope")
|
||||
full_name = req.full_name or ((req.ime or "") + " " + (req.prezime or "")).strip() or req.email
|
||||
pwd = req.password or ("PGZ-" + secrets.token_hex(4))
|
||||
must_change = not bool(req.password)
|
||||
try:
|
||||
new_id = db_one("""INSERT INTO pgz_sport.users
|
||||
(email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id,
|
||||
telefon, oib, must_change_pwd, aktivan, status, auth_provider)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local')
|
||||
RETURNING id""",
|
||||
(req.email.lower().strip(), hash_password(pwd), full_name,
|
||||
req.ime, req.prezime, req.user_type, req.klub_id, req.savez_id,
|
||||
req.telefon, req.oib, must_change))["id"]
|
||||
except Exception as e:
|
||||
if "duplicate" in str(e).lower() or "unique" in str(e).lower():
|
||||
raise HTTPException(409, f"Email već postoji: {req.email}")
|
||||
raise HTTPException(400, str(e))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.create", "user", new_id,
|
||||
{"email": req.email, "user_type": req.user_type,
|
||||
"klub_id": req.klub_id, "savez_id": req.savez_id}, ip, ua)
|
||||
return {"id": new_id, "email": req.email, "user_type": req.user_type,
|
||||
"must_change_pwd": must_change,
|
||||
"temporary_password": pwd if must_change else None}
|
||||
|
||||
# ─────────────────────────── Update ───────────────────────────
|
||||
class UpdateUserReq(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
ime: Optional[str] = None
|
||||
prezime: Optional[str] = None
|
||||
user_type: Optional[str] = None
|
||||
klub_id: Optional[int] = None
|
||||
savez_id: Optional[int] = None
|
||||
telefon: Optional[str] = None
|
||||
oib: Optional[str] = None
|
||||
aktivan: Optional[bool] = None
|
||||
|
||||
@router.put("/users/{uid}")
|
||||
def update_user(uid: int, req: UpdateUserReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
fields, args = [], []
|
||||
for f in ["full_name","ime","prezime","user_type","klub_id","savez_id","telefon","oib","aktivan"]:
|
||||
v = getattr(req, f)
|
||||
if v is not None:
|
||||
if f == "user_type" and v not in VALID_USER_TYPES:
|
||||
raise HTTPException(400, f"Invalid user_type: {v}")
|
||||
fields.append(f"{f}=%s"); args.append(v)
|
||||
if not fields:
|
||||
return {"status": "nothing_to_update"}
|
||||
fields.append("updated_at=now()")
|
||||
args.append(uid)
|
||||
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.update", "user", uid,
|
||||
req.dict(exclude_none=True), ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
# ─────────────────────────── Delete (soft) ───────────────────────────
|
||||
@router.delete("/users/{uid}")
|
||||
def delete_user(uid: int, request: Request, actor = Depends(require_user)):
|
||||
if uid == actor["id"]:
|
||||
raise HTTPException(400, "Ne možete obrisati svoj račun")
|
||||
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
db_exec("""UPDATE pgz_sport.users SET aktivan=false, status='deleted',
|
||||
updated_at=now() WHERE id=%s""", (uid,))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.delete", "user", uid, {"email": target["email"]}, ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
# ─────────────────────────── Invite ───────────────────────────
|
||||
class InviteReq(BaseModel):
|
||||
send_email: bool = False # placeholder — wired to mailer in M11
|
||||
note: Optional[str] = None
|
||||
|
||||
@router.post("/users/{uid}/invite")
|
||||
def invite_user(uid: int, req: InviteReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
new_temp = "PGZ-" + secrets.token_hex(4)
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=true,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(new_temp), uid))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.invite", "user", uid,
|
||||
{"email": target["email"], "send_email": req.send_email}, ip, ua)
|
||||
invite_link = f"https://api.rinet.one/sport/login?email={target['email']}"
|
||||
return {"status": "ok", "id": uid,
|
||||
"temporary_password": new_temp,
|
||||
"invite_link": invite_link,
|
||||
"email_sent": False} # mailer wired later
|
||||
|
||||
# ─────────────────────────── Role change ───────────────────────────
|
||||
class RoleReq(BaseModel):
|
||||
user_type: str
|
||||
|
||||
@router.post("/users/{uid}/role")
|
||||
def change_role(uid: int, req: RoleReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "Samo PGŽ admin može mijenjati role")
|
||||
if req.user_type not in VALID_USER_TYPES:
|
||||
raise HTTPException(400, f"Invalid user_type: {req.user_type}")
|
||||
target = db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
db_exec("UPDATE pgz_sport.users SET user_type=%s, updated_at=now() WHERE id=%s",
|
||||
(req.user_type, uid))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.role.change", "user", uid,
|
||||
{"from": target["user_type"], "to": req.user_type}, ip, ua)
|
||||
return {"status": "ok", "id": uid, "user_type": req.user_type}
|
||||
|
||||
# ─────────────────────────── Suspend / unsuspend ───────────────────────────
|
||||
class SuspendReq(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
minutes: Optional[int] = None # null → indefinite
|
||||
|
||||
@router.post("/users/{uid}/suspend")
|
||||
def suspend_user(uid: int, req: SuspendReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
if req.minutes:
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET locked_until = now() + (interval '1 minute' * %s),
|
||||
updated_at = now() WHERE id=%s""", (req.minutes, uid))
|
||||
else:
|
||||
db_exec("UPDATE pgz_sport.users SET aktivan=false, updated_at=now() WHERE id=%s",
|
||||
(uid,))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.suspend", "user", uid,
|
||||
{"reason": req.reason, "minutes": req.minutes}, ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
@router.post("/users/{uid}/unsuspend")
|
||||
def unsuspend_user(uid: int, request: Request, actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET aktivan=true, locked_until=NULL, failed_login_count=0,
|
||||
updated_at=now() WHERE id=%s""", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.unsuspend", "user", uid, None, ip, ua)
|
||||
return {"status": "ok", "id": uid}
|
||||
|
||||
# ─────────────────────────── Reset password (admin) ───────────────────────────
|
||||
@router.post("/users/{uid}/reset-password")
|
||||
def admin_reset_password(uid: int, request: Request, actor = Depends(require_user)):
|
||||
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
|
||||
(uid,))
|
||||
if not target: raise HTTPException(404, "User not found")
|
||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
new_temp = "PGZ-" + secrets.token_hex(4)
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=true,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(new_temp), uid))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "user.password.reset", "user", uid,
|
||||
{"email": target["email"]}, ip, ua)
|
||||
return {"status": "ok", "temporary_password": new_temp}
|
||||
|
||||
# ─────────────────────────── Audit log ───────────────────────────
|
||||
@router.get("/audit")
|
||||
def audit_log(user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
# savez/klub admins see only their scope
|
||||
if not (_is_savez_admin(actor) or _is_klub_admin(actor)):
|
||||
raise HTTPException(403, "Forbidden")
|
||||
where = ["1=1"]; args: List[Any] = []
|
||||
if user_id: where.append("a.user_id=%s"); args.append(user_id)
|
||||
if action: where.append("a.action LIKE %s"); args.append(f"%{action}%")
|
||||
if resource_type: where.append("a.resource_type=%s"); args.append(resource_type)
|
||||
if not _is_pgz_admin(actor):
|
||||
# restrict to own user's actions or resources within scope
|
||||
if _is_savez_admin(actor):
|
||||
where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE savez_id=%s OR klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s)))")
|
||||
args.extend([actor.get("savez_id"), actor.get("savez_id")])
|
||||
elif _is_klub_admin(actor):
|
||||
where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE klub_id=%s))")
|
||||
args.append(actor.get("klub_id"))
|
||||
args.extend([limit, offset])
|
||||
rows = db_query(f"""SELECT a.id, a.action, a.resource_type, a.resource_id,
|
||||
a.user_id, a.ts AS created_at, a.meta, a.ip_address, a.user_agent,
|
||||
u.email AS actor_email, u.full_name AS actor_name
|
||||
FROM pgz_sport.audit_events a
|
||||
LEFT JOIN pgz_sport.users u ON u.id=a.user_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args))
|
||||
return {"count": len(rows), "results": rows}
|
||||
|
||||
# ─────────────────────────── Tenants list ───────────────────────────
|
||||
@router.get("/tenants")
|
||||
def list_tenants(actor = Depends(require_user)):
|
||||
"""Combined view: tenants table + savezi + klubovi."""
|
||||
tenants = db_query("""SELECT id, slug, display_name, type, status, oib, created_at
|
||||
FROM pgz_sport.tenants ORDER BY id""")
|
||||
if _is_pgz_admin(actor):
|
||||
savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik
|
||||
FROM pgz_sport.savezi WHERE aktivan=true ORDER BY naziv LIMIT 200""")
|
||||
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
|
||||
FROM pgz_sport.klubovi WHERE aktivan=true ORDER BY naziv LIMIT 500""")
|
||||
elif _is_savez_admin(actor):
|
||||
sid = actor.get("savez_id")
|
||||
savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik
|
||||
FROM pgz_sport.savezi WHERE id=%s""", (sid,))
|
||||
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
|
||||
FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=true ORDER BY naziv""", (sid,))
|
||||
else:
|
||||
kid = actor.get("klub_id")
|
||||
savezi = []
|
||||
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
|
||||
FROM pgz_sport.klubovi WHERE id=%s""", (kid,))
|
||||
return {"tenants": tenants, "savezi": savezi, "klubovi": klubovi}
|
||||
|
||||
# ─────────────────────────── Bulk CSV import ───────────────────────────
|
||||
@router.post("/users/bulk-csv")
|
||||
async def bulk_csv(file: UploadFile = File(...),
|
||||
default_user_type: str = "klub_clan",
|
||||
default_klub_id: Optional[int] = None,
|
||||
default_savez_id: Optional[int] = None,
|
||||
request: Request = None,
|
||||
actor = Depends(require_user)):
|
||||
"""CSV columns (header required): email,ime,prezime,user_type,klub_id,savez_id,telefon,oib"""
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "Samo PGŽ admin može masovno uvoziti")
|
||||
raw = (await file.read()).decode("utf-8", errors="replace")
|
||||
rdr = csv.DictReader(io.StringIO(raw))
|
||||
created, skipped, errors = 0, 0, []
|
||||
for i, row in enumerate(rdr, 1):
|
||||
email = (row.get("email") or "").lower().strip()
|
||||
if not email:
|
||||
skipped += 1; continue
|
||||
try:
|
||||
ut = row.get("user_type") or default_user_type
|
||||
if ut not in VALID_USER_TYPES:
|
||||
errors.append(f"row {i}: invalid user_type {ut}"); skipped += 1; continue
|
||||
kid = int(row["klub_id"]) if row.get("klub_id") else default_klub_id
|
||||
sid = int(row["savez_id"]) if row.get("savez_id") else default_savez_id
|
||||
full_name = (row.get("ime","") + " " + row.get("prezime","")).strip() or email
|
||||
temp_pwd = "PGZ-" + secrets.token_hex(4)
|
||||
new_id = db_one("""INSERT INTO pgz_sport.users
|
||||
(email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id,
|
||||
telefon, oib, must_change_pwd, aktivan, status, auth_provider)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,true,'active','local')
|
||||
ON CONFLICT (email) DO NOTHING RETURNING id""",
|
||||
(email, hash_password(temp_pwd), row.get("ime"), row.get("prezime"),
|
||||
full_name, ut, kid, sid, row.get("telefon"), row.get("oib")))
|
||||
if new_id and new_id.get("id"):
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e:
|
||||
errors.append(f"row {i}: {e}"); skipped += 1
|
||||
audit(actor["id"], "user.bulk_csv", meta={"created": created, "skipped": skipped})
|
||||
return {"created": created, "skipped": skipped, "errors": errors[:20]}
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
# auth_v2.py — JWT auth backend with tenant_id, role, tier claims
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout,
|
||||
# /api/auth/me, /api/auth/password/change, /api/auth/password/reset
|
||||
"""
|
||||
JWT claims:
|
||||
sub int user id
|
||||
email str
|
||||
name str
|
||||
tenant_id int|null pgz_sport.tenants.id (or null for super_admin)
|
||||
tenant_type str pgz | savez | klub | global
|
||||
tenant_scope dict {"klub_id": ..., "savez_id": ...}
|
||||
role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...)
|
||||
tier int 0 = PGŽ, 1 = savez, 2 = klub
|
||||
jti str token id (revocable via user_sessions)
|
||||
iat / exp / nbf
|
||||
"""
|
||||
|
||||
import os, hashlib, secrets, json, time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
import jwt as _jwt
|
||||
import psycopg2, psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
try:
|
||||
from passlib.hash import bcrypt as _bcrypt
|
||||
HAS_BCRYPT = True
|
||||
except Exception:
|
||||
HAS_BCRYPT = False
|
||||
|
||||
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
|
||||
user='rinet', password='R1net2026!SecureDB#v7')
|
||||
|
||||
# Persistent JWT secret — read from env, else stable file, else generated.
|
||||
def _load_secret() -> str:
|
||||
env_secret = os.environ.get("PGZ_JWT_SECRET")
|
||||
if env_secret and len(env_secret) >= 32:
|
||||
return env_secret
|
||||
secret_file = "/opt/pgz-sport/auth/.jwt_secret"
|
||||
try:
|
||||
if os.path.exists(secret_file):
|
||||
with open(secret_file) as f:
|
||||
s = f.read().strip()
|
||||
if len(s) >= 32:
|
||||
return s
|
||||
s = "rinet-pgz-" + secrets.token_urlsafe(48)
|
||||
with open(secret_file, "w") as f:
|
||||
f.write(s)
|
||||
os.chmod(secret_file, 0o600)
|
||||
return s
|
||||
except Exception:
|
||||
return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest()
|
||||
|
||||
JWT_SECRET = _load_secret()
|
||||
JWT_ALG = "HS256"
|
||||
ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30")))
|
||||
REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7")))
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth_v2"])
|
||||
|
||||
# ─────────────────────────── DB helpers ───────────────────────────
|
||||
def _conn():
|
||||
return psycopg2.connect(**DB)
|
||||
|
||||
def db_query(sql: str, params=()):
|
||||
with _conn() as c:
|
||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql, params)
|
||||
if cur.description: return cur.fetchall()
|
||||
return []
|
||||
|
||||
def db_one(sql: str, params=()):
|
||||
rows = db_query(sql, params)
|
||||
return rows[0] if rows else None
|
||||
|
||||
def db_exec(sql: str, params=()):
|
||||
with _conn() as c:
|
||||
cur = c.cursor()
|
||||
cur.execute(sql, params)
|
||||
if cur.description:
|
||||
r = cur.fetchone()
|
||||
return r[0] if r else None
|
||||
c.commit()
|
||||
|
||||
# ─────────────────────────── Password helpers ───────────────────────────
|
||||
def _sha256(pw: str) -> str:
|
||||
return hashlib.sha256(pw.encode()).hexdigest()
|
||||
|
||||
def hash_password(pw: str) -> str:
|
||||
if HAS_BCRYPT:
|
||||
return _bcrypt.using(rounds=12).hash(pw)
|
||||
return _sha256(pw)
|
||||
|
||||
def verify_password(pw: str, hashed: Optional[str]) -> bool:
|
||||
if not hashed: return False
|
||||
h = hashed.strip()
|
||||
if h.startswith("$2") and HAS_BCRYPT:
|
||||
try:
|
||||
return _bcrypt.verify(pw, h)
|
||||
except Exception:
|
||||
return False
|
||||
return h == _sha256(pw)
|
||||
|
||||
def needs_rehash(hashed: Optional[str]) -> bool:
|
||||
if not hashed: return True
|
||||
return HAS_BCRYPT and not hashed.startswith("$2")
|
||||
|
||||
# ─────────────────────────── Tenant resolution ───────────────────────────
|
||||
PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"}
|
||||
SAVEZ_USER_TYPES = {"savez_admin", "savez_user"}
|
||||
KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"}
|
||||
|
||||
def _tier_for(user_type: str) -> int:
|
||||
ut = (user_type or "").lower()
|
||||
if ut in PGZ_USER_TYPES: return 0
|
||||
if ut in SAVEZ_USER_TYPES: return 1
|
||||
if ut in KLUB_USER_TYPES: return 2
|
||||
return 9 # unknown / viewer / guest
|
||||
|
||||
def _resolve_tenant(u: Dict) -> Dict:
|
||||
"""Resolve tenant_id + tenant_type from a user row."""
|
||||
ut = (u.get("user_type") or "").lower()
|
||||
klub_id = u.get("klub_id")
|
||||
savez_id = u.get("savez_id")
|
||||
if ut in PGZ_USER_TYPES:
|
||||
row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1")
|
||||
return {
|
||||
"tenant_id": row["id"] if row else None,
|
||||
"tenant_type": "pgz",
|
||||
"tenant_name": row["display_name"] if row else "PGŽ",
|
||||
"tenant_scope": {"klub_id": None, "savez_id": None},
|
||||
}
|
||||
if ut in SAVEZ_USER_TYPES and savez_id:
|
||||
return {
|
||||
"tenant_id": savez_id,
|
||||
"tenant_type": "savez",
|
||||
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"),
|
||||
"tenant_scope": {"klub_id": None, "savez_id": savez_id},
|
||||
}
|
||||
if ut in KLUB_USER_TYPES and klub_id:
|
||||
return {
|
||||
"tenant_id": klub_id,
|
||||
"tenant_type": "klub",
|
||||
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"),
|
||||
"tenant_scope": {"klub_id": klub_id, "savez_id": savez_id},
|
||||
}
|
||||
# super_admin without context
|
||||
if ut == "super_admin":
|
||||
return {"tenant_id": None, "tenant_type": "global",
|
||||
"tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}}
|
||||
return {"tenant_id": None, "tenant_type": "viewer",
|
||||
"tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}}
|
||||
|
||||
# ─────────────────────────── JWT issue / verify ───────────────────────────
|
||||
def _now() -> datetime: return datetime.now(timezone.utc)
|
||||
|
||||
def _new_jti() -> str: return secrets.token_urlsafe(16)
|
||||
|
||||
def make_access_token(u: Dict, jti: str) -> str:
|
||||
tenant = _resolve_tenant(u)
|
||||
tier = _tier_for(u.get("user_type") or "")
|
||||
now = _now()
|
||||
payload = {
|
||||
"sub": str(u["id"]),
|
||||
"uid": u["id"],
|
||||
"email": u["email"],
|
||||
"name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"],
|
||||
"tenant_id": tenant["tenant_id"],
|
||||
"tenant_type": tenant["tenant_type"],
|
||||
"tenant_name": tenant["tenant_name"],
|
||||
"tenant_scope": tenant["tenant_scope"],
|
||||
"role": u.get("user_type") or "viewer",
|
||||
"tier": tier,
|
||||
"jti": jti,
|
||||
"typ": "access",
|
||||
"iat": int(now.timestamp()),
|
||||
"nbf": int(now.timestamp()),
|
||||
"exp": int((now + ACCESS_TTL).timestamp()),
|
||||
}
|
||||
return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
|
||||
|
||||
def make_refresh_token(uid: int, jti: str) -> str:
|
||||
now = _now()
|
||||
return _jwt.encode({
|
||||
"sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh",
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + REFRESH_TTL).timestamp()),
|
||||
}, JWT_SECRET, algorithm=JWT_ALG)
|
||||
|
||||
def decode_token(token: str) -> Dict:
|
||||
try:
|
||||
return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
|
||||
except _jwt.ExpiredSignatureError:
|
||||
raise HTTPException(401, "Token expired")
|
||||
except Exception as e:
|
||||
raise HTTPException(401, f"Invalid token: {e}")
|
||||
|
||||
def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None):
|
||||
th = hashlib.sha256(jti.encode()).hexdigest()
|
||||
db_exec("""INSERT INTO pgz_sport.user_sessions
|
||||
(user_id, token_hash, device_info, ip_address, expires_at, revoked)
|
||||
VALUES (%s,%s,%s,%s::inet,%s,false)
|
||||
ON CONFLICT (token_hash) DO NOTHING""",
|
||||
(uid, th, ua, ip, expires))
|
||||
|
||||
def _is_revoked(jti: str) -> bool:
|
||||
th = hashlib.sha256(jti.encode()).hexdigest()
|
||||
r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,))
|
||||
if not r: return False
|
||||
return bool(r.get("revoked"))
|
||||
|
||||
def _revoke_jti(jti: str):
|
||||
th = hashlib.sha256(jti.encode()).hexdigest()
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,))
|
||||
|
||||
# ─────────────────────────── current_user dep ───────────────────────────
|
||||
def _extract_token(authorization: Optional[str]) -> Optional[str]:
|
||||
if not authorization: return None
|
||||
return authorization.replace("Bearer ", "").strip() or None
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]:
|
||||
token = _extract_token(authorization)
|
||||
if not token: return None
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except HTTPException:
|
||||
return None
|
||||
if payload.get("typ") not in (None, "access"):
|
||||
return None
|
||||
if _is_revoked(payload.get("jti","")):
|
||||
return None
|
||||
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
|
||||
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, status, aktivan, must_change_pwd
|
||||
FROM pgz_sport.users WHERE id=%s""", (uid,))
|
||||
if not u or u.get("status") != "active" or not u.get("aktivan", True):
|
||||
return None
|
||||
u["_jwt"] = payload
|
||||
u["_token"] = token
|
||||
return u
|
||||
|
||||
def require_user(user = Depends(get_current_user)) -> Dict:
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
def require_role(roles: List[str]):
|
||||
def dep(user = Depends(require_user)):
|
||||
if user.get("user_type") not in roles:
|
||||
raise HTTPException(403, f"Forbidden — required: {','.join(roles)}")
|
||||
return user
|
||||
return dep
|
||||
|
||||
# ─────────────────────────── Audit ───────────────────────────
|
||||
def audit(user_id: Optional[int], action: str, resource_type: str = None,
|
||||
resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None):
|
||||
try:
|
||||
db_exec("""INSERT INTO pgz_sport.audit_events
|
||||
(user_id, action, resource_type, resource_id, meta, ip_address, user_agent)
|
||||
VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""",
|
||||
(user_id, action, resource_type, resource_id,
|
||||
json.dumps(meta or {}), ip, ua))
|
||||
except Exception as e:
|
||||
print(f"[AUDIT WARN] {e}")
|
||||
|
||||
def _client(req: Request):
|
||||
ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None
|
||||
ua = req.headers.get("user-agent")
|
||||
return ip, ua
|
||||
|
||||
# ─────────────────────────── Schemas ───────────────────────────
|
||||
class LoginReq(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class RefreshReq(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class ChangePwdReq(BaseModel):
|
||||
old_password: Optional[str] = None
|
||||
new_password: str
|
||||
|
||||
class ResetPwdReq(BaseModel):
|
||||
email: str
|
||||
|
||||
# ─────────────────────────── Endpoints ───────────────────────────
|
||||
@router.post("/login")
|
||||
def login(req: LoginReq, request: Request):
|
||||
ip, ua = _client(request)
|
||||
email = (req.email or "").lower().strip()
|
||||
if not email or not req.password:
|
||||
raise HTTPException(400, "Email i lozinka obavezni")
|
||||
|
||||
u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status,
|
||||
user_type, klub_id, savez_id, aktivan, must_change_pwd,
|
||||
failed_login_count, locked_until
|
||||
FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,))
|
||||
if not u:
|
||||
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
|
||||
raise HTTPException(401, "Neispravni podaci")
|
||||
if u.get("locked_until"):
|
||||
lu = u["locked_until"]
|
||||
if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc)
|
||||
if lu > _now():
|
||||
audit(u["id"], "login.locked", ip=ip, ua=ua)
|
||||
raise HTTPException(423, "Račun privremeno zaključan")
|
||||
if u.get("status") != "active" or not u.get("aktivan", True):
|
||||
audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua)
|
||||
raise HTTPException(403, "Račun nije aktivan")
|
||||
if not verify_password(req.password, u.get("password_hash")):
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET failed_login_count = COALESCE(failed_login_count,0)+1,
|
||||
locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5
|
||||
THEN now()+interval '15 minutes' ELSE locked_until END
|
||||
WHERE id=%s""", (u["id"],))
|
||||
audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua)
|
||||
raise HTTPException(401, "Neispravni podaci")
|
||||
|
||||
# opportunistic rehash to bcrypt
|
||||
if needs_rehash(u.get("password_hash")):
|
||||
try:
|
||||
db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s",
|
||||
(hash_password(req.password), u["id"]))
|
||||
except Exception: pass
|
||||
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET failed_login_count=0, locked_until=NULL, last_login=now()
|
||||
WHERE id=%s""", (u["id"],))
|
||||
|
||||
jti = _new_jti()
|
||||
rjti = _new_jti()
|
||||
access = make_access_token(u, jti)
|
||||
refresh = make_refresh_token(u["id"], rjti)
|
||||
_record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
|
||||
_record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]")
|
||||
audit(u["id"], "login.ok", ip=ip, ua=ua)
|
||||
|
||||
tenant = _resolve_tenant(u)
|
||||
return {
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int(ACCESS_TTL.total_seconds()),
|
||||
"user": {
|
||||
"id": u["id"], "email": u["email"],
|
||||
"full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(),
|
||||
"role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""),
|
||||
"must_change_pwd": bool(u.get("must_change_pwd")),
|
||||
**tenant,
|
||||
},
|
||||
}
|
||||
|
||||
@router.post("/refresh")
|
||||
def refresh(req: RefreshReq, request: Request):
|
||||
payload = decode_token(req.refresh_token)
|
||||
if payload.get("typ") != "refresh":
|
||||
raise HTTPException(401, "Invalid refresh token")
|
||||
if _is_revoked(payload.get("jti","")):
|
||||
raise HTTPException(401, "Refresh token revoked")
|
||||
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
|
||||
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, status, aktivan, must_change_pwd
|
||||
FROM pgz_sport.users WHERE id=%s""", (uid,))
|
||||
if not u or u.get("status") != "active" or not u.get("aktivan", True):
|
||||
raise HTTPException(401, "User inactive")
|
||||
ip, ua = _client(request)
|
||||
new_jti = _new_jti()
|
||||
access = make_access_token(u, new_jti)
|
||||
_record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
|
||||
audit(u["id"], "auth.refresh", ip=ip, ua=ua)
|
||||
return {"access_token": access, "token_type": "Bearer",
|
||||
"expires_in": int(ACCESS_TTL.total_seconds())}
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, user = Depends(require_user)):
|
||||
jti = (user.get("_jwt") or {}).get("jti")
|
||||
if jti: _revoke_jti(jti)
|
||||
# Also revoke refresh tokens for this user (best-effort)
|
||||
db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true
|
||||
WHERE user_id=%s AND device_info LIKE %s""",
|
||||
(user["id"], "%[refresh]%"))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "logout", ip=ip, ua=ua)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/me")
|
||||
def me(user = Depends(require_user)):
|
||||
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, must_change_pwd, aktivan, status,
|
||||
last_login, oib, telefon, phone, preferred_language, created_at
|
||||
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
|
||||
if not enriched:
|
||||
raise HTTPException(404, "User not found")
|
||||
tenant = _resolve_tenant(enriched)
|
||||
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
|
||||
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
|
||||
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
|
||||
return {**enriched,
|
||||
"tier": _tier_for(enriched.get("user_type") or ""),
|
||||
"must_change_pwd": bool(enriched.get("must_change_pwd")),
|
||||
**tenant, "roles": roles}
|
||||
|
||||
@router.post("/password/change")
|
||||
def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)):
|
||||
if len(req.new_password) < 8:
|
||||
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
|
||||
cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s",
|
||||
(user["id"],))
|
||||
if not cur: raise HTTPException(404, "User not found")
|
||||
if not cur.get("must_change_pwd"):
|
||||
if not req.old_password:
|
||||
raise HTTPException(400, "old_password obavezan")
|
||||
if not verify_password(req.old_password, cur.get("password_hash")):
|
||||
raise HTTPException(401, "Stara lozinka netočna")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=false, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(req.new_password), user["id"]))
|
||||
ip, ua = _client(request)
|
||||
audit(user["id"], "password.change", ip=ip, ua=ua)
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.post("/password/reset")
|
||||
def password_reset(req: ResetPwdReq, request: Request):
|
||||
"""Issue a temporary password (admin-equivalent self-reset; logged)."""
|
||||
email = (req.email or "").lower().strip()
|
||||
u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||
(email,))
|
||||
ip, ua = _client(request)
|
||||
audit(u["id"] if u else None, "password.reset.request",
|
||||
meta={"email": email, "found": bool(u)}, ip=ip, ua=ua)
|
||||
# Generic response — do not leak which emails exist
|
||||
return {"status": "ok",
|
||||
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
|
||||
|
||||
# ─────────────────────────── 2FA placeholders (TOTP) ───────────────────────────
|
||||
@router.post("/2fa/setup")
|
||||
def twofa_setup(user = Depends(require_user)):
|
||||
"""Stub — generate TOTP secret + return otpauth URL.
|
||||
Full TOTP verification will be added in M1.5."""
|
||||
secret = secrets.token_hex(20).upper()
|
||||
db_exec("""ALTER TABLE pgz_sport.users
|
||||
ADD COLUMN IF NOT EXISTS two_factor_secret text,
|
||||
ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""")
|
||||
db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s",
|
||||
(secret, user["id"]))
|
||||
otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport"
|
||||
return {"secret": secret, "otpauth": otpauth, "enabled": False}
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)):
|
||||
return {"status": "stub", "verified": False, "code_received": bool(code)}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
# gdpr.py — GDPR endpoints: export, erasure, consent, audit (M10)
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
GET /api/gdpr/export (current user — Art. 20 portability)
|
||||
POST /api/gdpr/erase (current user — Art. 17 erasure request)
|
||||
POST /api/gdpr/consent (cookie / processing consent log)
|
||||
GET /api/gdpr/consent
|
||||
GET /api/gdpr/policy (returns text URL/markdown)
|
||||
GET /api/admin/gdpr/erasure-requests (PGŽ admin)
|
||||
POST /api/admin/gdpr/erasure-requests/{id}/process
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, Body
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .auth_v2 import (
|
||||
db_query, db_one, db_exec,
|
||||
require_user, audit, _client,
|
||||
)
|
||||
from .admin_users import _is_pgz_admin
|
||||
|
||||
router = APIRouter(prefix="/api/gdpr", tags=["gdpr"])
|
||||
admin_router = APIRouter(prefix="/api/admin/gdpr", tags=["gdpr_admin"])
|
||||
|
||||
# Ensure GDPR tables exist (idempotent)
|
||||
def _ensure_tables():
|
||||
try:
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_consent (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
session_id TEXT,
|
||||
ip TEXT,
|
||||
necessary BOOLEAN DEFAULT true,
|
||||
analytics BOOLEAN DEFAULT false,
|
||||
marketing BOOLEAN DEFAULT false,
|
||||
consent_at TIMESTAMPTZ DEFAULT now(),
|
||||
policy_version TEXT DEFAULT 'v1',
|
||||
user_agent TEXT
|
||||
)""")
|
||||
db_exec("""CREATE INDEX IF NOT EXISTS idx_gdpr_consent_user ON pgz_sport.gdpr_consent(user_id)""")
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.gdpr_erasure_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
requested_at TIMESTAMPTZ DEFAULT now(),
|
||||
reason TEXT,
|
||||
status TEXT DEFAULT 'pending', -- pending|approved|denied|completed
|
||||
processed_by INTEGER REFERENCES pgz_sport.users(id),
|
||||
processed_at TIMESTAMPTZ,
|
||||
note TEXT
|
||||
)""")
|
||||
db_exec("""ALTER TABLE pgz_sport.users
|
||||
ADD COLUMN IF NOT EXISTS gdpr_consent_at TIMESTAMPTZ""")
|
||||
except Exception as e:
|
||||
print(f"[GDPR migration WARN] {e}")
|
||||
|
||||
_ensure_tables()
|
||||
|
||||
POLICY_VERSION = "v1"
|
||||
|
||||
# ─────────────────────────── Cookie / consent ───────────────────────────
|
||||
class ConsentReq(BaseModel):
|
||||
necessary: bool = True
|
||||
analytics: bool = False
|
||||
marketing: bool = False
|
||||
session_id: Optional[str] = None
|
||||
policy_version: Optional[str] = None
|
||||
|
||||
@router.post("/consent")
|
||||
def post_consent(req: ConsentReq, request: Request):
|
||||
"""Record a consent event. Works for anonymous (session_id only) or logged-in users."""
|
||||
user = None
|
||||
auth = request.headers.get("authorization")
|
||||
if auth:
|
||||
from .auth_v2 import get_current_user
|
||||
user = get_current_user(authorization=auth)
|
||||
ip, ua = _client(request)
|
||||
uid = user["id"] if user else None
|
||||
db_exec("""INSERT INTO pgz_sport.gdpr_consent
|
||||
(user_id, session_id, ip, necessary, analytics, marketing, policy_version, user_agent)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(uid, req.session_id, ip, req.necessary, req.analytics, req.marketing,
|
||||
req.policy_version or POLICY_VERSION, ua))
|
||||
if uid:
|
||||
db_exec("UPDATE pgz_sport.users SET gdpr_consent_at=now() WHERE id=%s", (uid,))
|
||||
audit(uid, "gdpr.consent", meta={
|
||||
"necessary": req.necessary, "analytics": req.analytics,
|
||||
"marketing": req.marketing, "session_id": req.session_id}, ip=ip, ua=ua)
|
||||
return {"status": "ok", "policy_version": POLICY_VERSION}
|
||||
|
||||
@router.get("/consent")
|
||||
def get_consent(user = Depends(require_user)):
|
||||
rows = db_query("""SELECT necessary, analytics, marketing, consent_at,
|
||||
policy_version, ip, session_id
|
||||
FROM pgz_sport.gdpr_consent WHERE user_id=%s
|
||||
ORDER BY consent_at DESC LIMIT 50""", (user["id"],))
|
||||
return {"current": rows[0] if rows else None, "history": rows}
|
||||
|
||||
@router.get("/policy")
|
||||
def get_policy():
|
||||
return {
|
||||
"version": POLICY_VERSION,
|
||||
"url": "https://api.rinet.one/sport/static/privacy.html",
|
||||
"rights": [
|
||||
"Art. 15 — Pravo na pristup",
|
||||
"Art. 16 — Pravo na ispravak",
|
||||
"Art. 17 — Pravo na brisanje",
|
||||
"Art. 18 — Pravo na ograničenje obrade",
|
||||
"Art. 20 — Pravo na prenosivost podataka",
|
||||
"Art. 21 — Pravo na prigovor",
|
||||
],
|
||||
"controller": "Primorsko-goranska županija — Odjel za sport",
|
||||
"contact": "gdpr@pgz.hr",
|
||||
"dpo": "Damir Radulić (damir@rinet.one)",
|
||||
}
|
||||
|
||||
# ─────────────────────────── Article 20 — data export ───────────────────────────
|
||||
@router.get("/export")
|
||||
def export_my_data(user = Depends(require_user)):
|
||||
"""Return all data we hold about the calling user — JSON dump."""
|
||||
uid = user["id"]
|
||||
profile = db_one("""SELECT id, email, full_name, ime, prezime, oib, telefon, phone,
|
||||
user_type, klub_id, savez_id, status, aktivan, last_login, created_at,
|
||||
preferred_language, gdpr_consent_at
|
||||
FROM pgz_sport.users WHERE id=%s""", (uid,))
|
||||
sessions = db_query("""SELECT id, device_info, ip_address::text AS ip,
|
||||
created_at, expires_at, revoked
|
||||
FROM pgz_sport.user_sessions WHERE user_id=%s ORDER BY created_at DESC""", (uid,))
|
||||
audit_rows = db_query("""SELECT id, action, resource_type, resource_id,
|
||||
ts AS created_at, ip_address::text AS ip, user_agent, meta
|
||||
FROM pgz_sport.audit_events WHERE user_id=%s ORDER BY ts DESC LIMIT 1000""", (uid,))
|
||||
consent = db_query("""SELECT necessary, analytics, marketing, consent_at,
|
||||
policy_version FROM pgz_sport.gdpr_consent WHERE user_id=%s
|
||||
ORDER BY consent_at DESC""", (uid,))
|
||||
klub_links = db_query("""SELECT klub_id, savez_id, link_type, role,
|
||||
primary_klub, granted_at, od_datuma, do_datuma
|
||||
FROM pgz_sport.user_klub_links WHERE user_id=%s""", (uid,))
|
||||
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id,
|
||||
ur.granted_at, ur.expires_at, ur.active
|
||||
FROM pgz_sport.user_roles ur
|
||||
JOIN pgz_sport.roles r ON r.id=ur.role_id
|
||||
WHERE ur.user_id=%s""", (uid,))
|
||||
audit(uid, "gdpr.export")
|
||||
return {
|
||||
"exported_at": datetime.utcnow().isoformat() + "Z",
|
||||
"policy_version": POLICY_VERSION,
|
||||
"subject": profile,
|
||||
"sessions": sessions,
|
||||
"audit_events": audit_rows,
|
||||
"consent_history": consent,
|
||||
"klub_links": klub_links,
|
||||
"roles": roles,
|
||||
}
|
||||
|
||||
# ─────────────────────────── Article 17 — erasure request ───────────────────────────
|
||||
class EraseReq(BaseModel):
|
||||
reason: Optional[str] = None
|
||||
confirm_email: Optional[str] = None
|
||||
|
||||
@router.post("/erase")
|
||||
def request_erasure(req: EraseReq, request: Request, user = Depends(require_user)):
|
||||
if req.confirm_email and req.confirm_email.lower().strip() != user["email"].lower():
|
||||
raise HTTPException(400, "confirm_email se ne poklapa")
|
||||
ip, ua = _client(request)
|
||||
new_id = db_one("""INSERT INTO pgz_sport.gdpr_erasure_requests
|
||||
(user_id, email, reason, status) VALUES (%s,%s,%s,'pending') RETURNING id""",
|
||||
(user["id"], user["email"], req.reason))["id"]
|
||||
audit(user["id"], "gdpr.erasure.request", "user", user["id"],
|
||||
{"reason": req.reason}, ip, ua)
|
||||
return {"status": "ok", "request_id": new_id,
|
||||
"message": "Vaš zahtjev je zaprimljen i bit će obrađen unutar 30 dana."}
|
||||
|
||||
# ─────────────────────────── Admin: erasure queue ───────────────────────────
|
||||
@admin_router.get("/erasure-requests")
|
||||
def list_erasure_requests(status: Optional[str] = None,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "PGŽ admin only")
|
||||
where, args = ["1=1"], []
|
||||
if status: where.append("er.status=%s"); args.append(status)
|
||||
rows = db_query(f"""SELECT er.id, er.user_id, er.email, er.requested_at,
|
||||
er.reason, er.status, er.processed_by, er.processed_at, er.note,
|
||||
u.full_name
|
||||
FROM pgz_sport.gdpr_erasure_requests er
|
||||
LEFT JOIN pgz_sport.users u ON u.id=er.user_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY er.requested_at DESC""", tuple(args))
|
||||
return {"count": len(rows), "results": rows}
|
||||
|
||||
class ProcessEraseReq(BaseModel):
|
||||
decision: str # 'approve' | 'deny'
|
||||
note: Optional[str] = None
|
||||
anonymize: bool = True
|
||||
|
||||
@admin_router.post("/erasure-requests/{rid}/process")
|
||||
def process_erasure(rid: int, req: ProcessEraseReq, request: Request,
|
||||
actor = Depends(require_user)):
|
||||
if not _is_pgz_admin(actor):
|
||||
raise HTTPException(403, "PGŽ admin only")
|
||||
er = db_one("SELECT * FROM pgz_sport.gdpr_erasure_requests WHERE id=%s", (rid,))
|
||||
if not er: raise HTTPException(404, "Request not found")
|
||||
if er["status"] != "pending":
|
||||
raise HTTPException(400, f"Already {er['status']}")
|
||||
if req.decision == "approve":
|
||||
if req.anonymize and er["user_id"]:
|
||||
db_exec("""UPDATE pgz_sport.users SET
|
||||
email = CONCAT('erased-', id, '@anonymous.gdpr'),
|
||||
full_name = 'Erased',
|
||||
ime = NULL, prezime = NULL, oib = NULL,
|
||||
telefon = NULL, phone = NULL, password_hash = NULL,
|
||||
aktivan = false, status = 'erased',
|
||||
google_sub = NULL, google_picture = NULL,
|
||||
updated_at = now()
|
||||
WHERE id=%s""", (er["user_id"],))
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
|
||||
(er["user_id"],))
|
||||
new_status = "completed"
|
||||
else:
|
||||
new_status = "denied"
|
||||
db_exec("""UPDATE pgz_sport.gdpr_erasure_requests
|
||||
SET status=%s, processed_by=%s, processed_at=now(), note=%s
|
||||
WHERE id=%s""", (new_status, actor["id"], req.note, rid))
|
||||
ip, ua = _client(request)
|
||||
audit(actor["id"], "gdpr.erasure.process", "user", er["user_id"] or 0,
|
||||
{"request_id": rid, "decision": req.decision, "note": req.note}, ip, ua)
|
||||
return {"status": new_status, "id": rid}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# seed_demo.py — Demo tenants & users for Round 3 (M1+M2+M10)
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
Seeds:
|
||||
- 3 tenants: PGŽ (existing), Atletski savez PGŽ, AK Kvarner Rijeka
|
||||
- Demo users:
|
||||
damir@pgz.hr / PGZ2026! (pgz_admin) ← KEY DEMO
|
||||
pero@atletika.pgz.hr/ PGZ2026! (savez_admin)
|
||||
ana@akkvarner.hr / PGZ2026! (klub_admin)
|
||||
sportas@akkvarner.hr/ PGZ2026! (klub_clan)
|
||||
"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from auth.auth_v2 import db_query, db_one, db_exec, hash_password
|
||||
|
||||
def get_or_create_tenant(slug, display_name, ttype, oib=None):
|
||||
row = db_one("SELECT id FROM pgz_sport.tenants WHERE slug=%s", (slug,))
|
||||
if row: return row["id"]
|
||||
return db_one("""INSERT INTO pgz_sport.tenants (slug, display_name, type, oib, status)
|
||||
VALUES (%s,%s,%s,%s,'active') RETURNING id""",
|
||||
(slug, display_name, ttype, oib))["id"]
|
||||
|
||||
def get_or_create_savez(naziv, sport, oib=None):
|
||||
row = db_one("SELECT id FROM pgz_sport.savezi WHERE naziv=%s LIMIT 1", (naziv,))
|
||||
if row: return row["id"]
|
||||
return db_one("""INSERT INTO pgz_sport.savezi (naziv, sport, oib, aktivan)
|
||||
VALUES (%s,%s,%s,true) RETURNING id""", (naziv, sport, oib))["id"]
|
||||
|
||||
def get_or_create_klub(naziv, sport, grad, savez_id, oib=None, tenant_id=None):
|
||||
row = db_one("SELECT id FROM pgz_sport.klubovi WHERE naziv=%s LIMIT 1", (naziv,))
|
||||
if row: return row["id"]
|
||||
return db_one("""INSERT INTO pgz_sport.klubovi
|
||||
(naziv, sport, grad, savez_id, oib, tenant_id, aktivan)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,true) RETURNING id""",
|
||||
(naziv, sport, grad, savez_id, oib, tenant_id))["id"]
|
||||
|
||||
def upsert_user(email, password, full_name, ime, prezime, user_type,
|
||||
klub_id=None, savez_id=None):
|
||||
pw_hash = hash_password(password)
|
||||
row = db_one("SELECT id FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||
(email.lower(),))
|
||||
if row:
|
||||
db_exec("""UPDATE pgz_sport.users SET
|
||||
password_hash=%s, full_name=%s, ime=%s, prezime=%s,
|
||||
user_type=%s, klub_id=%s, savez_id=%s,
|
||||
aktivan=true, status='active', must_change_pwd=false,
|
||||
failed_login_count=0, locked_until=NULL,
|
||||
updated_at=now() WHERE id=%s""",
|
||||
(pw_hash, full_name, ime, prezime, user_type,
|
||||
klub_id, savez_id, row["id"]))
|
||||
return row["id"], "updated"
|
||||
new_id = db_one("""INSERT INTO pgz_sport.users
|
||||
(email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id,
|
||||
aktivan, status, must_change_pwd, auth_provider, email_verified)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,true,'active',false,'local',true)
|
||||
RETURNING id""",
|
||||
(email.lower(), pw_hash, full_name, ime, prezime,
|
||||
user_type, klub_id, savez_id))["id"]
|
||||
return new_id, "created"
|
||||
|
||||
def main():
|
||||
print("== Tenants ==")
|
||||
pgz_id = get_or_create_tenant("pgz", "Primorsko-goranska županija", "county")
|
||||
atletski_id = get_or_create_tenant("atletski_savez_pgz", "Atletski savez PGŽ", "federation")
|
||||
ak_kvarner_t = get_or_create_tenant("ak_kvarner_rijeka", "AK Kvarner Rijeka", "club")
|
||||
print(f" pgz tenant: {pgz_id}")
|
||||
print(f" atletski_savez_pgz tenant: {atletski_id}")
|
||||
print(f" ak_kvarner_rijeka tenant: {ak_kvarner_t}")
|
||||
|
||||
print("== Savezi ==")
|
||||
atletski_savez = get_or_create_savez("Atletski savez Primorsko-goranske županije", "Atletika")
|
||||
print(f" atletski_savez id: {atletski_savez}")
|
||||
|
||||
print("== Klub ==")
|
||||
ak_klub = get_or_create_klub("Atletski klub Kvarner Rijeka", "Atletika",
|
||||
"Rijeka", atletski_savez, tenant_id=ak_kvarner_t)
|
||||
print(f" AK Kvarner: {ak_klub}")
|
||||
|
||||
print("== Users ==")
|
||||
users = [
|
||||
("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None),
|
||||
("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez),
|
||||
("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez),
|
||||
("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez),
|
||||
]
|
||||
for email, pwd, fn, im, pz, ut, kid, sid in users:
|
||||
uid, action = upsert_user(email, pwd, fn, im, pz, ut, kid, sid)
|
||||
print(f" [{action}] {email} (id={uid}, type={ut}, klub_id={kid}, savez_id={sid})")
|
||||
|
||||
print("\n== Sanity check ==")
|
||||
for email in ["damir@pgz.hr","pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]:
|
||||
u = db_one("SELECT id, email, user_type, klub_id, savez_id, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", (email,))
|
||||
print(f" {email}: {u}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user