Files
kaffeekasse-saas/scripts/check-totp-flow.php
T
clemensandClaude Opus 5 a2dcc4df65 Zwei-Faktor-Anmeldung per TOTP
app/totp.php implementiert RFC 6238 selbst statt per Bibliothek: der
Algorithmus ist ein HMAC plus eine Truncation, und ein zweiter Faktor ist
die letzte Stelle fuer ungepruefte Abhaengigkeiten. Der QR-Code entsteht
aus dem ohnehin vorhandenen TCPDF, damit kein externer Dienst das
Geheimnis sieht.

Beim Login wird die Anmeldung bei aktivem zweitem Faktor nicht
abgeschlossen; der Zwischenzustand gewaehrt keinerlei Zugriff und ist
byte-identisch zu einem unangemeldeten Aufruf. users.totp_last_step
verhindert die Wiederverwendung eines abgefangenen Codes innerhalb seines
Gueltigkeitsfensters. Abschalten verlangt Passwort und Code.

APP_REQUIRE_2FA_FOR_ADMINS macht den Faktor fuer Platform-Admins
verbindlich, per Weiterleitung auf die Einrichtung statt als harte Sperre
- sonst koennte der Schalter den einzigen Admin aussperren. Standard aus.

check-konto-und-mandantenwechsel erwartete beim Login noch das entfernte
Kundenkuerzel-Feld und damit einen direkten Sprung aufs Dashboard; der
Check bildet jetzt den tatsaechlichen Weg ueber die Mandantenauswahl ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:51:45 +02:00

