CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit

#1 JWT middleware extended:
- Was: /api/admin/* only
- Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT
- Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password,
  /password/reset, /reset-password, /setup-password, /google;
  /api/gdpr/consent; any path ending /avatar
- 14 mutating endpoints verified to return 401 without token

#2 Avatar upload demo mode (routers/clan_panel_router.py):
- Anonymous → returns {demo_mode:true, slika_url:null,
  message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'},
  no FS write, no DB write
- Authenticated (valid JWT, allowed role) → real save as before
- Auth check now uses auth.auth_v2.decode_token (proper secret + revocation)
  instead of the broken local _resolve_role

#3 Mock mailer (auth/mailer.py):
- send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl
- send_password_reset, send_invite helpers with HR text + HTML alt
- Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default)
- forgot-password and admin invite both call mailer; audit logs mail status

#5 Rate limiting on /api/auth/login:
- Per-user: 5 wrong attempts → 5-minute DB-backed lockout
  (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES.
- Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429
  Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful
  login clears the IP counter.
- Failed attempts respond '(N/5) — račun je zaključan na 5 minuta'
- New audit actions: login.ratelimit.ip; login.fail meta now
  includes fails count, locked, lock_minutes

#4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14
   mutating endpoints, public path whitelist, demo-mode avatar +
   real save, forgot-password e-mail to mailbox, no-leak unknown email,
   5-fail lockout, 423 during lockout, audit coverage.
This commit is contained in:
Damir Radulić
2026-05-05 01:42:53 +02:00
parent 3a79965899
commit f9ebcddf28
38 changed files with 24709 additions and 92 deletions
+76 -13
View File
@@ -288,6 +288,36 @@ class ChangePwdReq(BaseModel):
class ResetPwdReq(BaseModel):
email: str
# ─────────────────────────── Rate limiting (R6 #5) ───────────────────────────
LOCK_THRESHOLD = int(os.environ.get("PGZ_LOGIN_LOCK_THRESHOLD", "5"))
LOCK_MINUTES = int(os.environ.get("PGZ_LOGIN_LOCK_MINUTES", "5"))
IP_THRESHOLD = int(os.environ.get("PGZ_LOGIN_IP_THRESHOLD", "10"))
IP_WINDOW_SEC = int(os.environ.get("PGZ_LOGIN_IP_WINDOW_SEC", "300")) # 5 min
# In-memory IP throttle: ip → list[float fail timestamps within window]
_ip_fail_log: Dict[str, List[float]] = {}
def _ip_record_fail(ip: Optional[str]):
if not ip: return
now = time.time()
arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC]
arr.append(now)
_ip_fail_log[ip] = arr
def _ip_blocked(ip: Optional[str]) -> Optional[int]:
"""Return seconds-until-unblock, or None if not blocked."""
if not ip: return None
now = time.time()
arr = [t for t in _ip_fail_log.get(ip, []) if now - t < IP_WINDOW_SEC]
_ip_fail_log[ip] = arr
if len(arr) < IP_THRESHOLD: return None
oldest = min(arr)
return max(1, int(IP_WINDOW_SEC - (now - oldest)))
def _ip_clear(ip: Optional[str]):
if ip and ip in _ip_fail_log:
_ip_fail_log.pop(ip, None)
# ─────────────────────────── Endpoints ───────────────────────────
@router.post("/login")
def login(req: LoginReq, request: Request):
@@ -296,11 +326,20 @@ def login(req: LoginReq, request: Request):
if not email or not req.password:
raise HTTPException(400, "Email i lozinka obavezni")
# R6 #5: per-IP throttle (stops brute-force across many emails)
blocked_for = _ip_blocked(ip)
if blocked_for:
audit(None, "login.ratelimit.ip",
meta={"email": email, "ip": ip, "block_seconds": blocked_for},
ip=ip, ua=ua)
raise HTTPException(429, f"Previše pokušaja s ove IP adrese — pokušajte za {blocked_for}s")
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:
_ip_record_fail(ip)
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
raise HTTPException(401, "Neispravni podaci")
if u.get("locked_until"):
@@ -313,13 +352,25 @@ def login(req: LoginReq, request: Request):
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")):
# R6 #5: 5 fails → 5-minute lockout
new_fails = (u.get("failed_login_count") or 0) + 1
will_lock = new_fails >= LOCK_THRESHOLD
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")
SET failed_login_count = %s,
locked_until = CASE WHEN %s
THEN now() + (interval '1 minute' * %s)
ELSE locked_until END
WHERE id=%s""",
(new_fails, will_lock, LOCK_MINUTES, u["id"]))
_ip_record_fail(ip)
audit(u["id"], "login.fail",
meta={"reason":"bad_password", "fails": new_fails,
"locked": bool(will_lock),
"lock_minutes": LOCK_MINUTES if will_lock else 0},
ip=ip, ua=ua)
raise HTTPException(401,
f"Neispravni podaci ({new_fails}/{LOCK_THRESHOLD})" +
(f" — račun je zaključan na {LOCK_MINUTES} minuta" if will_lock else ""))
# opportunistic rehash to bcrypt
if needs_rehash(u.get("password_hash")):
@@ -357,6 +408,7 @@ def login(req: LoginReq, request: Request):
db_exec("""UPDATE pgz_sport.users
SET failed_login_count=0, locked_until=NULL, last_login=now()
WHERE id=%s""", (u["id"],))
_ip_clear(ip) # successful login clears IP throttle
jti = _new_jti()
rjti = _new_jti()
@@ -620,17 +672,29 @@ class ForgotPwdReq(BaseModel):
@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."""
Issues a reset token only if the user exists and is active, then
sends a (mock) e-mail with the reset link."""
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
mail_result = 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})
reset_link = _build_link("/static/login.html?reset=1", token)
try:
from .mailer import send_password_reset
mail_result = send_password_reset(email, reset_link,
int(RESET_TTL.total_seconds()))
except Exception as e:
print(f"[forgot_password mail WARN] {e}")
audit(u["id"], "password.forgot.issue",
meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600},
meta={"email": email,
"ttl_hours": RESET_TTL.total_seconds()/3600,
"mail_sent": bool(mail_result and mail_result.get("sent")),
"mail_mock": bool(mail_result and mail_result.get("mock"))},
ip=ip, ua=ua)
else:
audit(u["id"] if u else None, "password.forgot.miss",
@@ -638,14 +702,13 @@ def forgot_password(req: ForgotPwdReq, request: Request):
# 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.
# Reveal link only on localhost or with explicit env flag (debugging).
# Real users get it via e-mail.
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["reset_link"] = _build_link("/static/login.html?reset=1", token)
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
resp["mail_mock"] = bool(mail_result and mail_result.get("mock"))
return resp
class ResetTokenReq(BaseModel):