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:
+200
@@ -547,6 +547,206 @@ def password_reset(req: ResetPwdReq, request: Request):
|
||||
return {"status": "ok",
|
||||
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
|
||||
|
||||
# ─────────────────────────── R5 #2+#3: invite & reset tokens ───────────────────────────
|
||||
def _ensure_token_table():
|
||||
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_action_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL, -- 'invite' | 'reset'
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_by INTEGER REFERENCES pgz_sport.users(id),
|
||||
ip TEXT,
|
||||
meta JSONB
|
||||
)""")
|
||||
db_exec("""CREATE INDEX IF NOT EXISTS idx_action_tokens_user
|
||||
ON pgz_sport.user_action_tokens (user_id, kind, used_at)""")
|
||||
_ensure_token_table()
|
||||
|
||||
INVITE_TTL = timedelta(days=int(os.environ.get("PGZ_INVITE_TTL_DAYS", "7")))
|
||||
RESET_TTL = timedelta(hours=int(os.environ.get("PGZ_RESET_TTL_HOURS", "2")))
|
||||
|
||||
def _make_action_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def _hash_action_token(t: str) -> str:
|
||||
return hashlib.sha256(t.encode()).hexdigest()
|
||||
|
||||
def issue_action_token(user_id: int, kind: str, ttl: timedelta,
|
||||
created_by: Optional[int] = None,
|
||||
ip: Optional[str] = None,
|
||||
meta: Optional[Dict] = None) -> str:
|
||||
"""Create a one-time URL-safe token; only its sha256 is persisted."""
|
||||
if kind not in ("invite", "reset"):
|
||||
raise ValueError("kind must be invite|reset")
|
||||
# Invalidate any prior unused tokens of same kind for this user
|
||||
db_exec("""UPDATE pgz_sport.user_action_tokens SET used_at=now()
|
||||
WHERE user_id=%s AND kind=%s AND used_at IS NULL""",
|
||||
(user_id, kind))
|
||||
raw = _make_action_token()
|
||||
th = _hash_action_token(raw)
|
||||
db_exec("""INSERT INTO pgz_sport.user_action_tokens
|
||||
(token_hash, user_id, kind, expires_at, created_by, ip, meta)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""",
|
||||
(th, user_id, kind, _now() + ttl, created_by, ip, json.dumps(meta or {})))
|
||||
return raw
|
||||
|
||||
def consume_action_token(raw: str, kind: str) -> Optional[Dict]:
|
||||
"""Validate (kind/expiry/unused) and atomically mark used_at. Returns row dict if OK."""
|
||||
th = _hash_action_token(raw)
|
||||
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, t.kind, t.meta,
|
||||
u.email, u.aktivan, u.status
|
||||
FROM pgz_sport.user_action_tokens t
|
||||
JOIN pgz_sport.users u ON u.id = t.user_id
|
||||
WHERE t.token_hash=%s AND t.kind=%s""", (th, kind))
|
||||
if not row: return None
|
||||
if row["used_at"] is not None: return None
|
||||
exp = row["expires_at"]
|
||||
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= _now(): return None
|
||||
db_exec("UPDATE pgz_sport.user_action_tokens SET used_at=now() WHERE token_hash=%s", (th,))
|
||||
return row
|
||||
|
||||
def _build_link(path: str, token: str) -> str:
|
||||
base = os.environ.get("PGZ_PUBLIC_BASE", "https://api.rinet.one/sport")
|
||||
sep = '&' if '?' in path else '?'
|
||||
return f"{base}{path}{sep}token={token}"
|
||||
|
||||
# ─────────────────────────── /auth/forgot-password ───────────────────────────
|
||||
class ForgotPwdReq(BaseModel):
|
||||
email: str
|
||||
|
||||
@router.post("/forgot-password")
|
||||
def forgot_password(req: ForgotPwdReq, request: Request):
|
||||
"""Always returns a generic message — never leaks which emails exist.
|
||||
Issues a reset token only if the user exists and is active."""
|
||||
email = (req.email or "").lower().strip()
|
||||
ip, ua = _client(request)
|
||||
u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s",
|
||||
(email,))
|
||||
token = None
|
||||
if u and u.get("aktivan") and u.get("status") == "active":
|
||||
token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip,
|
||||
meta={"email": email})
|
||||
audit(u["id"], "password.forgot.issue",
|
||||
meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600},
|
||||
ip=ip, ua=ua)
|
||||
else:
|
||||
audit(u["id"] if u else None, "password.forgot.miss",
|
||||
meta={"email": email}, ip=ip, ua=ua)
|
||||
# Generic response — do not leak account existence
|
||||
resp = {"status": "ok",
|
||||
"message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."}
|
||||
# In production, e-mailer would deliver the link. For demo / dev,
|
||||
# return it only if header X-Demo-Reveal-Token is set OR caller is from
|
||||
# localhost (rare). Easier: always include it but document that real
|
||||
# deployment must remove it from the response.
|
||||
if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or
|
||||
(request.client.host in ("127.0.0.1", "::1"))):
|
||||
resp["reset_link"] = _build_link("/auth/reset-password", token)
|
||||
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
|
||||
return resp
|
||||
|
||||
class ResetTokenReq(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@router.post("/reset-password")
|
||||
def reset_password_with_token(req: ResetTokenReq, request: Request):
|
||||
"""Consume a reset token and set a new password."""
|
||||
if len(req.new_password or "") < 8:
|
||||
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
|
||||
row = consume_action_token(req.token, "reset")
|
||||
ip, ua = _client(request)
|
||||
if not row:
|
||||
audit(None, "password.reset.fail",
|
||||
meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua)
|
||||
raise HTTPException(400, "Token je nevažeći ili istekao")
|
||||
if not row.get("aktivan") or row.get("status") != "active":
|
||||
audit(row["user_id"], "password.reset.fail",
|
||||
meta={"reason": "user_inactive"}, ip=ip, ua=ua)
|
||||
raise HTTPException(403, "Račun nije aktivan")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=false,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(req.new_password), row["user_id"]))
|
||||
# Revoke all active sessions for safety
|
||||
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
|
||||
(row["user_id"],))
|
||||
audit(row["user_id"], "password.reset.ok", ip=ip, ua=ua)
|
||||
return {"status": "ok", "email": row["email"]}
|
||||
|
||||
@router.get("/reset-password")
|
||||
def reset_password_check(token: str, request: Request):
|
||||
"""Pre-flight: validate that the token exists and isn't expired/used.
|
||||
Does NOT consume the token."""
|
||||
th = _hash_action_token(token)
|
||||
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email
|
||||
FROM pgz_sport.user_action_tokens t
|
||||
JOIN pgz_sport.users u ON u.id = t.user_id
|
||||
WHERE t.token_hash=%s AND t.kind='reset'""", (th,))
|
||||
if not row:
|
||||
raise HTTPException(404, "Token nije pronađen")
|
||||
if row["used_at"] is not None:
|
||||
raise HTTPException(410, "Token je već iskorišten")
|
||||
exp = row["expires_at"]
|
||||
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= _now():
|
||||
raise HTTPException(410, "Token je istekao")
|
||||
return {"status": "ok", "email": row["email"], "expires_at": row["expires_at"].isoformat()}
|
||||
|
||||
# ─────────────────────────── /auth/setup-password (invite) ───────────────────────────
|
||||
class SetupPwdReq(BaseModel):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
@router.get("/setup-password")
|
||||
def setup_password_check(token: str, request: Request):
|
||||
"""Pre-flight: validate an invite token without consuming it."""
|
||||
th = _hash_action_token(token)
|
||||
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email, u.full_name, u.user_type
|
||||
FROM pgz_sport.user_action_tokens t
|
||||
JOIN pgz_sport.users u ON u.id = t.user_id
|
||||
WHERE t.token_hash=%s AND t.kind='invite'""", (th,))
|
||||
if not row:
|
||||
raise HTTPException(404, "Pozivnica nije pronađena")
|
||||
if row["used_at"] is not None:
|
||||
raise HTTPException(410, "Pozivnica je već iskorištena")
|
||||
exp = row["expires_at"]
|
||||
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
|
||||
if exp <= _now():
|
||||
raise HTTPException(410, "Pozivnica je istekla")
|
||||
return {"status": "ok",
|
||||
"email": row["email"],
|
||||
"full_name": row["full_name"],
|
||||
"user_type": row["user_type"],
|
||||
"expires_at": row["expires_at"].isoformat()}
|
||||
|
||||
@router.post("/setup-password")
|
||||
def setup_password_consume(req: SetupPwdReq, request: Request):
|
||||
"""Consume an invite token and set the user's first password."""
|
||||
if len(req.new_password or "") < 8:
|
||||
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
|
||||
row = consume_action_token(req.token, "invite")
|
||||
ip, ua = _client(request)
|
||||
if not row:
|
||||
audit(None, "invite.consume.fail",
|
||||
meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua)
|
||||
raise HTTPException(400, "Pozivnica je nevažeća ili istekla")
|
||||
if not row.get("aktivan") or row.get("status") != "active":
|
||||
audit(row["user_id"], "invite.consume.fail",
|
||||
meta={"reason": "user_inactive"}, ip=ip, ua=ua)
|
||||
raise HTTPException(403, "Račun nije aktivan")
|
||||
db_exec("""UPDATE pgz_sport.users
|
||||
SET password_hash=%s, must_change_pwd=false,
|
||||
email_verified=true,
|
||||
failed_login_count=0, locked_until=NULL, updated_at=now()
|
||||
WHERE id=%s""", (hash_password(req.new_password), row["user_id"]))
|
||||
audit(row["user_id"], "invite.consume.ok",
|
||||
meta={"email": row["email"]}, ip=ip, ua=ua)
|
||||
return {"status": "ok", "email": row["email"]}
|
||||
|
||||
# ─────────────────────────── 2FA — real TOTP (RFC 6238) ───────────────────────────
|
||||
try:
|
||||
import pyotp as _pyotp
|
||||
|
||||
Reference in New Issue
Block a user