CC2 R4 #6: real TOTP 2FA (setup + verify + disable + login flow)

- auth/auth_v2.py:
  - pyotp-based TOTP (RFC 6238, base32 secret, ±30s window)
  - new pgz_sport.user_2fa table (auto-created)
  - QR code embedded as data: URL via qrcode lib
  - 8 single-use recovery codes generated at setup
  - /2fa/setup, /2fa/verify, /2fa/disable, /2fa/status endpoints
  - Login flow: when 2FA enabled, requires totp field; recovery codes
    accepted and consumed on use
- static/login.html: TOTP field appears when login returns 2FA_REQUIRED
- static/admin_users.html: full 2FA panel in Sigurnost tab
  (status badge, QR + secret + recovery code display, verify input)

Live tests pass:
  T1 status (no setup) → enabled:false
  T2 setup → secret + 1.5KB QR PNG + 8 recovery codes
  T3 verify wrong code → 401
  T4 verify real TOTP → enabled:true
  T5 login w/o TOTP after enable → 401 detail=2FA_REQUIRED
  T6 login w/ TOTP → 200
This commit is contained in:
Damir Radulić
2026-05-05 00:50:28 +02:00
parent a0db65fc31
commit bd3773434e
10 changed files with 4594 additions and 225 deletions
+59
View File
@@ -271,6 +271,30 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
<div class="tab-content" id="tab-security">
<div class="page-header"><h2>Sigurnost</h2></div>
<div class="kpi-grid" id="secKpi"></div>
<div class="section">
<h3>Two-factor authentication (2FA) <small>moj račun</small></h3>
<div id="twofaPanel" style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
<span id="twofaStatus" class="badge gray">Učitavam…</span>
<button class="btn primary" id="btnEnable2FA">Omogući 2FA</button>
<button class="btn danger" id="btnDisable2FA" style="display:none">Onemogući 2FA</button>
</div>
<div id="twofaSetup" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
<div style="flex:0 0 220px"><img id="twofaQr" style="background:#fff;padding:8px;border-radius:6px;width:220px;height:220px"></div>
<div style="flex:1;min-width:220px">
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Skenirajte QR u aplikaciji (Google Authenticator, Authy, 1Password, …) ili upišite secret ručno:</div>
<code id="twofaSecret" style="display:block;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:5px;font-family:'JetBrains Mono',monospace;word-break:break-all;margin-bottom:14px"></code>
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Kodovi za oporavak (sačuvajte ih sigurno):</div>
<div id="twofaRecovery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px;font-family:'JetBrains Mono',monospace;font-size:12px;margin-bottom:14px"></div>
<div class="field">
<label>Potvrda — kod iz autentifikatora</label>
<input type="text" id="twofaConfirm" maxlength="8" inputmode="numeric" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
</div>
<button class="btn primary" id="btnVerify2FA">Potvrdi i aktiviraj</button>
</div>
</div>
</div>
</div>
<div class="section"><h3>Zaključani / failed-login računi</h3>
<table><thead><tr><th>E-mail</th><th>Uloga</th><th class="num">Pokušaja</th><th>Zaključan do</th><th class="actions-col">Akcije</th></tr></thead><tbody id="lockedTbody"></tbody></table>
</div>
@@ -692,8 +716,43 @@ async function loadSecurity() {
<button class="btn primary" onclick="toggleSuspend(${u.id}, false)">▶ Otključaj</button>
</td></tr>
`).join('') || '<tr><td colspan="5" class="empty">Nema zaključanih računa</td></tr>';
load2FAStatus();
}
// 2FA UI
async function load2FAStatus() {
const r = await apiJson('/auth/2fa/status');
const enabled = !!(r && r.enabled);
$('#twofaStatus').className = 'badge ' + (enabled ? 'green' : 'gray');
$('#twofaStatus').textContent = enabled ? '✓ Omogućen' : 'Onemogućen';
$('#btnEnable2FA').style.display = enabled ? 'none' : '';
$('#btnDisable2FA').style.display = enabled ? '' : 'none';
$('#twofaSetup').style.display = 'none';
}
$('#btnEnable2FA').addEventListener('click', async () => {
const r = await apiJson('/auth/2fa/setup', {method:'POST'});
if (!r || !r.qr_png) return toast(r?.detail || 'Greška', 'error');
$('#twofaQr').src = r.qr_png;
$('#twofaSecret').textContent = r.secret;
$('#twofaRecovery').innerHTML = (r.recovery_codes||[]).map(c => `<code style="background:var(--bg);padding:5px 8px;border-radius:4px;border:1px solid var(--border)">${c}</code>`).join('');
$('#twofaSetup').style.display = '';
$('#twofaConfirm').focus();
});
$('#btnVerify2FA').addEventListener('click', async () => {
const code = ($('#twofaConfirm').value || '').trim().replace(/\s/g,'');
if (!code) return toast('Unesite kod', 'error');
const r = await apiJson('/auth/2fa/verify', {method:'POST', body:{code}});
if (r?.status === 'ok') { toast('2FA omogućen ✓'); load2FAStatus(); }
else toast(r?.detail || 'Neispravan kod', 'error');
});
$('#btnDisable2FA').addEventListener('click', async () => {
const code = prompt('Unesite trenutni kod iz autentifikatora (ili recovery kod) za onemogućavanje 2FA:');
if (!code) return;
const r = await apiJson('/auth/2fa/disable', {method:'POST', body:{code: code.trim()}});
if (r?.status === 'ok') { toast('2FA onemogućen'); load2FAStatus(); }
else toast(r?.detail || 'Greška', 'error');
});
// GDPR
async function loadGdpr() {
const er = await apiJson('/admin/gdpr/erasure-requests');