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>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
<?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";
|
||||
Reference in New Issue
Block a user