492c8fdd87
- 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
456 lines
20 KiB
Python
456 lines
20 KiB
Python
#!/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)}
|