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:
+19
-4
@@ -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):
|
||||
|
||||
+76
-13
@@ -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):
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
# mailer.py — Mock e-mail sender for dev/demo (R6 #3)
|
||||
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
|
||||
"""
|
||||
In dev/demo mode, e-mails are appended to:
|
||||
- /tmp/pgz_mailbox/<unix-ts>-<recipient>.eml (raw .eml file)
|
||||
- /tmp/pgz_mailbox/INDEX.jsonl (one JSON line per send)
|
||||
plus printed to stdout (visible in journalctl).
|
||||
|
||||
Set PGZ_SMTP_HOST=... to switch to real SMTP (skipped here — out of scope
|
||||
for R6 demo). The function ALWAYS returns a result; never raises.
|
||||
"""
|
||||
|
||||
import os, json, time, smtplib
|
||||
from email.message import EmailMessage
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
MAILBOX_DIR = Path(os.environ.get("PGZ_MAILBOX_DIR", "/tmp/pgz_mailbox"))
|
||||
MAILBOX_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INDEX_FILE = MAILBOX_DIR / "INDEX.jsonl"
|
||||
|
||||
DEFAULT_FROM = os.environ.get("PGZ_MAIL_FROM", "no-reply@pgz.hr")
|
||||
DEFAULT_SENDER = os.environ.get("PGZ_MAIL_SENDER", "PGŽ Sport platforma")
|
||||
|
||||
def send_email(to: str, subject: str, body: str,
|
||||
html: Optional[str] = None,
|
||||
from_addr: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None) -> Dict:
|
||||
"""Send (or mock-send) an email. Returns a dict with status + storage info."""
|
||||
sender = from_addr or DEFAULT_FROM
|
||||
msg = EmailMessage()
|
||||
msg["From"] = f"{DEFAULT_SENDER} <{sender}>"
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
msg.set_content(body)
|
||||
if html:
|
||||
msg.add_alternative(html, subtype="html")
|
||||
|
||||
smtp_host = os.environ.get("PGZ_SMTP_HOST")
|
||||
use_real = bool(smtp_host)
|
||||
sent_at = int(time.time())
|
||||
fname = f"{sent_at}-{to.replace('@','_at_')}.eml"
|
||||
fpath = MAILBOX_DIR / fname
|
||||
|
||||
try:
|
||||
with open(fpath, "wb") as f:
|
||||
f.write(bytes(msg))
|
||||
except Exception as e:
|
||||
print(f"[MAILER WARN] cannot write {fpath}: {e}")
|
||||
|
||||
rec = {
|
||||
"ts": sent_at,
|
||||
"to": to, "from": sender, "subject": subject,
|
||||
"file": str(fpath),
|
||||
"real_send": use_real,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
try:
|
||||
with open(INDEX_FILE, "a") as f:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
except Exception: pass
|
||||
|
||||
if use_real:
|
||||
try:
|
||||
port = int(os.environ.get("PGZ_SMTP_PORT", "587"))
|
||||
user = os.environ.get("PGZ_SMTP_USER")
|
||||
pwd = os.environ.get("PGZ_SMTP_PASS")
|
||||
with smtplib.SMTP(smtp_host, port, timeout=10) as s:
|
||||
s.starttls()
|
||||
if user: s.login(user, pwd or "")
|
||||
s.send_message(msg)
|
||||
rec["sent"] = True
|
||||
except Exception as e:
|
||||
rec["sent"] = False
|
||||
rec["error"] = str(e)
|
||||
print(f"[MAILER ERROR] {e}")
|
||||
else:
|
||||
# demo / dev mode — print preview to stdout
|
||||
print(f"[MOCK-MAIL] → {to} | {subject}")
|
||||
print(f"[MOCK-MAIL] stored at {fpath}")
|
||||
rec["sent"] = True
|
||||
rec["mock"] = True
|
||||
|
||||
return rec
|
||||
|
||||
# ─────────────────────────── Convenience helpers ───────────────────────────
|
||||
def send_password_reset(email: str, reset_link: str, expires_in_seconds: int) -> Dict:
|
||||
hours = expires_in_seconds / 3600
|
||||
body = (
|
||||
f"Pozdrav,\n\n"
|
||||
f"Zatraženo je resetiranje lozinke za vaš račun na PGŽ Sport platformi.\n\n"
|
||||
f"Otvorite ovaj link za postavljanje nove lozinke (vrijedi {hours:.0f} h):\n"
|
||||
f"{reset_link}\n\n"
|
||||
f"Ako niste vi tražili promjenu, ignorirajte ovu poruku.\n\n"
|
||||
f"— PGŽ Sport platforma"
|
||||
)
|
||||
html = (
|
||||
f'<p>Pozdrav,</p>'
|
||||
f'<p>Zatraženo je resetiranje lozinke za vaš račun na PGŽ Sport platformi.</p>'
|
||||
f'<p><a href="{reset_link}" style="background:#00f0ff;color:#000;padding:10px 20px;'
|
||||
f'text-decoration:none;border-radius:6px;font-weight:600">Postavi novu lozinku</a></p>'
|
||||
f'<p style="font-size:12px;color:#888">Link vrijedi {hours:.0f} h.</p>'
|
||||
)
|
||||
return send_email(email, "Resetiranje lozinke — PGŽ Sport", body, html=html,
|
||||
metadata={"kind": "password_reset"})
|
||||
|
||||
def send_invite(email: str, invite_link: str, expires_in_seconds: int,
|
||||
inviter: Optional[str] = None,
|
||||
role: Optional[str] = None) -> Dict:
|
||||
days = expires_in_seconds / 86400
|
||||
by = f" od {inviter}" if inviter else ""
|
||||
body = (
|
||||
f"Pozdrav,\n\n"
|
||||
f"Pozvani ste{by} u PGŽ Sport platformu" +
|
||||
(f" kao {role}" if role else "") + ".\n\n"
|
||||
f"Otvorite ovaj link i postavite svoju lozinku (vrijedi {days:.0f} dana):\n"
|
||||
f"{invite_link}\n\n"
|
||||
f"— PGŽ Sport platforma"
|
||||
)
|
||||
html = (
|
||||
f'<p>Pozdrav,</p>'
|
||||
f'<p>Pozvani ste{by} u PGŽ Sport platformu' +
|
||||
(f' kao <strong>{role}</strong>' if role else '') + '.</p>'
|
||||
f'<p><a href="{invite_link}" style="background:#00f0ff;color:#000;padding:10px 20px;'
|
||||
f'text-decoration:none;border-radius:6px;font-weight:600">Postavi lozinku i prijavi se</a></p>'
|
||||
f'<p style="font-size:12px;color:#888">Pozivnica vrijedi {days:.0f} dana.</p>'
|
||||
)
|
||||
return send_email(email, "Pozivnica — PGŽ Sport", body, html=html,
|
||||
metadata={"kind": "invite", "role": role})
|
||||
Reference in New Issue
Block a user