Files
kaffeekasse-saas/scripts/check-konto-und-mandantenwechsel.php
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

339 lines
13 KiB
PHP

<?php
declare(strict_types=1);
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
}
/**
* Prueft die vier Kontofunktionen, die vor dem Go-Live ergaenzt wurden:
*
* 1. Passwortwechsel im eingeloggten Zustand (saas_change_password).
* 2. Mandantenwechsel aus einer bestehenden Sitzung heraus.
* 3. Erzwungene E-Mail-Verifikation fuer Aktionen, die nach aussen mailen.
* 4. Login landet auf dem Dashboard statt auf der Kontoseite.
*
* Braucht einen laufenden Dev-Server (scripts/run-dev-server.sh).
* Legt eigene Testmandanten an und raeumt sie am Ende wieder ab.
*/
require __DIR__ . '/dev-db.php';
require_once __DIR__ . '/../app/saas-auth.php';
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
$altesPasswort = 'KontoCheckAlt123!';
$neuesPasswort = 'KontoCheckNeu456!';
/**
* Minimaler Cookie-Jar auf Basis von file_get_contents, weil diese
* PHP-Installation keine curl-Extension hat - gleiches Vorgehen wie in
* scripts/check-m8-role-matrix.php.
*
* @param array<string,string> $cookies
* @return array{status:int, body:string, location:?string}
*/
function konto_request(string $url, array &$cookies, string $method = 'GET', ?string $postBody = null): array
{
$cookieHeader = '';
if ($cookies !== []) {
$pairs = [];
foreach ($cookies as $name => $value) {
$pairs[] = "{$name}={$value}";
}
$cookieHeader = 'Cookie: ' . implode('; ', $pairs) . "\r\n";
}
$headers = "User-Agent: KaffeelisteKontoCheck/1.0\r\n" . $cookieHeader;
if ($postBody !== null) {
$headers .= "Content-Type: application/x-www-form-urlencoded\r\n";
}
$context = stream_context_create([
'http' => [
'method' => $method,
'header' => $headers,
'content' => $postBody,
'timeout' => 15,
'ignore_errors' => true,
'follow_location' => 0,
],
]);
$body = @file_get_contents($url, false, $context);
$responseHeaders = $http_response_header ?? [];
$status = 0;
$location = null;
foreach ($responseHeaders as $header) {
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
$status = (int)$m[1];
}
if (preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $header, $m) === 1) {
$cookies[$m[1]] = $m[2];
}
if (preg_match('/^Location:\s*(.+)$/i', $header, $m) === 1) {
$location = trim($m[1]);
}
}
return ['status' => $status, 'body' => (string)$body, 'location' => $location];
}
function konto_follow_redirect(array $response, array &$cookies, string $baseUrl): array
{
if ($response['status'] < 300 || $response['status'] >= 400 || $response['location'] === null) {
return $response;
}
$location = (string)$response['location'];
if (preg_match('~^https?://~i', $location) === 1) {
$url = $location;
} else {
$url = $baseUrl . '/' . ltrim($location, '/');
}
return konto_request($url, $cookies);
}
/** @param array<string,string> $cookies */
function konto_csrf(string $url, array &$cookies): string
{
$page = konto_request($url, $cookies);
preg_match('/name="csrf_token" value="([^"]+)"/', $page['body'], $m);
return $m[1] ?? '';
}
$failures = [];
$passes = 0;
function pruefe(string $label, bool $ok): void
{
global $failures, $passes;
if ($ok) {
$passes++;
echo "PASS {$label}\n";
} else {
$failures[] = $label;
echo "FAIL {$label}\n";
}
}
$tenantAId = null;
$tenantBId = null;
$tenantCId = null;
$userId = null;
$participantId = null;
try {
// Zwei Mandanten fuer den Wechsel plus ein dritter, in dem der Testnutzer
// ausdruecklich kein Mitglied ist (Negativfall).
foreach (['a', 'b', 'c'] as $key) {
$stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)');
$stmt->execute(["kontocheck-{$key}-{$suffix}", "Kontocheck {$key}", 'active']);
$id = (int)$pdo->lastInsertId();
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$id]);
${'tenant' . strtoupper($key) . 'Id'} = $id;
}
$email = "kontocheck-{$suffix}@test.local";
$pdo->prepare(
'INSERT INTO users (email, email_norm, display_name, password_hash, status) VALUES (?, ?, ?, ?, ?)'
)->execute([$email, $email, 'Kontocheck Nutzer', password_hash($altesPasswort, PASSWORD_DEFAULT), 'active']);
$userId = (int)$pdo->lastInsertId();
foreach ([$tenantAId, $tenantBId] as $tid) {
$pdo->prepare(
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) VALUES (?, ?, ?, ?, NOW())'
)->execute([$tid, $userId, 'owner', 'active']);
}
// Teilnehmer in Mandant A, dem die Einladung gelten soll.
$einladungsMail = "kontocheck-invite-{$suffix}@test.local";
$pdo->prepare(
'INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)'
)->execute([$tenantAId, 'Einzuladendes Mitglied', $einladungsMail, $einladungsMail]);
$participantId = (int)$pdo->lastInsertId();
} catch (Throwable $e) {
fwrite(STDERR, "Kontocheck-Setup fehlgeschlagen: {$e->getMessage()}\n");
exit(1);
}
try {
// ---------------------------------------------------------------
// 4. Login landet auf dem Dashboard
// ---------------------------------------------------------------
$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,
]));
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
// ---------------------------------------------------------------
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$einladung = konto_follow_redirect(konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'aktion' => 'zugang_gewaehren',
'mitgliedID' => (string)$participantId,
'rolle' => 'member',
])), $cookies, $baseUrl);
pruefe(
'Einladung ohne bestaetigte Adresse wird abgelehnt',
str_contains($einladung['body'], 'bestätigt sein')
);
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ?');
$stmt->execute([$participantId]);
pruefe('Ohne Verifikation wurde kein Konto angelegt', (int)($stmt->fetchColumn() ?: 0) === 0);
// Adresse bestaetigen und dieselbe Aktion erneut versuchen.
$pdo->prepare('UPDATE users SET email_verified_at = NOW() WHERE id = ?')->execute([$userId]);
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
$einladung = konto_follow_redirect(konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'aktion' => 'zugang_gewaehren',
'mitgliedID' => (string)$participantId,
'rolle' => 'member',
])), $cookies, $baseUrl);
pruefe(
'Einladung mit bestaetigter Adresse funktioniert',
str_contains($einladung['body'], 'Zugang wurde gewährt')
);
// ---------------------------------------------------------------
// 2. Mandantenwechsel aus bestehender Sitzung
// ---------------------------------------------------------------
$auswahl = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies);
pruefe(
'Mandantenwechsel ist eingeloggt erreichbar (kein Redirect auf login.php)',
$auswahl['status'] === 200 && str_contains($auswahl['body'], 'Mandant wechseln')
);
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
$wechsel = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'tenant_id' => (string)$tenantBId,
]));
pruefe('Wechsel auf eigenen zweiten Mandanten leitet aufs Dashboard', $wechsel['location'] === 'index.php');
$konto = konto_request("{$baseUrl}/konto.php", $cookies);
pruefe(
'Nach dem Wechsel ist Mandant B aktiv',
str_contains($konto['body'], "kontocheck-b-{$suffix}")
);
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
$fremd = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'tenant_id' => (string)$tenantCId,
]));
pruefe(
'Wechsel auf fremden Mandanten wird abgelehnt',
$fremd['location'] === null && str_contains($fremd['body'], 'nicht verfügbar')
);
$konto = konto_request("{$baseUrl}/konto.php", $cookies);
pruefe(
'Nach abgelehntem Wechsel ist weiterhin Mandant B aktiv',
str_contains($konto['body'], "kontocheck-b-{$suffix}")
);
// ---------------------------------------------------------------
// 1. Passwortwechsel
// ---------------------------------------------------------------
$falsch = saas_change_password($pdo, $userId, 'VoelligFalsch999!', $neuesPasswort, $neuesPasswort);
pruefe('Falsches aktuelles Passwort wird abgelehnt', $falsch['ok'] === false);
$ungleich = saas_change_password($pdo, $userId, $altesPasswort, $neuesPasswort, 'tippfehler');
pruefe('Abweichende Wiederholung wird abgelehnt', $ungleich['ok'] === false);
$zuKurz = saas_change_password($pdo, $userId, $altesPasswort, 'kurz', 'kurz');
pruefe('Zu kurzes Passwort wird abgelehnt', $zuKurz['ok'] === false);
$gleich = saas_change_password($pdo, $userId, $altesPasswort, $altesPasswort, $altesPasswort);
pruefe('Unveraendertes Passwort wird abgelehnt', $gleich['ok'] === false);
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
$stmt->execute([$userId]);
pruefe(
'Nach allen Fehlversuchen gilt weiterhin das alte Passwort',
password_verify($altesPasswort, (string)$stmt->fetchColumn())
);
$ok = saas_change_password($pdo, $userId, $altesPasswort, $neuesPasswort, $neuesPasswort);
pruefe('Korrekter Passwortwechsel wird gespeichert', $ok['ok'] === true);
// Der Wechsel muss sich auch am echten Login zeigen.
$neueCookies = [];
$csrf = konto_csrf("{$baseUrl}/login.php", $neueCookies);
$altLogin = konto_request("{$baseUrl}/login.php", $neueCookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'email' => "kontocheck-{$suffix}@test.local",
'password' => $altesPasswort,
]));
pruefe('Login mit altem Passwort schlaegt fehl', $altLogin['location'] === null);
$neueCookies = [];
$csrf = konto_csrf("{$baseUrl}/login.php", $neueCookies);
$neuLogin = konto_request("{$baseUrl}/login.php", $neueCookies, 'POST', http_build_query([
'csrf_token' => $csrf,
'email' => "kontocheck-{$suffix}@test.local",
'password' => $neuesPasswort,
]));
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'mandant-auswahl.php');
// ---------------------------------------------------------------
// Reset-Link bestaetigt die Adresse mit
// ---------------------------------------------------------------
$pdo->prepare('UPDATE users SET email_verified_at = NULL WHERE id = ?')->execute([$userId]);
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantAId, 60);
$reset = saas_reset_password_with_token($pdo, $token['token'], 'ResetCheck789!', 'ResetCheck789!');
$stmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = ?');
$stmt->execute([$userId]);
pruefe(
'Passwort-Reset per Mail-Link bestaetigt die Adresse mit',
$reset['ok'] === true && $stmt->fetchColumn() !== null
);
} finally {
// Aufraeumen: Mitgliedschaften/Teilnehmer haengen per Fremdschluessel an
// den Mandanten, der Nutzer wird separat entfernt.
foreach ([$tenantAId, $tenantBId, $tenantCId] as $tid) {
if ($tid !== null) {
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tid]);
}
}
if ($userId !== null) {
$pdo->prepare('DELETE FROM user_auth_tokens WHERE user_id = ?')->execute([$userId]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
}
// Das eingeladene Mitglied bekam bei Erfolg ein eigenes Benutzerkonto.
$pdo->prepare('DELETE FROM users WHERE email_norm = ?')
->execute(["kontocheck-invite-{$suffix}@test.local"]);
}
if ($failures !== []) {
fwrite(STDERR, "\nKontocheck fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "\nKonto- und Mandantenwechsel-Check bestanden mit {$passes} Assertions.\n";