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:
+20
-10
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user