OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

mfa.php

10 KB
<?php $this->layout('layouts/auth', [
    'page_title' => 'Zwei-Faktor-Authentifizierung',
    // Mit zwei Varianten stehen zwei Umschalt-Knöpfe nebeneinander – dafür
    // braucht die Karte mehr Breite als das Standard-Anmeldefenster.
    'auth_wide'  => !empty($has_totp ?? true) && !empty($has_webauthn ?? false),
]); ?>

<div class="auth-box-body">
    <p class="auth-box-msg">
        <?= $this->t('<strong>Zwei-Faktor-Authentifizierung</strong><br> <small style="color:var(--color-secondary);">Bestätige deine Identität mit einem zweiten Faktor.</small>') ?>
    </p>

    <?php if (!empty($error)): ?>
        <div class="auth-alert auth-alert-error"><?= $this->e($error) ?></div>
    <?php endif; ?>

    <?php
    $hasTotp     = $has_totp     ?? true;
    $hasWebauthn = $has_webauthn ?? false;
    // If both available, start with WebAuthn (more seamless UX)
    $defaultTab  = $hasWebauthn ? 'webauthn' : 'totp';
    ?>

    <?php if ($hasTotp && $hasWebauthn): ?>
    <!-- Tab switcher -->
    <div style="display:flex;gap:.5rem;margin-bottom:1.25rem;">
        <button id="tabBtnWebauthn" onclick="mfaTab('webauthn')"
                class="btn btn-sm btn-block"
                style="flex:1;border:1px solid var(--color-gray-300);border-radius:.25rem;padding:.4rem .5rem;font-size:.82rem;background:var(--color-primary);color:var(--color-on-accent);">
            <?= $this->te('🔑 Sicherheitsschlüssel') ?>
        </button>
        <button id="tabBtnTotp" onclick="mfaTab('totp')"
                class="btn btn-sm btn-block"
                style="flex:1;border:1px solid var(--color-gray-300);border-radius:.25rem;padding:.4rem .5rem;font-size:.82rem;background:var(--color-bg);color:var(--color-gray-700);">
            <?= $this->te('📱 Authenticator-App') ?>
        </button>
    </div>
    <?php endif; ?>

    <!-- ── WebAuthn panel ── -->
    <?php if ($hasWebauthn): ?>
    <div id="panelWebauthn" <?= $defaultTab !== 'webauthn' ? 'style="display:none;"' : '' ?>>
        <div style="text-align:center;padding:.75rem 0 1.25rem;">
            <div id="waIcon" style="font-size:2.5rem;margin-bottom:.5rem;line-height:1;">🔑</div>
            <p id="waStatus" style="font-size:.875rem;color:var(--color-secondary);margin:0;">
                <?= $this->te('Klicke auf den Button und berühre dann deinen Sicherheitsschlüssel.') ?>
            </p>
            <div id="waError" class="auth-alert auth-alert-error" style="display:none;margin-top:.75rem;text-align:left;"></div>
        </div>
        <button id="waBtn" type="button" class="btn btn-primary btn-block" onclick="mfaWebauthnStart()">
            <?= $this->te('Sicherheitsschlüssel verwenden') ?>
        </button>
    </div>
    <?php endif; ?>

    <!-- ── TOTP panel ── -->
    <?php if ($hasTotp): ?>
    <div id="panelTotp" <?= $defaultTab !== 'totp' ? 'style="display:none;"' : '' ?>>
        <form method="POST" action="/auth/mfa" id="mfaForm">
            <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
            <div class="auth-input-group" style="text-align:center;">
                <input type="text" name="code" id="mfaCode"
                       class="form-control" inputmode="numeric" pattern="\d{6}"
                       maxlength="6" placeholder="000000" required
                       style="font-size:1.8rem;letter-spacing:.35em;text-align:center;font-weight:600;"
                       autocomplete="one-time-code" autofocus>
                <small style="color:var(--color-secondary);font-size:.78rem;"><?= $this->te('6-stelliger Code aus deiner Authenticator-App') ?></small>
            </div>
        </form>
    </div>
    <?php endif; ?>

    <!-- Backup-Code-Option -->
    <div style="text-align:center;margin-top:1rem;border-top:1px solid var(--color-gray-300);padding-top:.75rem;">
        <a href="#" id="showBackupCodeLink" style="font-size:.78rem;color:var(--color-secondary);"
           onclick="document.getElementById('backupCodePanel').style.display='';this.style.display='none';return false;">
            <?= $this->te('Backup-Code verwenden') ?>
        </a>
        <div id="backupCodePanel" style="display:none;margin-top:.75rem;">
            <form method="POST" action="/auth/mfa/backup" id="backupForm">
                <input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
                <input type="text" name="backup_code" class="form-control"
                       placeholder="XXXX-XXXX"
                       maxlength="9"
                       style="text-align:center;letter-spacing:.1em;font-weight:600;text-transform:uppercase;"
                       autocomplete="off">
                <button type="submit" class="btn btn-outline-secondary btn-block" style="margin-top:.5rem;font-size:.85rem;">
                    <?= $this->te('Mit Backup-Code anmelden') ?>
                </button>
                <small style="color:var(--color-secondary);font-size:.75rem;"><?= $this->te('Jeder Backup-Code kann nur einmal verwendet werden.') ?></small>
            </form>
        </div>
    </div>
    <div style="text-align:center;margin-top:.75rem;">
        <a href="/auth/login" style="font-size:.82rem;color:var(--color-secondary);">
            <?= $this->te('&#8592; Zurück zur Anmeldung') ?>
        </a>
    </div>
