f9ebcddf28
#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.
133 lines
5.3 KiB
Python
133 lines
5.3 KiB
Python
#!/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})
|