Quelltext des aktuellen Standes
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => $current_route ?? '', 'full_width' => true]); ?>
<style>
.mfa-wrap { display:flex; flex-direction:column; }
.mfa-body { padding:1.5rem 1rem 3rem; background:var(--color-bg-alt); flex:1; }
.mfa-qr-box {
display:inline-flex; align-items:center; justify-content:center;
background:var(--color-bg); border:1px solid rgba(0,0,0,.125); border-radius:.25rem;
padding:12px; box-shadow:0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2);
margin-bottom:1rem;
}
.mfa-qr-box img { display:block; border-radius:4px; }
.mfa-secret-box {
display:flex; align-items:center; gap:.5rem;
background:var(--color-gray-50); border:1px solid var(--color-gray-300); border-radius:.25rem;
padding:.5rem .75rem; margin-bottom:1rem;
}
.mfa-secret-code {
flex:1; font-family:monospace; font-size:.9rem; letter-spacing:.18em;
word-break:break-all; color:var(--color-gray-900); cursor:pointer; user-select:all;
}
.mfa-copy-msg { font-size:.75rem; color:var(--bs-success); display:none; margin-top:.25rem; }
.mfa-step-num {
display:inline-flex; align-items:center; justify-content:center;
width:22px; height:22px; border-radius:50%;
font-size:.75rem; font-weight:700; flex-shrink:0;
}
/* WebAuthn key list */
.wa-key-row {
display:flex; align-items:center; gap:.75rem;
padding:.65rem 1rem; border-bottom:1px solid var(--gray-100);
}
.wa-key-row:last-child { border-bottom:none; }
.wa-key-icon { font-size:1.3rem; flex-shrink:0; }
.wa-key-info { flex:1; min-width:0; }
.wa-key-name { font-weight:600; font-size:.875rem; color:var(--color-gray-900); }
.wa-key-meta { font-size:.75rem; color:var(--gray-400); margin-top:1px; }
@media(max-width:768px) { .mfa-body { padding:1.25rem 1rem 2rem; } }
</style>
<div class="mfa-wrap">
<div class="content-header">
<div>
<h1><?= $this->te('Zwei-Faktor-Authentifizierung') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/dashboard"><?= $this->te('Dashboard') ?></a></li>
<li class="breadcrumb-item"><a href="/account"><?= $this->te('Konto') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('MFA') ?></li>
</ol>
</div>
</div>
<div class="mfa-body">
<div class="row">
<!-- ══════════════════════════════════════════════
SPALTE 1: Authenticator-App (TOTP)
══════════════════════════════════════════════ -->
<div class="col-lg-5 col-md-6">
<?php if ($totp_enabled): ?>
<!-- TOTP aktiv -->
<div class="card card-outline card-success">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-mobile-alt text-success mr-2"></i><?= $this->te('Authenticator-App') ?>
</h3>
<div class="card-tools">
<span class="badge badge-success"><?= $this->te('Aktiv') ?></span>
</div>
</div>
<div class="card-body">
<div class="callout callout-success mb-3">
<?= $this->te('Dein Konto ist mit einer Authenticator-App (TOTP) geschützt.') ?>
</div>
<p class="text-muted mb-2" style="font-size:.875rem;">
<?= $this->te('Gib zur Bestätigung den aktuellen Code aus deiner App ein.') ?>
</p>
<form method="POST" action="/account/mfa/disable">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="input-group">
<input type="text" name="code" class="form-control"
inputmode="numeric" pattern="\d{6}" maxlength="6"
placeholder="<?= $this->te('6-stelliger Code') ?>" required
autocomplete="one-time-code"
style="font-size:1.1rem;letter-spacing:.2em;text-align:center;font-weight:600;">
<div class="input-group-append">
<button type="submit" class="btn btn-outline-danger"><?= $this->te('Deaktivieren') ?></button>
</div>
</div>
</form>
</div>
</div>
<?php else: ?>
<!-- TOTP einrichten -->
<div class="card card-outline card-primary">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-mobile-alt text-primary mr-2"></i><?= $this->te('Authenticator-App') ?>
</h3>
<div class="card-tools">
<span class="badge badge-secondary"><?= $this->te('Nicht aktiv') ?></span>
</div>
</div>
<div class="card-body">
<p class="text-muted mb-3" style="font-size:.875rem;">
<?= $this->te('Scanne den QR-Code mit deiner Authenticator-App (Google Authenticator, Authy, Bitwarden, …).') ?>
</p>
<?php if (!empty($otp_url)): ?>
<div class="text-center">
<div class="mfa-qr-box">
<!-- Der QR-Code wird lokal im Browser erzeugt; das
Einmalpasswort-Geheimnis verlässt den Server nicht. -->
<canvas id="mfaQrCanvas" width="200" height="200" role="img" aria-label="<?= $this->te('QR-Code zur Einrichtung der Authenticator-App') ?>"></canvas>
<p id="mfaQrFallback" class="text-muted" style="display:none;font-size:.8rem;margin:.5rem 0 0;">
<?= $this->te('QR-Code konnte nicht erzeugt werden – bitte den manuellen Schlüssel verwenden.') ?>
</p>
</div>
</div>
<script src="/assets/js/qrcode.min.js"></script>
<script>
(function () {
var canvas = document.getElementById('mfaQrCanvas');
var fallback = document.getElementById('mfaQrFallback');
function failed() {
if (canvas) { canvas.style.display = 'none'; }
if (fallback) { fallback.style.display = 'block'; }
}
if (!canvas || !window.QRCode) { failed(); return; }
window.QRCode.toCanvas(
canvas,
<?= json_encode($otp_url, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>,
{ width: 200, margin: 0, color: { dark: '#000000', light: '#ffffff' } },
function (err) { if (err) { failed(); } }
);
})();
</script>
<?php endif; ?>
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--gray-400);margin-bottom:.35rem;">
<?= $this->te('Manueller Schlüssel') ?>
</div>
<div class="mfa-secret-box" onclick="mfaCopySecret()" title="<?= $this->te('Klicken zum Kopieren') ?>">
<code class="mfa-secret-code"><?= chunk_split(htmlspecialchars($temp_secret ?? ''), 4, ' ') ?></code>
<button type="button" class="btn btn-xs btn-outline-secondary" aria-label="<?= $this->te('Kopieren') ?>" style="line-height:1;padding:.2rem .4rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
</div>
<div id="mfaCopyMsg" class="mfa-copy-msg"><i class="fas fa-check mr-1"></i><?= $this->te('Kopiert!') ?></div>
<hr class="mt-3 mb-3">
<p class="text-muted mb-2" style="font-size:.875rem;">
<?= $this->t('<span class="mfa-step-num bg-success text-white mr-1" style="vertical-align:middle;">✓</span> Code aus der App eingeben, um die Einrichtung abzuschließen:') ?>
</p>
<form method="POST" action="/account/mfa/enable" id="mfaEnableForm">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="input-group">
<input type="text" name="code" id="mfaEnableCode" class="form-control"
inputmode="numeric" pattern="\d{6}" maxlength="6"
placeholder="<?= $this->te('6-stelliger Code') ?>" required
autocomplete="one-time-code"
style="font-size:1.2rem;letter-spacing:.25em;text-align:center;font-weight:700;">
<div class="input-group-append">
<button type="submit" class="btn btn-success">
<i class="fas fa-check mr-1"></i><?= $this->te('Aktivieren') ?>
</button>
</div>
</div>
</form>
</div>
<div class="card-footer text-muted" style="font-size:.78rem;">
<?= $this->te('Kompatibel mit Google Authenticator, Authy, Bitwarden & allen RFC 6238-Apps') ?>
</div>
</div>
<?php endif; ?>
</div><!-- /col TOTP -->
<!-- ══════════════════════════════════════════════
SPALTE 2: Sicherheitsschlüssel (WebAuthn/YubiKey)
══════════════════════════════════════════════ -->
<div class="col-lg-5 col-md-6">
<?php $hasKeys = !empty($webauthn_keys); ?>
<div class="card card-outline <?= $hasKeys ? 'card-success' : 'card-secondary' ?>">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-key <?= $hasKeys ? 'text-success' : 'text-secondary' ?> mr-2"></i><?= $this->te('Sicherheitsschlüssel') ?>
</h3>
<div class="card-tools">
<?php if ($hasKeys): ?>
<span class="badge badge-success"><?= $this->te('{anzahl} registriert', ['anzahl' => count($webauthn_keys)]) ?></span>
<?php else: ?>
<span class="badge badge-secondary"><?= $this->te('Nicht eingerichtet') ?></span>
<?php endif; ?>
</div>
</div>
<?php if ($hasKeys): ?>
<!-- List of registered keys -->
<div style="border-bottom:1px solid var(--gray-100);">
<?php foreach ($webauthn_keys as $key): ?>
<div class="wa-key-row">
<div class="wa-key-icon">🔑</div>
<div class="wa-key-info">
<div class="wa-key-name"><?= htmlspecialchars($key['device_name']) ?></div>
<div class="wa-key-meta">
<?= $this->te('Registriert: {datum}', ['datum' => date('d.m.Y', strtotime($key['created_at']))]) ?>
<?php if ($key['last_used_at']): ?>
· <?= $this->te('Zuletzt: {datum}', ['datum' => date('d.m.Y', strtotime($key['last_used_at']))]) ?>
<?php endif; ?>
</div>
</div>
<form method="POST" action="/account/mfa/webauthn/delete/<?= $key['id'] ?>"
onsubmit="return confirm('Schlüssel \"<?= htmlspecialchars(addslashes($key['device_name'])) ?>\" wirklich entfernen?')">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-xs btn-outline-danger" title="<?= $this->te('Entfernen') ?>">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="card-body pb-2">
<p class="text-muted mb-0" style="font-size:.875rem;">
<?= $this->te('Melde dich mit einem Klick an — kein Code-Abtippen. Kompatibel mit YubiKey, FIDO2-Sticks und anderen Hardware-Tokens.') ?>
</p>
</div>
<?php endif; ?>
<div class="card-body pt-2">
<?php if ($hasKeys): ?>
<p class="text-muted mb-2" style="font-size:.8rem;">
<i class="fas fa-info-circle mr-1"></i>
<?= $this->te('Du kannst beliebig viele Schlüssel registrieren — z. B. einen Backup-Key für den Notfall.') ?>
</p>
<?php endif; ?>
<!-- Register new key -->
<div id="waRegStatus" style="display:none;" class="callout callout-info mb-3" style="font-size:.85rem;"></div>
<div id="waRegError" style="display:none;" class="callout callout-danger mb-3" style="font-size:.85rem;"></div>
<div class="input-group input-group-sm mb-2">
<input type="text" id="waKeyName" class="form-control"
placeholder="<?= $hasKeys ? 'Name des Backup-Schlüssels (z.B. YubiKey Zuhause)' : 'Name des Schlüssels (z.B. YubiKey Büro)' ?>"
value="<?= $hasKeys ? 'YubiKey Backup' : 'YubiKey' ?>" maxlength="60">
<div class="input-group-append">
<button id="waRegBtn" type="button" class="btn btn-primary" onclick="waRegisterStart()">
<i class="fas fa-plus mr-1"></i><?= $hasKeys ? 'Backup-Schlüssel hinzufügen' : 'Schlüssel registrieren' ?>
</button>
</div>
</div>
<small class="text-muted" style="font-size:.75rem;">
<?= $this->te('Stecke deinen YubiKey ein und klicke dann auf den Button.') ?>
</small>
</div>
<div class="card-footer text-muted" style="font-size:.78rem;">
<?= $this->te('Unterstützt YubiKey, alle FIDO2/WebAuthn-kompatiblen Sicherheitsschlüssel') ?>
</div>
</div>
</div><!-- /col WebAuthn -->
</div><!-- /row -->
<!-- ══════════════════════════════════════════════
Aktive Sitzungen
══════════════════════════════════════════════ -->
<div class="row">
<div class="col-lg-10">
<div class="card card-outline card-secondary">
<div class="card-header">
<h3 class="card-title">
<i class="fas fa-desktop text-secondary mr-2"></i><?= $this->te('Aktive Sitzungen') ?>
</h3>
<div class="card-tools">
<?php if (count($sessions ?? []) > 1): ?>
<form method="POST" action="/account/sessions/revoke-others" style="margin:0;"
onsubmit="return confirm('Alle anderen Sitzungen abmelden? Auf allen anderen Geräten musst du dich danach neu anmelden.')">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-xs btn-outline-danger">
<i class="fas fa-sign-out-alt mr-1"></i><?= $this->te('Überall sonst abmelden') ?>
</button>
</form>
<?php endif; ?>
</div>
</div>
<div class="card-body p-0">
<?php if (empty($sessions)): ?>
<div class="p-3 text-muted" style="font-size:.875rem;"><?= $this->te('Keine aktiven Sitzungen gefunden.') ?></div>
<?php else: ?>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th scope="col"><?= $this->te('Gerät') ?></th>
<th scope="col"><?= $this->te('IP-Adresse') ?></th>
<th scope="col"><?= $this->te('Angemeldet') ?></th>
<th scope="col"><?= $this->te('Letzte Aktivität') ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($sessions as $s): ?>
<tr>
<td>
<i class="fas fa-desktop text-muted mr-1"></i>
<?= htmlspecialchars($s['device'] ?? 'Unbekanntes Gerät') ?>
<?php if (!empty($s['is_current'])): ?>
<span class="badge badge-success ml-1"><?= $this->te('Diese Sitzung') ?></span>
<?php endif; ?>
</td>
<td><code style="font-size:.8rem;"><?= htmlspecialchars($s['ip_address'] ?? '') ?></code></td>
<td class="text-muted"><?= !empty($s['created_at']) ? date('d.m.Y H:i', strtotime($s['created_at'])) : '–' ?></td>
<td class="text-muted"><?= !empty($s['last_activity']) ? date('d.m.Y H:i', strtotime($s['last_activity'])) : '–' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<div class="card-footer text-muted" style="font-size:.78rem;">
<i class="fas fa-info-circle mr-1"></i>
<?= $this->te('Meldest du dich von einem neuen Gerät an, erhältst du automatisch eine Benachrichtigung. Kommt dir eine Sitzung unbekannt vor, melde alle anderen ab und ändere dein Passwort.') ?>
</div>
</div>
</div>
</div><!-- /row Sitzungen -->
</div><!-- /.mfa-body -->
</div><!-- /.mfa-wrap -->
<!-- Backup-Codes-Modal -->
<div id="backupCodesModal" class="modal" hidden role="dialog" aria-modal="true" aria-labelledby="backupCodesTitle">
<div class="modal-backdrop"></div>
<div class="modal-dialog" style="max-width:460px">
<div class="modal-header" style="background:var(--green-700);color:var(--color-on-accent);">
<h3 id="backupCodesTitle" style="color:var(--color-on-accent);"><?= $this->te('🔒 Backup-Codes') ?></h3>
</div>
<div class="modal-body">
<div class="callout callout-warning" style="margin-bottom:1rem;">
<?= $this->t('<strong>⚠ Wichtig – nur einmal sichtbar!</strong><br> Diese 10 Backup-Codes werden dir nur jetzt angezeigt. Speichere sie an einem sicheren Ort (Passwort-Manager, ausgedruckt in einem verschlossenen Umschlag o.ä.).<br> Jeder Code kann nur <strong>einmal</strong> zur Anmeldung verwendet werden.') ?>
</div>
<ul id="backupCodesList" style="
list-style:none;margin:0;padding:0;
display:grid;grid-template-columns:1fr 1fr;gap:.375rem .75rem;
font-family:monospace;font-size:1rem;font-weight:600;letter-spacing:.05em;
background:#f8f9fa;border:1px solid #dee2e6;border-radius:.375rem;padding:.75rem 1rem;
"></ul>
<div style="margin-top:1rem;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyBackupCodes()">
<?= $this->te('📋 Kopieren') ?>
</button>
<span id="backupCopyMsg" style="display:none;font-size:.8rem;color:var(--green-700);"><?= $this->te('✓ Kopiert!') ?></span>
<small style="color:var(--color-secondary);margin-left:auto;font-size:.75rem;"><?= $this->te('10 Codes • je einmalig verwendbar') ?></small>
</div>
<div style="margin-top:1.25rem;border-top:1px solid var(--color-gray-300);padding-top:.875rem;">
<button type="button" class="btn btn-success btn-block"
onclick="document.getElementById('backupCodesModal').setAttribute('hidden',''); window.location.reload();">
<?= $this->te('Ich habe die Codes gespeichert ✓') ?>
</button>
</div>
</div>
</div>
</div>
<script>
<?php if (!$totp_enabled && !empty($temp_secret)): ?>
function mfaCopySecret() {
var secret = <?= json_encode(str_replace(' ', '', $temp_secret ?? '')) ?>;
var msg = document.getElementById('mfaCopyMsg');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(secret).then(function() {
msg.style.display = 'block';
setTimeout(function() { msg.style.display = 'none'; }, 2000);
});
} else {
var ta = document.createElement('textarea');
ta.value = secret; ta.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
msg.style.display = 'block';
setTimeout(function() { msg.style.display = 'none'; }, 2000);
}
}
var codeEl = document.getElementById('mfaEnableCode');
if (codeEl) {
codeEl.addEventListener('input', function() {
var clean = this.value.replace(/\D/g, '');
this.value = clean;
if (clean.length === 6) document.getElementById('mfaEnableForm').submit();
});
}
<?php endif; ?>
// ── WebAuthn Registration ──────────────────────────────────────
function b64uToBuffer(b64u) {
var b64 = b64u.replace(/-/g,'+').replace(/_/g,'/');
while (b64.length % 4) b64 += '=';
var bin = atob(b64), buf = new Uint8Array(bin.length);
for (var i=0; i<bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
function bufToB64u(buf) {
var bytes = new Uint8Array(buf), bin = '';
bytes.forEach(function(b){ bin += String.fromCharCode(b); });
return btoa(bin).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
}
async function waRegisterStart() {
var btn = document.getElementById('waRegBtn');
var status = document.getElementById('waRegStatus');
var errEl = document.getElementById('waRegError');
var name = (document.getElementById('waKeyName').value.trim()) || 'Sicherheitsschlüssel';
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Warte auf Schlüssel…';
status.style.display = 'block';
status.textContent = 'Stecke deinen Schlüssel ein und berühre ihn, wenn er blinkt.';
errEl.style.display = 'none';
try {
// 1. Get challenge
var beginResp = await fetch('/account/mfa/webauthn/register/begin', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':csrf},
body: '{}'
});
if (!beginResp.ok) { var e=await beginResp.json(); throw new Error(e.error||'Server-Fehler'); }
var createArgs = await beginResp.json();
// 2. Decode binary fields
createArgs.publicKey.challenge = b64uToBuffer(createArgs.publicKey.challenge);
createArgs.publicKey.user.id = b64uToBuffer(createArgs.publicKey.user.id);
if (createArgs.publicKey.excludeCredentials) {
createArgs.publicKey.excludeCredentials = createArgs.publicKey.excludeCredentials.map(function(c){
return Object.assign({}, c, {id: b64uToBuffer(c.id)});
});
}
// 3. Prompt browser / key
var cred = await navigator.credentials.create(createArgs);
// 4. Send to server
var completeResp = await fetch('/account/mfa/webauthn/register/complete', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','X-CSRF-Token':csrf},
body: JSON.stringify({
deviceName: name,
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
attestationObject: bufToB64u(cred.response.attestationObject)
})
});
var result = await completeResp.json();
if (result.ok) {
if (result.backup_codes && result.backup_codes.length) {
showBackupCodesModal(result.backup_codes);
} else {
status.textContent = '✅ Schlüssel erfolgreich hinzugefügt! Seite wird neu geladen…';
setTimeout(function(){ window.location.reload(); }, 1200);
}
} else {
throw new Error(result.error || 'Registrierung fehlgeschlagen.');
}
} catch(err) {
status.style.display = 'none';
errEl.style.display = 'block';
errEl.textContent = err.name === 'NotAllowedError'
? 'Abgebrochen oder Zeitüberschreitung. Bitte erneut versuchen.'
: (err.message || 'Ein Fehler ist aufgetreten.');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-plus mr-1"></i>Schlüssel registrieren';
}
}
function showBackupCodesModal(codes) {
var modal = document.getElementById('backupCodesModal');
var list = document.getElementById('backupCodesList');
if (!modal || !list) { window.location.reload(); return; }
list.innerHTML = '';
codes.forEach(function(c) {
var li = document.createElement('li');
li.textContent = c;
list.appendChild(li);
});
modal.removeAttribute('hidden');
}
function copyBackupCodes() {
var items = document.querySelectorAll('#backupCodesList li');
var text = Array.from(items).map(function(li){ return li.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
document.getElementById('backupCopyMsg').style.display = 'inline';
setTimeout(function() { document.getElementById('backupCopyMsg').style.display = 'none'; }, 2000);
});
}
}
</script>