265 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prueft den zweiten Faktor von der Codeerzeugung bis zum Einloesen eines
* Wiederherstellungscodes. Legt dafuer einen Wegwerf-Mandanten an und raeumt
* ihn hinterher wieder ab.
*/
require __DIR__ . '/dev-db.php';
require __DIR__ . '/../app/totp.php';
function totp_check_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
$failures = [];
$passes = 0;
// --- Algorithmus gegen die Testvektoren aus RFC 6238 ---------------------
// Der dortige Schluessel ist die ASCII-Folge "12345678901234567890".
$rfcSecret = app_totp_base32_encode('12345678901234567890');
$vectors = [
59 => '287082',
1111111109 => '081804',
1111111111 => '050471',
1234567890 => '005924',
2000000000 => '279037',
];
foreach ($vectors as $timestamp => $expected) {
totp_check_assert(
"RFC-6238-Testvektor bei t={$timestamp}",
app_totp_code($rfcSecret, intdiv($timestamp, APP_TOTP_PERIOD)) === $expected,
$failures,
$passes
);
}
// --- Base32 hin und zurueck ---------------------------------------------
$binary = random_bytes(20);
totp_check_assert(
'Base32 kodiert und dekodiert verlustfrei',
app_totp_base32_decode(app_totp_base32_encode($binary)) === $binary,
$failures,
$passes
);
totp_check_assert(
'Von Hand abgetipptes Geheimnis mit Leerzeichen wird verstanden',
app_totp_base32_decode(trim(chunk_split(app_totp_base32_encode($binary), 4, ' '))) === $binary,
$failures,
$passes
);
// --- Toleranzfenster -----------------------------------------------------
$secret = app_totp_generate_secret();
$now = time();
$step = intdiv($now, APP_TOTP_PERIOD);
totp_check_assert(
'Aktueller Code wird angenommen',
app_totp_verify_code($secret, app_totp_code($secret, $step), $now) === $step,
$failures,
$passes
);
totp_check_assert(
'Code des vorigen Zeitschritts wird noch angenommen',
app_totp_verify_code($secret, app_totp_code($secret, $step - 1), $now) === $step - 1,
$failures,
$passes
);
totp_check_assert(
'Zu alter Code wird abgelehnt',
app_totp_verify_code($secret, app_totp_code($secret, $step - 5), $now) === null,
$failures,
$passes
);
totp_check_assert(
'Falscher Code wird abgelehnt',
app_totp_verify_code($secret, '000000', $now) === null || app_totp_code($secret, $step) === '000000',
$failures,
$passes
);
totp_check_assert(
'Nicht-numerische Eingabe wird abgelehnt',
app_totp_verify_code($secret, 'abcdef', $now) === null,
$failures,
$passes
);
// --- QR-Code und URI -----------------------------------------------------
$uri = app_totp_provisioning_uri($secret, 'test@example.com');
totp_check_assert(
'Provisioning-URI traegt Geheimnis und Aussteller',
str_starts_with($uri, 'otpauth://totp/') && str_contains($uri, 'secret=' . $secret) && str_contains($uri, 'issuer=Kaffeeliste'),
$failures,
$passes
);
$svg = app_totp_qr_svg($uri);
totp_check_assert(
'QR-Code entsteht als einbettbares SVG',
str_starts_with($svg, '<svg') && !str_contains($svg, '<?xml'),
$failures,
$passes
);
// --- Vollstaendiger Ablauf gegen die Datenbank ---------------------------
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$slug = "totpcheck-{$suffix}";
$email = "totpcheck-{$suffix}@example.com";
$registration = saas_register_tenant_owner($pdo, [
'tenant_name' => "TOTP Check {$suffix}",
'tenant_slug' => $slug,
'display_name' => 'TOTP Checker',
'email' => $email,
'password' => 'geheim-genug-123',
'password_confirm' => 'geheim-genug-123',
'customer_type' => 'business',
'accept_terms' => true,
'acknowledge_privacy' => true,
'accept_dpa' => true,
]);
if (empty($registration['ok'])) {
fwrite(STDERR, "Setup fehlgeschlagen: " . implode('; ', $registration['errors'] ?? []) . "\n");
exit(1);
}
$userId = (int)$registration['identity']['user_id'];
$tenantId = (int)$registration['identity']['tenant_id'];
try {
totp_check_assert('Frisches Konto hat keinen zweiten Faktor', !app_totp_is_active($pdo, $userId), $failures, $passes);
$setupSecret = app_totp_begin_setup($pdo, $userId);
totp_check_assert('Einrichtung liefert ein Geheimnis', is_string($setupSecret) && $setupSecret !== '', $failures, $passes);
totp_check_assert(
'Angefangene Einrichtung ist noch nicht scharf',
!app_totp_is_active($pdo, $userId),
$failures,
$passes
);
totp_check_assert(
'Bestaetigung mit falschem Code schlaegt fehl',
app_totp_confirm_setup($pdo, $userId, '000000') === null
|| app_totp_code((string)$setupSecret, intdiv(time(), APP_TOTP_PERIOD)) === '000000',
$failures,
$passes
);
$currentStep = intdiv(time(), APP_TOTP_PERIOD);
$recoveryCodes = app_totp_confirm_setup($pdo, $userId, app_totp_code((string)$setupSecret, $currentStep));
totp_check_assert(
'Bestaetigung mit richtigem Code liefert Wiederherstellungscodes',
is_array($recoveryCodes) && count($recoveryCodes) === APP_TOTP_RECOVERY_CODE_COUNT,
$failures,
$passes
);
totp_check_assert('Zweiter Faktor ist jetzt aktiv', app_totp_is_active($pdo, $userId), $failures, $passes);
// Der bei der Einrichtung benutzte Zeitschritt darf nicht noch einmal gehen.
totp_check_assert(
'Bereits eingeloester Code wird nicht wiederverwendet',
!app_totp_verify_for_user($pdo, $userId, app_totp_code((string)$setupSecret, $currentStep)),
$failures,
$passes
);
totp_check_assert(
'Naechster Code wird angenommen',
app_totp_verify_for_user($pdo, $userId, app_totp_code((string)$setupSecret, $currentStep + 1)),
$failures,
$passes
);
// --- Wiederherstellungscodes ---
$codes = (array)$recoveryCodes;
totp_check_assert(
'Alle Wiederherstellungscodes sind zunaechst unbenutzt',
app_totp_unused_recovery_code_count($pdo, $userId) === APP_TOTP_RECOVERY_CODE_COUNT,
$failures,
$passes
);
totp_check_assert(
'Wiederherstellungscode wird angenommen',
app_totp_consume_recovery_code($pdo, $userId, (string)$codes[0]),
$failures,
$passes
);
totp_check_assert(
'Derselbe Wiederherstellungscode gilt kein zweites Mal',
!app_totp_consume_recovery_code($pdo, $userId, (string)$codes[0]),
$failures,
$passes
);
totp_check_assert(
'Wiederherstellungscode wird auch mit Grossbuchstaben und ohne Bindestrich erkannt',
app_totp_consume_recovery_code($pdo, $userId, strtoupper(str_replace('-', '', (string)$codes[1]))),
$failures,
$passes
);
totp_check_assert(
'Erfundener Wiederherstellungscode wird abgelehnt',
!app_totp_consume_recovery_code($pdo, $userId, 'zzzz-zzzz'),
$failures,
$passes
);
totp_check_assert(
'Verbrauchte Codes sind abgezogen',
app_totp_unused_recovery_code_count($pdo, $userId) === APP_TOTP_RECOVERY_CODE_COUNT - 2,
$failures,
$passes
);
// --- Passwortbestaetigung und Abschalten ---
totp_check_assert(
'Richtiges Passwort wird bestaetigt',
saas_password_matches($pdo, $userId, 'geheim-genug-123'),
$failures,
$passes
);
totp_check_assert(
'Falsches Passwort wird abgelehnt',
!saas_password_matches($pdo, $userId, 'falsch'),
$failures,
$passes
);
app_totp_disable($pdo, $userId);
totp_check_assert('Abschalten deaktiviert den zweiten Faktor', !app_totp_is_active($pdo, $userId), $failures, $passes);
totp_check_assert(
'Abschalten entfernt die Wiederherstellungscodes',
app_totp_unused_recovery_code_count($pdo, $userId) === 0,
$failures,
$passes
);
} finally {
$pdo->prepare('DELETE FROM totp_recovery_codes WHERE user_id = ?')->execute([$userId]);
$pdo->prepare('DELETE FROM legal_acceptances WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
}
echo "\n";
if ($failures !== []) {
fwrite(STDERR, 'TOTP-Check fehlgeschlagen: ' . implode('; ', $failures) . "\n");
exit(1);
}
echo "TOTP-Check bestanden mit {$passes} Zusicherungen.\n";