feat: add TOTP 2FA with QR code and manual secret entry
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
// E2EE module — Signal-style hybrid encryption for group chat.
|
||||
// Uses Web Crypto API only. Private keys never leave this device.
|
||||
const E2EE = (() => {
|
||||
const GROUP_ID = 'info1';
|
||||
const IDB_NAME = 'ifb_e2ee_v1';
|
||||
const IDB_STORE = 'k';
|
||||
const sub = crypto.subtle;
|
||||
const te = new TextEncoder();
|
||||
const td = new TextDecoder();
|
||||
const EC = { name: 'ECDH', namedCurve: 'P-256' };
|
||||
|
||||
// ── IndexedDB ─────────────────────────────────────────────────
|
||||
function openIDB() {
|
||||
return new Promise((res, rej) => {
|
||||
const r = indexedDB.open(IDB_NAME, 1);
|
||||
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
||||
r.onsuccess = e => res(e.target.result);
|
||||
r.onerror = () => rej(r.error);
|
||||
});
|
||||
}
|
||||
async function idbGet(key) {
|
||||
const db = await openIDB();
|
||||
return new Promise((res, rej) => {
|
||||
const t = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(key);
|
||||
t.onsuccess = () => res(t.result);
|
||||
t.onerror = () => rej(t.error);
|
||||
});
|
||||
}
|
||||
async function idbSet(key, val) {
|
||||
const db = await openIDB();
|
||||
return new Promise((res, rej) => {
|
||||
const t = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE).put(val, key);
|
||||
t.onsuccess = () => res();
|
||||
t.onerror = () => rej(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── REST helper ───────────────────────────────────────────────
|
||||
async function rpc(method, path, body) {
|
||||
try {
|
||||
const r = await fetch('/api/' + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
return r.json();
|
||||
} catch (e) {
|
||||
console.error('[E2EE] rpc', method, path, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Binary helpers ────────────────────────────────────────────
|
||||
function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
|
||||
function fromB64(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); }
|
||||
function randomId() {
|
||||
try { return crypto.randomUUID(); }
|
||||
catch { return toB64(crypto.getRandomValues(new Uint8Array(18))); }
|
||||
}
|
||||
|
||||
// ── Key generation & import ───────────────────────────────────
|
||||
const genECDH = () => sub.generateKey(EC, true, ['deriveKey', 'deriveBits']);
|
||||
const importECPub = jwk => sub.importKey('jwk', jwk, EC, true, []);
|
||||
const importECPriv = jwk => sub.importKey('jwk', jwk, EC, true, ['deriveKey', 'deriveBits']);
|
||||
const importAES = raw => sub.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
const genAES = () => sub.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
const exportRaw = key => sub.exportKey('raw', key);
|
||||
|
||||
// ECDH(myPriv, theirPub) → HKDF → AES-256-GCM wrap key
|
||||
async function deriveWrapKey(myPriv, theirPub) {
|
||||
const bits = await sub.deriveBits({ name: 'ECDH', public: theirPub }, myPriv, 256);
|
||||
const hk = await sub.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
||||
return sub.deriveKey(
|
||||
{ name: 'HKDF', hash: 'SHA-256', salt: te.encode('ifb-e2ee-v1'), info: te.encode(GROUP_ID) },
|
||||
hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
// ── AES-GCM encrypt/decrypt ───────────────────────────────────
|
||||
async function aesEncrypt(key, data) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = await sub.encrypt({ name: 'AES-GCM', iv }, key, data);
|
||||
return { iv: toB64(iv), ct: toB64(ct) };
|
||||
}
|
||||
async function aesDecrypt(key, ivB64, ctB64) {
|
||||
return sub.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64) }, key, fromB64(ctB64));
|
||||
}
|
||||
|
||||
// ── Sender key wrap/unwrap ────────────────────────────────────
|
||||
async function wrapSenderKey(rawKey, myPriv, theirPub) {
|
||||
const wk = await deriveWrapKey(myPriv, theirPub);
|
||||
const { iv, ct } = await aesEncrypt(wk, rawKey);
|
||||
return JSON.stringify({ iv, ct });
|
||||
}
|
||||
async function unwrapSenderKey(encJson, myPriv, distPub) {
|
||||
const { iv, ct } = JSON.parse(encJson);
|
||||
const wk = await deriveWrapKey(myPriv, distPub);
|
||||
return aesDecrypt(wk, iv, ct);
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
let myKeyPair = null; // { priv: CryptoKey, pub: CryptoKey }
|
||||
let myUserId = null;
|
||||
let activeSK = null; // { key: CryptoKey, kid: string }
|
||||
let kidCache = {}; // kid → CryptoKey
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────
|
||||
async function init(userId) {
|
||||
myUserId = userId;
|
||||
try {
|
||||
// Load or generate P-256 identity key pair in IndexedDB
|
||||
let privJwk = await idbGet('priv_' + userId);
|
||||
let pubJwk = await idbGet('pub_' + userId);
|
||||
if (!privJwk || !pubJwk) {
|
||||
const kp = await genECDH();
|
||||
privJwk = await sub.exportKey('jwk', kp.privateKey);
|
||||
pubJwk = await sub.exportKey('jwk', kp.publicKey);
|
||||
await idbSet('priv_' + userId, privJwk);
|
||||
await idbSet('pub_' + userId, pubJwk);
|
||||
}
|
||||
myKeyPair = { priv: await importECPriv(privJwk), pub: await importECPub(pubJwk) };
|
||||
|
||||
// Publish public key (server upserts, idempotent)
|
||||
const pubResp = await rpc('POST', 'e2ee/public-key', { public_key_jwk: JSON.stringify(pubJwk) });
|
||||
if (!pubResp || pubResp.error) console.warn('[E2EE] public-key publish failed', pubResp);
|
||||
|
||||
// Fresh sender key per session → forward secrecy
|
||||
await rotateSenderKey();
|
||||
} catch (e) {
|
||||
console.error('[E2EE] init failed', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateSenderKey() {
|
||||
const kid = randomId();
|
||||
const senderKey = await genAES();
|
||||
const raw = await exportRaw(senderKey);
|
||||
|
||||
// Distribute encrypted copy to every member who has a public key
|
||||
const usersResp = await rpc('GET', 'e2ee/users');
|
||||
const users = Array.isArray(usersResp) ? usersResp : [];
|
||||
|
||||
const dists = [];
|
||||
for (const u of users) {
|
||||
try {
|
||||
const theirPub = await importECPub(JSON.parse(u.public_key_jwk));
|
||||
dists.push({ user_id: u.id, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, theirPub) });
|
||||
} catch {}
|
||||
}
|
||||
// Always include self (may not be in users list yet)
|
||||
if (!dists.some(d => d.user_id === myUserId)) {
|
||||
dists.push({ user_id: myUserId, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, myKeyPair.pub) });
|
||||
}
|
||||
|
||||
await rpc('POST', 'e2ee/group-keys', { group_id: GROUP_ID, kid, keys: dists });
|
||||
activeSK = { key: senderKey, kid };
|
||||
kidCache[kid] = senderKey;
|
||||
}
|
||||
|
||||
async function resolveKid(kid) {
|
||||
if (kidCache[kid]) return kidCache[kid];
|
||||
const row = await rpc('GET', `e2ee/group-key?group_id=${GROUP_ID}&kid=${encodeURIComponent(kid)}`);
|
||||
if (!row || row.error) return null;
|
||||
try {
|
||||
const distPubResp = await rpc('GET', 'e2ee/public-key/' + row.distributor_user_id);
|
||||
if (!distPubResp?.public_key_jwk) return null;
|
||||
const distPub = await importECPub(JSON.parse(distPubResp.public_key_jwk));
|
||||
const rawBuf = await unwrapSenderKey(row.encrypted_key, myKeyPair.priv, distPub);
|
||||
const key = await importAES(rawBuf);
|
||||
kidCache[kid] = key;
|
||||
return key;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function encrypt(plaintext) {
|
||||
if (!activeSK) throw new Error('E2EE not initialized');
|
||||
const { iv, ct } = await aesEncrypt(activeSK.key, te.encode(plaintext));
|
||||
// ts included for replay detection on client side
|
||||
return JSON.stringify({ v: 1, kid: activeSK.kid, iv, ct, ts: Date.now() });
|
||||
}
|
||||
|
||||
async function decrypt(content) {
|
||||
let p;
|
||||
try { p = JSON.parse(content); } catch { return content; } // not encrypted
|
||||
if (!p.v || !p.kid || !p.iv || !p.ct) return content; // legacy plaintext
|
||||
const key = await resolveKid(p.kid);
|
||||
if (!key) return '[Nachricht nicht entschlusselbar]';
|
||||
try {
|
||||
const buf = await aesDecrypt(key, p.iv, p.ct);
|
||||
return td.decode(buf);
|
||||
} catch { return '[Entschlusselung fehlgeschlagen]'; }
|
||||
}
|
||||
|
||||
return { init, encrypt, decrypt, rotateSenderKey };
|
||||
})();
|
||||
+148
-12
@@ -8,6 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script src="/e2ee.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--blue: #2563eb; --blue-d: #1d4ed8;
|
||||
@@ -1421,6 +1422,52 @@ footer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="2fa-section">
|
||||
<div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div>
|
||||
<div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div>
|
||||
<!-- Setup flow -->
|
||||
<div id="2fa-setup-area" style="display:none">
|
||||
<div style="margin-bottom:10px;font-size:13px;color:var(--text-2)">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy).</div>
|
||||
<img id="2fa-qr" style="width:180px;height:180px;border-radius:8px;border:1px solid var(--border);display:block;margin-bottom:8px" alt="QR Code">
|
||||
<details style="margin-bottom:10px;font-size:12px">
|
||||
<summary style="cursor:pointer;color:var(--text-muted);user-select:none">Kein Kamera? Manuell eingeben</summary>
|
||||
<div style="margin-top:6px;padding:8px;background:var(--n-100);border-radius:6px;border:1px solid var(--border)">
|
||||
<div style="color:var(--text-muted);margin-bottom:4px">Geheimschlüssel (Base32):</div>
|
||||
<code id="2fa-secret" style="font-size:13px;word-break:break-all;color:var(--text);letter-spacing:.05em"></code>
|
||||
<div style="color:var(--text-subtle);margin-top:4px;font-size:11px">In App: Konto manuell hinzufügen → TOTP → diesen Schlüssel eingeben</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="settings-fields">
|
||||
<input type="text" id="2fa-confirm-code" placeholder="6-stelliger Code zur Bestätigung" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button class="btn-save" style="align-self:flex-start" onclick="confirm2FA()">Bestätigen & aktivieren</button>
|
||||
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FASetup()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Disable flow -->
|
||||
<div id="2fa-disable-area" style="display:none">
|
||||
<div class="settings-fields">
|
||||
<input type="password" id="2fa-disable-pw" placeholder="Aktuelles Passwort">
|
||||
<input type="text" id="2fa-disable-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-danger" style="align-self:flex-start" onclick="disable2FA()">2FA deaktivieren</button>
|
||||
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FADisable()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Idle buttons -->
|
||||
<div id="2fa-idle-area" style="display:none">
|
||||
<button class="btn-save" style="align-self:flex-start" onclick="setup2FA()">2FA einrichten</button>
|
||||
</div>
|
||||
<div id="2fa-enabled-area" style="display:none">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span>
|
||||
</div>
|
||||
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="danger-zone">
|
||||
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
|
||||
@@ -1572,7 +1619,7 @@ function loginUI(username,id,role,subject){
|
||||
</div>`;
|
||||
|
||||
loadAll();
|
||||
initChat(username);
|
||||
initChat(username, id);
|
||||
}
|
||||
|
||||
function toggleDropdown(el){
|
||||
@@ -1870,9 +1917,92 @@ async function saveModal(){
|
||||
function openSettings(){
|
||||
document.getElementById('settings-overlay').style.display='flex';
|
||||
document.getElementById('user-dropdown')?.classList.remove('open');
|
||||
load2FAStatus();
|
||||
}
|
||||
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
|
||||
|
||||
async function load2FAStatus(){
|
||||
const statusRow=document.getElementById('2fa-status-row');
|
||||
document.getElementById('2fa-idle-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='none';
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
try {
|
||||
const r=await api('GET','2fa/status');
|
||||
statusRow.textContent='';
|
||||
if(r.enabled){
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
} else {
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
} catch(e) {
|
||||
statusRow.textContent='Fehler beim Laden.';
|
||||
}
|
||||
}
|
||||
|
||||
async function setup2FA(){
|
||||
document.getElementById('2fa-idle-area').style.display='none';
|
||||
document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…';
|
||||
try {
|
||||
const r=await api('POST','2fa/setup');
|
||||
document.getElementById('2fa-status-row').textContent='';
|
||||
if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;}
|
||||
document.getElementById('2fa-qr').src=r.qr;
|
||||
document.getElementById('2fa-secret').textContent=r.secret;
|
||||
document.getElementById('2fa-confirm-code').value='';
|
||||
document.getElementById('2fa-setup-area').style.display='';
|
||||
document.getElementById('2fa-confirm-code').focus();
|
||||
} catch(e) {
|
||||
document.getElementById('2fa-status-row').textContent='';
|
||||
toast('Fehler beim Generieren des QR-Codes','error');
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
}
|
||||
|
||||
function cancel2FASetup(){
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
}
|
||||
|
||||
async function confirm2FA(){
|
||||
const code=document.getElementById('2fa-confirm-code').value.trim();
|
||||
if(!code){toast('Code eingeben','error');return;}
|
||||
const r=await api('POST','2fa/confirm',{token:code});
|
||||
if(r.ok){
|
||||
toast('2FA aktiviert ✓','success');
|
||||
document.getElementById('2fa-setup-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
} else {
|
||||
toast(r.error,'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showDisable2FA(){
|
||||
document.getElementById('2fa-enabled-area').style.display='none';
|
||||
document.getElementById('2fa-disable-pw').value='';
|
||||
document.getElementById('2fa-disable-code').value='';
|
||||
document.getElementById('2fa-disable-area').style.display='';
|
||||
}
|
||||
|
||||
function cancel2FADisable(){
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
document.getElementById('2fa-enabled-area').style.display='';
|
||||
}
|
||||
|
||||
async function disable2FA(){
|
||||
const pw=document.getElementById('2fa-disable-pw').value;
|
||||
const code=document.getElementById('2fa-disable-code').value.trim();
|
||||
if(!pw||!code){toast('Passwort und Code erforderlich','error');return;}
|
||||
const r=await api('POST','2fa/disable',{password:pw,token:code});
|
||||
if(r.ok){
|
||||
toast('2FA deaktiviert','success');
|
||||
document.getElementById('2fa-disable-area').style.display='none';
|
||||
document.getElementById('2fa-idle-area').style.display='';
|
||||
} else {
|
||||
toast(r.error,'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(){
|
||||
const cp=document.getElementById('pw-current').value;
|
||||
const np=document.getElementById('pw-new').value;
|
||||
@@ -1900,18 +2030,19 @@ function chatFmtTime(ts) {
|
||||
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function renderChatMsg(m, append) {
|
||||
async function renderChatMsg(m, append) {
|
||||
const el = document.getElementById('chat-msgs');
|
||||
const isOwn = m.username === chatMyUsername;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg';
|
||||
div.dataset.id = m.id;
|
||||
const displayContent = await E2EE.decrypt(m.content);
|
||||
div.innerHTML = `<div class="chat-msg-meta">
|
||||
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
|
||||
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
|
||||
<button class="chat-msg-del" onclick="delChatMsg(${m.id})" title="Löschen">✕</button>
|
||||
</div>
|
||||
<div class="chat-msg-body">${esc(m.content)}</div>`;
|
||||
<div class="chat-msg-body">${esc(displayContent)}</div>`;
|
||||
if (append) {
|
||||
el.appendChild(div);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
@@ -1924,17 +2055,17 @@ async function loadChat() {
|
||||
const msgs = await api('GET', 'chat');
|
||||
const el = document.getElementById('chat-msgs');
|
||||
el.innerHTML = '';
|
||||
msgs.forEach(m => renderChatMsg(m, true));
|
||||
for (const m of msgs) await renderChatMsg(m, true);
|
||||
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
|
||||
}
|
||||
|
||||
async function pollChat() {
|
||||
try {
|
||||
const msgs = await api('GET', 'chat?after=' + chatLastId);
|
||||
msgs.forEach(m => {
|
||||
renderChatMsg(m, true);
|
||||
for (const m of msgs) {
|
||||
await renderChatMsg(m, true);
|
||||
chatLastId = Math.max(chatLastId, m.id);
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
chatPollTimer = setTimeout(pollChat, 3000);
|
||||
}
|
||||
@@ -1944,9 +2075,12 @@ async function sendChatMsg() {
|
||||
const content = inp.value.trim();
|
||||
if (!content) return;
|
||||
inp.value = '';
|
||||
const r = await api('POST', 'chat', { content });
|
||||
if (r.error) { toast(r.error, 'error'); return; }
|
||||
renderChatMsg(r, true);
|
||||
let ciphertext;
|
||||
try { ciphertext = await E2EE.encrypt(content); }
|
||||
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
|
||||
const r = await api('POST', 'chat', { content: ciphertext });
|
||||
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
|
||||
await renderChatMsg(r, true);
|
||||
chatLastId = Math.max(chatLastId, r.id);
|
||||
}
|
||||
|
||||
@@ -1957,9 +2091,11 @@ async function delChatMsg(id) {
|
||||
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
|
||||
}
|
||||
|
||||
function initChat(username) {
|
||||
async function initChat(username, userId) {
|
||||
chatMyUsername = username;
|
||||
loadChat().then(() => pollChat());
|
||||
await E2EE.init(userId);
|
||||
await loadChat();
|
||||
pollChat();
|
||||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
|
||||
});
|
||||
|
||||
+40
-10
@@ -209,17 +209,27 @@ footer a:hover { color: #2563eb; }
|
||||
</div>
|
||||
|
||||
<form class="form active" id="form-login" onsubmit="doLogin(event)">
|
||||
<div class="field">
|
||||
<label for="l-user">Benutzername</label>
|
||||
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
|
||||
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
|
||||
<div id="login-step-1">
|
||||
<div class="field" style="margin-bottom:14px">
|
||||
<label for="l-user">Benutzername</label>
|
||||
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
|
||||
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="l-pass">Passwort</label>
|
||||
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="l-pass">Passwort</label>
|
||||
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
|
||||
<div id="login-step-2" style="display:none">
|
||||
<div class="field">
|
||||
<label for="l-totp">2FA-Code</label>
|
||||
<input type="text" id="l-totp" autocomplete="one-time-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric">
|
||||
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus deiner Authenticator-App eingeben</span>
|
||||
</div>
|
||||
<button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button>
|
||||
</div>
|
||||
<div class="notice notice-red" id="login-err"></div>
|
||||
<button class="btn-submit" type="submit">Anmelden</button>
|
||||
<button class="btn-submit" type="submit" id="login-btn">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<form class="form" id="form-reg" onsubmit="doRegister(event)">
|
||||
@@ -296,12 +306,32 @@ function showErr(id, msg) {
|
||||
}
|
||||
function clearErr(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
let totpPending = false;
|
||||
|
||||
function backToStep1() {
|
||||
totpPending = false;
|
||||
document.getElementById('login-step-1').style.display = '';
|
||||
document.getElementById('login-step-2').style.display = 'none';
|
||||
document.getElementById('login-btn').textContent = 'Anmelden';
|
||||
clearErr('login-err');
|
||||
}
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault(); clearErr('login-err');
|
||||
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }) });
|
||||
const body = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
|
||||
if (totpPending) body.totp_token = document.getElementById('l-totp').value;
|
||||
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) { showErr('login-err', d.error); return; }
|
||||
if (d.requireTotp) {
|
||||
totpPending = true;
|
||||
document.getElementById('login-step-1').style.display = 'none';
|
||||
document.getElementById('login-step-2').style.display = '';
|
||||
document.getElementById('login-btn').textContent = 'Bestätigen';
|
||||
document.getElementById('l-totp').value = '';
|
||||
document.getElementById('l-totp').focus();
|
||||
return;
|
||||
}
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user