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:
+18
-4
@@ -352,6 +352,10 @@ body {
|
||||
<label for="password">Lozinka</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="field" id="totpField" style="display:none">
|
||||
<label for="totp">Kod autentifikatora (2FA)</label>
|
||||
<input type="text" id="totp" name="totp" inputmode="numeric" pattern="[0-9 ]*" autocomplete="one-time-code" placeholder="123456" maxlength="8" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label><input type="checkbox" id="remember" checked> Zapamti me</label>
|
||||
<a href="#" id="forgotLink">Zaboravljena lozinka?</a>
|
||||
@@ -407,19 +411,28 @@ function showAlert(msg, type) {
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin(email, password) {
|
||||
async function doLogin(email, password, totp) {
|
||||
const btn = $('#submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span>Prijavljujem…';
|
||||
try {
|
||||
const body = { email, password };
|
||||
if (totp) body.totp = totp;
|
||||
const r = await fetch(API + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
showAlert(data.detail || 'Neispravni podaci');
|
||||
if (r.status === 401 && (data.detail === '2FA_REQUIRED' || /2FA/i.test(data.detail||''))) {
|
||||
// Show TOTP field and stop
|
||||
$('#totpField').style.display = '';
|
||||
$('#totp').focus();
|
||||
showAlert('Unesite kod iz autentifikatora.');
|
||||
} else {
|
||||
showAlert(data.detail || 'Neispravni podaci');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Prijavi se';
|
||||
return;
|
||||
@@ -461,8 +474,9 @@ $('#loginForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const email = $('#email').value.trim().toLowerCase();
|
||||
const pwd = $('#password').value;
|
||||
const totp = ($('#totp').value || '').trim().replace(/\s/g,'') || null;
|
||||
if (!email || !pwd) return;
|
||||
doLogin(email, pwd);
|
||||
doLogin(email, pwd, totp);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.demo').forEach(el => {
|
||||
|
||||
Reference in New Issue
Block a user