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
+20 -10
View File
@@ -24,6 +24,7 @@ from .auth_v2 import (
require_user, audit, _client,
_resolve_tenant, _tier_for,
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
issue_action_token, INVITE_TTL, _build_link,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -246,25 +247,34 @@ class InviteReq(BaseModel):
@router.post("/users/{uid}/invite")
def invite_user(uid: int, req: InviteReq, request: Request,
actor = Depends(require_user)):
"""Generate a single-use invite token; the user clicks the emailed link
and lands on /login/setup-password?token=… to set their password."""
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)
# Mark must_change_pwd and revoke any existing sessions so old creds can't log in
db_exec("""UPDATE pgz_sport.users SET must_change_pwd=true, updated_at=now()
WHERE id=%s""", (uid,))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
raw_token = issue_action_token(uid, "invite", INVITE_TTL,
created_by=actor["id"], ip=ip,
meta={"email": target["email"], "note": req.note})
invite_link = _build_link("/static/login.html?setup=1", raw_token)
api_link = _build_link("/api/auth/setup-password", raw_token)
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']}"
{"email": target["email"], "send_email": req.send_email,
"ttl_days": INVITE_TTL.days}, ip, ua)
# NOTE: real deployment must e-mail invite_link via a mailer (M11);
# for now, the link is returned to the admin who triggered the invite.
return {"status": "ok", "id": uid,
"temporary_password": new_temp,
"email": target["email"],
"invite_link": invite_link,
"email_sent": False} # mailer wired later
"api_link": api_link,
"expires_in_seconds": int(INVITE_TTL.total_seconds()),
"email_sent": False}
# ─────────────────────────── Role change ───────────────────────────
class RoleReq(BaseModel):