$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 $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";