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,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Prueft den zweiten Faktor am echten Anmeldeweg: dass der Login mit
|
||||
* aktivem TOTP nicht durchgestellt wird, dass der Zwischenzustand keinerlei
|
||||
* Zugriff gewaehrt und dass erst der richtige Code die Sitzung herstellt.
|
||||
*
|
||||
* Braucht einen laufenden Webserver auf dem Projektverzeichnis:
|
||||
*
|
||||
* php -S 127.0.0.1:8080 -t .
|
||||
* php scripts/check-2fa-http-flow.php
|
||||
*
|
||||
* Abweichende Adresse ueber SMOKE_BASE_URL. Die algorithmische Seite deckt
|
||||
* scripts/check-totp-flow.php ohne Server ab.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
require __DIR__ . '/../app/totp.php';
|
||||
|
||||
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
|
||||
|
||||
/**
|
||||
* @param array<string, string> $jar
|
||||
* @return array{status: int, location: ?string, body: string}
|
||||
*/
|
||||
function zfa_request(string $url, array &$jar, string $method = 'GET', ?string $body = null): array
|
||||
{
|
||||
$headers = ['Content-Type: application/x-www-form-urlencoded'];
|
||||
if ($jar !== []) {
|
||||
$pairs = [];
|
||||
foreach ($jar as $name => $value) {
|
||||
$pairs[] = "{$name}={$value}";
|
||||
}
|
||||
$headers[] = 'Cookie: ' . implode('; ', $pairs);
|
||||
}
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => $method,
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $body,
|
||||
'timeout' => 15,
|
||||
'ignore_errors' => true,
|
||||
'follow_location' => 0,
|
||||
]]);
|
||||
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
$status = 0;
|
||||
$location = null;
|
||||
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
||||
$status = (int)$m[1];
|
||||
}
|
||||
if (stripos($header, 'Location:') === 0) {
|
||||
$location = trim(substr($header, 9));
|
||||
}
|
||||
if (stripos($header, 'Set-Cookie:') === 0 && preg_match('/^Set-Cookie:\s*([^=]+)=([^;]*)/i', $header, $m) === 1) {
|
||||
$jar[$m[1]] = $m[2];
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => $status, 'location' => $location, 'body' => (string)$responseBody];
|
||||
}
|
||||
|
||||
/** @param array<string, string> $jar */
|
||||
function zfa_csrf(string $url, array &$jar): string
|
||||
{
|
||||
$response = zfa_request($url, $jar);
|
||||
preg_match('/name="csrf_token" value="([^"]+)"/', $response['body'], $m);
|
||||
|
||||
return $m[1] ?? '';
|
||||
}
|
||||
|
||||
$failures = [];
|
||||
$passes = 0;
|
||||
|
||||
function zfa_assert(string $label, bool $condition, array &$failures, int &$passes): void
|
||||
{
|
||||
if ($condition) {
|
||||
$passes++;
|
||||
echo "PASS {$label}\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$failures[] = $label;
|
||||
echo "FAIL {$label}\n";
|
||||
}
|
||||
|
||||
$probe = [];
|
||||
$reachable = zfa_request("{$baseUrl}/login.php", $probe);
|
||||
if ($reachable['status'] !== 200) {
|
||||
fwrite(STDERR, "Kein Webserver unter {$baseUrl} erreichbar. Siehe Kopf dieser Datei.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdo = dev_pdo();
|
||||
$suffix = bin2hex(random_bytes(4));
|
||||
$email = "zfacheck-{$suffix}@example.com";
|
||||
$password = 'geheim-genug-123';
|
||||
|
||||
$registration = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => "2FA Check {$suffix}",
|
||||
'tenant_slug' => "zfacheck-{$suffix}",
|
||||
'display_name' => '2FA Checker',
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_confirm' => $password,
|
||||
'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 {
|
||||
// Ausgangslage: ohne zweiten Faktor fuehrt der Login direkt weiter.
|
||||
$jar = [];
|
||||
$csrf = zfa_csrf("{$baseUrl}/login.php", $jar);
|
||||
$login = zfa_request("{$baseUrl}/login.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf, 'email' => $email, 'password' => $password,
|
||||
]));
|
||||
zfa_assert('Ohne zweiten Faktor fuehrt der Login direkt auf index.php', $login['location'] === 'index.php', $failures, $passes);
|
||||
|
||||
$secret = (string)app_totp_begin_setup($pdo, $userId);
|
||||
app_totp_confirm_setup($pdo, $userId, app_totp_code($secret, intdiv(time(), APP_TOTP_PERIOD)));
|
||||
|
||||
// Ab hier ist der zweite Faktor scharf.
|
||||
$jar = [];
|
||||
$csrf = zfa_csrf("{$baseUrl}/login.php", $jar);
|
||||
$login = zfa_request("{$baseUrl}/login.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf, 'email' => $email, 'password' => $password,
|
||||
]));
|
||||
zfa_assert('Mit zweitem Faktor fuehrt der Login auf zwei-faktor.php', $login['location'] === 'zwei-faktor.php', $failures, $passes);
|
||||
|
||||
// Der Zwischenzustand darf sich in nichts von "gar nicht angemeldet"
|
||||
// unterscheiden - das ist der Kern der ganzen Uebung.
|
||||
$pending = zfa_request("{$baseUrl}/index.php", $jar);
|
||||
$anonymousJar = [];
|
||||
$anonymous = zfa_request("{$baseUrl}/index.php", $anonymousJar);
|
||||
zfa_assert(
|
||||
'Der Zwischenzustand liefert dasselbe wie ein unangemeldeter Aufruf',
|
||||
$pending['status'] === $anonymous['status'] && strlen($pending['body']) === strlen($anonymous['body']),
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
zfa_assert(
|
||||
'Der Zwischenzustand zeigt keine Mandantendaten',
|
||||
!str_contains($pending['body'], "2FA Check {$suffix}"),
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
$csrf = zfa_csrf("{$baseUrl}/zwei-faktor.php", $jar);
|
||||
$wrong = zfa_request("{$baseUrl}/zwei-faktor.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf, 'code' => '000000',
|
||||
]));
|
||||
zfa_assert(
|
||||
'Falscher Code wird abgewiesen',
|
||||
$wrong['location'] === null && str_contains($wrong['body'], 'stimmt nicht'),
|
||||
$failures,
|
||||
$passes
|
||||
);
|
||||
|
||||
// Ein Zeitschritt weiter, weil der Einrichtungscode schon verbraucht ist.
|
||||
$csrf = zfa_csrf("{$baseUrl}/zwei-faktor.php", $jar);
|
||||
$right = zfa_request("{$baseUrl}/zwei-faktor.php", $jar, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf,
|
||||
'code' => app_totp_code($secret, intdiv(time(), APP_TOTP_PERIOD) + 1),
|
||||
]));
|
||||
zfa_assert('Richtiger Code schliesst die Anmeldung ab', $right['location'] === 'index.php', $failures, $passes);
|
||||
|
||||
$dashboard = zfa_request("{$baseUrl}/index.php", $jar);
|
||||
zfa_assert('Nach dem Code ist das Dashboard erreichbar', $dashboard['status'] === 200, $failures, $passes);
|
||||
|
||||
$strangerJar = [];
|
||||
$stranger = zfa_request("{$baseUrl}/zwei-faktor.php", $strangerJar);
|
||||
zfa_assert(
|
||||
'zwei-faktor.php ohne angefangene Anmeldung leitet zum Login',
|
||||
$stranger['location'] === 'login.php',
|
||||
$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]);
|
||||
$pdo->prepare('DELETE FROM rate_limit_attempts WHERE bucket LIKE ?')->execute(['2fa_%']);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
if ($failures !== []) {
|
||||
fwrite(STDERR, '2FA-HTTP-Check fehlgeschlagen: ' . implode('; ', $failures) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "2FA-HTTP-Check bestanden mit {$passes} Zusicherungen.\n";
|
||||
@@ -169,13 +169,21 @@ try {
|
||||
// ---------------------------------------------------------------
|
||||
$cookies = [];
|
||||
$csrf = konto_csrf("{$baseUrl}/login.php", $cookies);
|
||||
// Das Login-Formular kennt kein Kundenkuerzel mehr: wer bei mehreren
|
||||
// Mandanten Mitglied ist, waehlt nach dem Passwort per Klarnamen aus.
|
||||
$login = konto_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf,
|
||||
'email' => "kontocheck-{$suffix}@test.local",
|
||||
'password' => $altesPasswort,
|
||||
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||
]));
|
||||
pruefe('Login leitet auf index.php statt konto.php', $login['location'] === 'index.php');
|
||||
pruefe('Login mit mehreren Mandanten fuehrt zur Mandantenauswahl', $login['location'] === 'mandant-auswahl.php');
|
||||
|
||||
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
|
||||
$ersteWahl = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
|
||||
'csrf_token' => $csrf,
|
||||
'tenant_id' => (string)$tenantAId,
|
||||
]));
|
||||
pruefe('Mandantenwahl nach dem Login leitet auf index.php statt konto.php', $ersteWahl['location'] === 'index.php');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 3. Verifikation wird fuer Einladungen erzwungen
|
||||
@@ -281,7 +289,6 @@ try {
|
||||
'csrf_token' => $csrf,
|
||||
'email' => "kontocheck-{$suffix}@test.local",
|
||||
'password' => $altesPasswort,
|
||||
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||
]));
|
||||
pruefe('Login mit altem Passwort schlaegt fehl', $altLogin['location'] === null);
|
||||
|
||||
@@ -291,9 +298,8 @@ try {
|
||||
'csrf_token' => $csrf,
|
||||
'email' => "kontocheck-{$suffix}@test.local",
|
||||
'password' => $neuesPasswort,
|
||||
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||
]));
|
||||
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'index.php');
|
||||
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'mandant-auswahl.php');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Reset-Link bestaetigt die Adresse mit
|
||||
|
||||
@@ -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