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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user