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
+19 -4
View File
@@ -264,17 +264,32 @@ def invite_user(uid: int, req: InviteReq, request: Request,
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)
# R6 #3: send invite email (mock in dev)
mail_result = None
if req.send_email:
try:
from .mailer import send_invite
mail_result = send_invite(
target["email"], invite_link,
int(INVITE_TTL.total_seconds()),
inviter=actor.get("email"),
role=target.get("user_type"),
)
except Exception as e:
print(f"[invite mail WARN] {e}")
audit(actor["id"], "user.invite", "user", uid,
{"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.
"ttl_days": INVITE_TTL.days,
"mail_sent": bool(mail_result and mail_result.get("sent")),
"mail_mock": bool(mail_result and mail_result.get("mock"))}, ip, ua)
return {"status": "ok", "id": uid,
"email": target["email"],
"invite_link": invite_link,
"api_link": api_link,
"expires_in_seconds": int(INVITE_TTL.total_seconds()),
"email_sent": False}
"email_sent": bool(mail_result and mail_result.get("sent")),
"email_mock": bool(mail_result and mail_result.get("mock")),
"email_file": (mail_result or {}).get("file")}
# ─────────────────────────── Role change ───────────────────────────
class RoleReq(BaseModel):