feat: add TOTP 2FA with QR code and manual secret entry

This commit is contained in:
Simon
2026-04-17 22:56:39 +02:00
parent ae789318ba
commit 8f75bc6a10
7 changed files with 959 additions and 26 deletions
+148 -12
View File
@@ -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 &amp; 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(); }
});