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
+69 -12
View File
@@ -408,10 +408,11 @@ async function enrichEntity(kind, id){
<thead><tr style="background:var(--bg2)"><th style="text-align:left;padding:6px 8px;width:160px">Polje</th><th style="text-align:left;padding:6px 8px;width:240px">Trenutno</th><th style="text-align:left;padding:6px 8px">Predloženo</th></tr></thead>
<tbody id="enrich-diff-${kind}-${id}">${rows}</tbody>
</table>
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 Spremi izmjene</button>
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
<button class="btn" onclick="document.getElementById('enrich-out-${kind}-${id}').innerHTML=''">❌ Odustani</button>
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 SPREMI IZMJENE</button>
</div>
</div>`;
} else {
@@ -454,11 +455,39 @@ function enrichSelectAll(kind, id, on){
tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; });
}
// Reusable toast component (success / error / info / warn).
window.toast = function(msg, type, duration){
type = type || 'success';
duration = duration || 3000;
const palette = {
success: ['#1ec773', '#0b1a16'],
error: ['#ff6b6b', '#1a0b0b'],
info: ['#4a9eff', '#04132b'],
warn: ['#ffb84a', '#1a1004'],
}[type] || ['#4a9eff', '#04132b'];
const t = document.createElement('div');
t.className = 'pgz-toast pgz-toast-' + type;
t.style.cssText = 'position:fixed;right:20px;bottom:20px;'+
'background:'+palette[0]+';color:'+palette[1]+';'+
'padding:12px 18px;border-radius:8px;font-weight:700;font-size:14px;'+
'z-index:99999;box-shadow:0 6px 22px rgba(0,0,0,.45);'+
'transform:translateY(40px);opacity:0;transition:all .25s ease-out;'+
'max-width:380px;line-height:1.45;';
t.innerHTML = msg;
document.body.appendChild(t);
requestAnimationFrame(()=>{ t.style.transform='translateY(0)'; t.style.opacity='1'; });
setTimeout(()=>{
t.style.transform='translateY(40px)'; t.style.opacity='0';
setTimeout(()=>t.remove(), 280);
}, duration);
return t;
};
async function enrichApply(kind, id){
const target = document.getElementById('enrich-out-'+kind+'-'+id);
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
const preview = (window._enrichPreviews||{})[kind+':'+id];
if(!preview){ alert('Prvo pokreni "▶ Pokreni"'); return; }
if(!preview){ toast('Prvo pokreni "▶ Pokreni"', 'warn'); return; }
const proposed = preview.proposed || {};
const fields = {};
if(tbody){
@@ -469,7 +498,7 @@ async function enrichApply(kind, id){
} else {
Object.assign(fields, proposed);
}
if(!Object.keys(fields).length){ alert('Označi barem jedno polje za primjenu.'); return; }
if(!Object.keys(fields).length){ toast('Označi barem jedno polje za primjenu.', 'warn'); return; }
if(target) target.innerHTML = '<div class="loading">⏳ Spremam u bazu…</div>';
try{
const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', {
@@ -484,18 +513,46 @@ async function enrichApply(kind, id){
else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id);
else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id);
setTimeout(() => enrichEntity(kind, id), 350);
const cnt = Object.keys(data.applied||{}).length;
const t = document.createElement('div');
t.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--ok,#1ec773);color:#0b1a16;padding:10px 16px;border-radius:6px;font-weight:700;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.4)';
t.textContent = ' Spremljeno '+cnt+' polja u bazu';
document.body.appendChild(t);
setTimeout(()=>t.remove(), 3500);
const cnt = data.applied_count != null ? data.applied_count : Object.keys(data.applied||{}).length;
const fieldsList = (data.applied_fields || Object.keys(data.applied||{})).join(', ');
if(cnt){
toast(' Spremljeno <b>'+cnt+'</b> polja u bazu'
+ (fieldsList ? '<br><span style="opacity:.85;font-weight:500;font-size:12px">'+esc(fieldsList)+'</span>' : ''),
'success', 3500);
} else {
toast('Nema novih izmjena za spremiti.', 'info', 2500);
}
}catch(e){
console.error(e);
toast('❌ Greška pri spremanju: '+esc(e.message||String(e)), 'error', 4500);
if(target) target.innerHTML = '<div class="empty" style="color:var(--bad,#ff6b6b)">Greška pri spremanju: '+esc(e.message||String(e))+'</div>';
}
}
// Bulk enrichment — used by "Obogati sve" buttons in list views
async function enrichBulk(kind, limit, coverage_max){
limit = limit || 50; coverage_max = coverage_max || 70;
if(!confirm('Pokreni obogaćivanje za '+limit+' nasumično odabranih ('+kind+', coverage<'+coverage_max+'%)?')) return;
toast('⏳ Pokrećem bulk obogaćivanje za '+limit+' '+kind+'…', 'info', 2500);
try{
const r = await fetch(API+'/v2/enrich/bulk', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({kind, limit, coverage_max}),
});
const data = await r.json();
if(!r.ok) throw new Error(data.detail || ('HTTP '+r.status));
toast('✅ Bulk gotov: <b>'+data.processed+'</b>/'+data.requested+' obrađeno, '+
'dodano <b>'+data.fields_total+'</b> polja u DB ('+data.elapsed_s+'s)',
'success', 5000);
// Reload the section so new values appear
if(typeof loadSection === 'function' && _state && _state.section) loadSection(_state.section);
}catch(e){
console.error(e);
toast('❌ Bulk greška: '+esc(e.message||String(e)), 'error', 5000);
}
}
function enrichBlock(kind, id){
return `
<div class="card" id="enrich-card-${kind}-${id}">