</div>

<script>
<?php if ($hasTotp): ?>
// Auto-submit TOTP when 6 digits entered
var codeEl = document.getElementById('mfaCode');
if (codeEl) {
    codeEl.addEventListener('input', function() {
        var clean = this.value.replace(/\D/g,'');
        this.value = clean;
        if (clean.length === 6) document.getElementById('mfaForm').submit();
    });
}
<?php endif; ?>

<?php if ($hasTotp && $has_webauthn): ?>
function mfaTab(tab) {
    document.getElementById('panelWebauthn').style.display = tab === 'webauthn' ? '' : 'none';
    document.getElementById('panelTotp').style.display     = tab === 'totp'     ? '' : 'none';
    document.getElementById('tabBtnWebauthn').style.background = tab === 'webauthn' ? '#3c8dbc' : '#fff';
    document.getElementById('tabBtnWebauthn').style.color      = tab === 'webauthn' ? '#fff'    : '#495057';
    document.getElementById('tabBtnTotp').style.background     = tab === 'totp'     ? '#3c8dbc' : '#fff';
    document.getElementById('tabBtnTotp').style.color          = tab === 'totp'     ? '#fff'    : '#495057';
    if (tab === 'totp' && document.getElementById('mfaCode')) {
        document.getElementById('mfaCode').focus();
    }
}
<?php endif; ?>

<?php if ($has_webauthn): ?>
function base64urlToBuffer(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 bufferToBase64url(buf) {
    var bytes = new Uint8Array(buf);
    var bin   = '';
    bytes.forEach(function(b) { bin += String.fromCharCode(b); });
    return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

async function mfaWebauthnStart() {
    var btn    = document.getElementById('waBtn');
    var status = document.getElementById('waStatus');
    var errEl  = document.getElementById('waError');
    var icon   = document.getElementById('waIcon');
    var csrf   = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';

    btn.disabled = true;
    btn.textContent = 'Warte auf Schlüssel…';
    status.textContent = 'Berühre jetzt deinen Sicherheitsschlüssel.';
    icon.textContent = '⏳';
    errEl.style.display = 'none';

    try {
        // 1. Get challenge from server
        var beginResp = await fetch('/auth/webauthn/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 beim Starten der Authentifizierung.');
        }
        var getArgs = await beginResp.json();

        // 2. Decode binary fields
        getArgs.publicKey.challenge = base64urlToBuffer(getArgs.publicKey.challenge);
        if (getArgs.publicKey.allowCredentials) {
            getArgs.publicKey.allowCredentials = getArgs.publicKey.allowCredentials.map(function(c) {
                return Object.assign({}, c, {id: base64urlToBuffer(c.id)});
            });
        }

        // 3. Prompt user to touch key
        var assertion = await navigator.credentials.get(getArgs);

        // 4. Send assertion to server
        var completeResp = await fetch('/auth/webauthn/complete', {
            method: 'POST',
            headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrf},
            body: JSON.stringify({
                id:                bufferToBase64url(assertion.rawId),
                clientDataJSON:    bufferToBase64url(assertion.response.clientDataJSON),
                authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
                signature:         bufferToBase64url(assertion.response.signature)
            })
        });
        var result = await completeResp.json();
        if (result.ok) {
            icon.textContent = '✅';
            status.textContent = 'Erfolgreich! Weiterleitung…';
            window.location.href = result.redirect || '/dashboard';
        } else {
            throw new Error(result.error || 'Authentifizierung fehlgeschlagen.');
        }
    } catch (err) {
        if (err.name === 'NotAllowedError') {
            errEl.textContent = 'Abgebrochen oder Zeitüberschreitung. Bitte erneut versuchen.';
        } else {
            errEl.textContent = err.message || 'Ein Fehler ist aufgetreten.';
        }
        errEl.style.display = 'block';
        icon.textContent = '🔑';
        status.textContent = 'Klicke auf den Button und berühre dann deinen Sicherheitsschlüssel.';
        btn.disabled = false;
        btn.textContent = 'Sicherheitsschlüssel verwenden';
    }
}
<?php endif; ?>
</script>