diff --git a/app/saas-auth.php b/app/saas-auth.php index 4926dba..1644f76 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -24,6 +24,16 @@ function saas_html(mixed $value): string return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8'); } +function saas_should_show_auth_links(): bool +{ + return app_is_dev() || app_env('APP_SHOW_AUTH_LINKS', '0') === '1'; +} + +function saas_token_hash(string $token): string +{ + return hash('sha256', $token); +} + function saas_parse_money_cents(mixed $value): ?int { $normalized = trim((string)$value); @@ -85,6 +95,7 @@ function saas_current_user(?PDO $pdo = null): ?array u.email, u.email_norm, u.display_name, + u.email_verified_at, u.status AS user_status, t.id AS tenant_id, t.slug AS tenant_slug, @@ -321,6 +332,261 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr ]; } +function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = ''): ?array +{ + $emailNorm = saas_email_norm($email); + if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { + return null; + } + + $params = [$emailNorm, 'active', 'active', 'active']; + $tenantFilter = ''; + if (trim($tenantSlug) !== '') { + $tenantFilter = ' AND t.slug = ?'; + $params[] = saas_slugify($tenantSlug); + } + + $stmt = $pdo->prepare( + 'SELECT + u.id AS user_id, + u.email, + u.email_norm, + u.display_name, + u.status AS user_status, + u.email_verified_at, + t.id AS tenant_id, + t.slug AS tenant_slug, + t.name AS tenant_name, + tm.role + FROM users u + JOIN tenant_memberships tm ON tm.user_id = u.id + JOIN tenants t ON t.id = tm.tenant_id + WHERE u.email_norm = ? + AND u.status = ? + AND tm.status = ? + AND t.status = ?' . $tenantFilter . ' + ORDER BY + CASE tm.role + WHEN \'owner\' THEN 1 + WHEN \'admin\' THEN 2 + ELSE 3 + END, + t.name + LIMIT 1' + ); + $stmt->execute($params); + $identity = $stmt->fetch(); + + return $identity !== false ? $identity : null; +} + +function saas_create_auth_token(PDO $pdo, int $userId, string $tokenType, ?int $tenantId, int $ttlMinutes): array +{ + $token = bin2hex(random_bytes(32)); + + $pdo->prepare( + 'UPDATE user_auth_tokens + SET consumed_at = NOW() + WHERE user_id = ? + AND token_type = ? + AND consumed_at IS NULL' + )->execute([$userId, $tokenType]); + + $stmt = $pdo->prepare( + 'INSERT INTO user_auth_tokens (user_id, tenant_id, token_type, token_hash, expires_at) + VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))' + ); + $stmt->execute([$userId, $tenantId, $tokenType, saas_token_hash($token), $ttlMinutes]); + + return [ + 'token' => $token, + 'expires_at' => date('Y-m-d H:i:s', time() + ($ttlMinutes * 60)), + ]; +} + +function saas_fetch_valid_auth_token(PDO $pdo, string $token, string $tokenType): ?array +{ + if (!preg_match('/^[a-f0-9]{64}$/', $token)) { + return null; + } + + $stmt = $pdo->prepare( + 'SELECT + at.id, + at.user_id, + at.tenant_id, + at.token_type, + at.expires_at, + u.email, + u.email_norm, + u.display_name, + u.status AS user_status, + u.email_verified_at + FROM user_auth_tokens at + JOIN users u ON u.id = at.user_id + WHERE at.token_hash = ? + AND at.token_type = ? + AND at.consumed_at IS NULL + AND at.expires_at >= NOW() + AND u.status = ? + LIMIT 1' + ); + $stmt->execute([saas_token_hash($token), $tokenType, 'active']); + $row = $stmt->fetch(); + + return $row !== false ? $row : null; +} + +function saas_request_password_reset(PDO $pdo, string $email, string $tenantSlug = ''): array +{ + $emailNorm = saas_email_norm($email); + if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { + return [ + 'ok' => false, + 'errors' => ['Bitte eine gueltige E-Mail-Adresse eingeben.'], + ]; + } + + $identity = saas_find_auth_identity($pdo, $emailNorm, $tenantSlug); + if ($identity === null) { + return [ + 'ok' => true, + 'message' => 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.', + 'token' => null, + 'errors' => [], + ]; + } + + $token = saas_create_auth_token( + $pdo, + (int)$identity['user_id'], + 'password_reset', + (int)$identity['tenant_id'], + 60 + ); + + return [ + 'ok' => true, + 'message' => 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.', + 'token' => $token['token'], + 'expires_at' => $token['expires_at'], + 'errors' => [], + ]; +} + +function saas_reset_password_with_token(PDO $pdo, string $token, string $password, string $passwordConfirm): array +{ + $errors = []; + if (strlen($password) < 8) { + $errors[] = 'Das Passwort muss mindestens 8 Zeichen lang sein.'; + } + if ($password !== $passwordConfirm) { + $errors[] = 'Die Passwort-Wiederholung stimmt nicht.'; + } + + $tokenRow = saas_fetch_valid_auth_token($pdo, $token, 'password_reset'); + if ($tokenRow === null) { + $errors[] = 'Der Link ist ungueltig oder abgelaufen.'; + } + + if ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + try { + $pdo->beginTransaction(); + $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?') + ->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]); + $pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?') + ->execute([(int)$tokenRow['id']]); + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return [ + 'ok' => false, + 'errors' => ['Das Passwort konnte nicht gespeichert werden.'], + ]; + } + + return ['ok' => true, 'errors' => []]; +} + +function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array +{ + $stmt = $pdo->prepare( + 'SELECT id, email_verified_at, status + FROM users + WHERE id = ? + LIMIT 1' + ); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + if ($user === false || (string)$user['status'] !== 'active') { + return [ + 'ok' => false, + 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.'], + ]; + } + + if ($user['email_verified_at'] !== null) { + return [ + 'ok' => true, + 'already_verified' => true, + 'token' => null, + 'errors' => [], + ]; + } + + $token = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440); + + return [ + 'ok' => true, + 'already_verified' => false, + 'token' => $token['token'], + 'expires_at' => $token['expires_at'], + 'errors' => [], + ]; +} + +function saas_verify_email_token(PDO $pdo, string $token): array +{ + $tokenRow = saas_fetch_valid_auth_token($pdo, $token, 'email_verification'); + if ($tokenRow === null) { + return [ + 'ok' => false, + 'errors' => ['Der Link ist ungueltig oder abgelaufen.'], + ]; + } + + try { + $pdo->beginTransaction(); + $pdo->prepare('UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = ?') + ->execute([(int)$tokenRow['user_id']]); + $pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?') + ->execute([(int)$tokenRow['id']]); + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return [ + 'ok' => false, + 'errors' => ['Die E-Mail-Adresse konnte nicht bestaetigt werden.'], + ]; + } + + return [ + 'ok' => true, + 'email' => (string)$tokenRow['email'], + 'errors' => [], + ]; +} + function saas_authenticate(PDO $pdo, string $email, string $password, string $tenantSlug = ''): array { $emailNorm = saas_email_norm($email); @@ -521,6 +787,8 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array ); $stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]); + $verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440); + $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { @@ -545,6 +813,7 @@ function saas_register_tenant_owner(PDO $pdo, array $input): array 'tenant_name' => $tenantName, 'role' => 'owner', ], + 'email_verification_token' => $verificationToken['token'], 'errors' => [], ]; } diff --git a/database/migrations/0004_saas_auth_tokens.sql b/database/migrations/0004_saas_auth_tokens.sql new file mode 100644 index 0000000..9f96359 --- /dev/null +++ b/database/migrations/0004_saas_auth_tokens.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS user_auth_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + tenant_id INT NULL, + token_type VARCHAR(40) NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + consumed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_user_auth_tokens_hash (token_hash), + KEY idx_user_auth_tokens_user_type (user_id, token_type, consumed_at, expires_at), + KEY idx_user_auth_tokens_tenant (tenant_id), + CONSTRAINT fk_user_auth_tokens_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE CASCADE, + CONSTRAINT fk_user_auth_tokens_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/m2-technical-foundation.md b/docs/m2-technical-foundation.md index ae25824..b696e79 100644 --- a/docs/m2-technical-foundation.md +++ b/docs/m2-technical-foundation.md @@ -140,8 +140,9 @@ Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`, eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`. - CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen. - Golden Master weiterhin gruen mit 104 Assertions. -- HTTP-Smoke weiterhin gruen mit 17 sicheren Seiten inklusive Login, - Registrierung und geschuetzter Mandant-Einstellungen. +- HTTP-Smoke weiterhin gruen mit 20 sicheren Seiten inklusive Login, + Registrierung, Passwort-Reset, E-Mail-Verifikation und geschuetzter + Mandant-Einstellungen. ## Bewusste Grenzen diff --git a/docs/m3-saas-basis-vorbereitung.md b/docs/m3-saas-basis-vorbereitung.md index b4eefb7..d7b485c 100644 --- a/docs/m3-saas-basis-vorbereitung.md +++ b/docs/m3-saas-basis-vorbereitung.md @@ -21,7 +21,7 @@ das neue Modell umgestellt. - Keine Migration von Einzahlungen und Kaffeeverbrauch nach `ledger_entries`; das gehoert zu M4. - Kein Design- oder Layout-Umbau. -- Kein Billing, keine Tarife, keine E-Mail-Verifikation im ersten Schritt. +- Kein Billing und keine Tarife im ersten Schritt. - Kein produktiver Tenant-Wechsel in bestehenden Legacy-Seiten. ## Umgesetzter erster Schritt @@ -31,6 +31,7 @@ Umgesetzte Migrationen: ```text database/migrations/0002_saas_identity_tenants.sql database/migrations/0003_saas_auth_account_fields.sql +database/migrations/0004_saas_auth_tokens.sql ``` Tabellen: @@ -48,6 +49,7 @@ scripts/backfill-default-tenant.php scripts/check-m3-saas-basis.php scripts/check-m3-auth-flow.php scripts/check-m3-settings-flow.php +scripts/check-m3-password-email-flow.php ``` Wichtige Regeln: @@ -118,6 +120,10 @@ login.php register.php konto.php logout.php +passwort-vergessen.php +passwort-zuruecksetzen.php +email-verifikation-senden.php +email-verifizieren.php ``` Umgesetzter Umfang: @@ -131,13 +137,17 @@ Umgesetzter Umfang: laesst `DEV_AUTH_EMAIL` und `AUTH_USER` aber als Legacy-Fallback bestehen. - `footer.php` ist fuer nicht angemeldete Public-Seiten sicher und zeigt Links zu Login und Registrierung. +- Passwort-Reset erzeugt Single-Use-Tokens und speichert nur Token-Hashes. +- E-Mail-Verifikation erzeugt Single-Use-Tokens und setzt + `users.email_verified_at`. +- Bis zum produktiven Mailversand werden Links nur im Dev-Modus angezeigt + beziehungsweise in CLI-Checks direkt verwendet. Noch offen im M3-Auth-Scope: -- Passwort-Reset. -- E-Mail-Verifikation. - Tenant-Aufloesung ueber Subdomain oder Custom Domain. - Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten. +- Produktiver Mailversand fuer Reset- und Verifikationslinks. ## Rollen und Grundeinstellungen @@ -171,6 +181,8 @@ Umgesetzter Umfang: - Registrierung und Login funktionieren fuer einen neuen Test-Tenant: erfuellt. - Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt. +- Passwort-Reset und E-Mail-Verifikation funktionieren mit Single-Use-Tokens: + erfuellt. - Golden-Master und HTTP-Smoke bleiben gruen: erfuellt. ## Risiken @@ -194,6 +206,9 @@ Umgesetzter Umfang: 7. Auth-Flow-Kontrollskript schreiben: erledigt. 8. Zentrale Rollenpruefung und Mandant-Einstellungen bauen: erledigt. 9. Settings-Flow-Kontrollskript schreiben: erledigt. -10. Golden-Master und HTTP-Smoke ausfuehren: erledigt. -11. Passwort-Reset, E-Mail-Verifikation und Tenant-Aufloesung planen: +10. Migration `0004_saas_auth_tokens.sql` erstellen: erledigt. +11. Passwort-Reset und E-Mail-Verifikation bauen: erledigt. +12. Password-/E-Mail-Flow-Kontrollskript schreiben: erledigt. +13. Golden-Master und HTTP-Smoke ausfuehren: erledigt. +14. Tenant-Aufloesung planen: naechster Schritt. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 1171e4c..6f054e6 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -420,7 +420,8 @@ Schritte: - Registrierung: Tenant + Owner-User + Default-Settings erzeugen: erster Flow erledigt. - Login und Logout bauen: erster Flow erledigt. -- Passwort-Reset und E-Mail-Verifikation bauen. +- Passwort-Reset und E-Mail-Verifikation bauen: Dev-Flow mit Single-Use-Tokens + erledigt. - Tenant-Aufloesung definieren. - Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt. - Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt. diff --git a/email-verifikation-senden.php b/email-verifikation-senden.php new file mode 100644 index 0000000..0472cdb --- /dev/null +++ b/email-verifikation-senden.php @@ -0,0 +1,66 @@ + + + + + diff --git a/email-verifizieren.php b/email-verifizieren.php new file mode 100644 index 0000000..8c3e268 --- /dev/null +++ b/email-verifizieren.php @@ -0,0 +1,38 @@ + false, 'errors' => ['Der Link ist ungueltig oder abgelaufen.']]; + +include 'header.php'; +include 'headerline.php'; +include 'nav.php'; +?> + + + + diff --git a/footer.php b/footer.php index d2e153a..33c0e78 100644 --- a/footer.php +++ b/footer.php @@ -38,6 +38,13 @@ $saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null if ($saasNavUser !== null) { ?>
Das Kundenkonto wurde angelegt.
Der Verifizierungslink wurde vorbereitet.
+ +| Name | @@ -38,8 +50,19 @@ include 'nav.php';Rolle | |
|---|---|---|
| E-Mail bestaetigt | ++ |