diff --git a/app/saas-auth.php b/app/saas-auth.php index b39a104..e3339fb 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -760,8 +760,17 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor try { $pdo->beginTransaction(); - $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?') - ->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]); + // Wer den per Mail zugestellten Link oeffnen konnte, hat den Zugriff + // auf das Postfach nachgewiesen - fachlich dasselbe wie eine + // Verifikation. Das betrifft vor allem eingeladene Mitglieder: die + // Zugangsvergabe laeuft ueber genau diesen Reset-Link, ohne dass je + // eine separate Verifikationsmail verschickt wird. + $pdo->prepare( + 'UPDATE users + SET password_hash = ?, + email_verified_at = COALESCE(email_verified_at, NOW()) + 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(); @@ -779,6 +788,97 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor return ['ok' => true, 'errors' => []]; } +/** + * Passwortwechsel fuer den bereits angemeldeten Nutzer. Bewusst getrennt vom + * Token-Reset: hier weist das aktuelle Passwort die Identitaet nach, nicht + * ein Mail-Link. Setzt anders als der Reset keine E-Mail-Verifikation, weil + * dabei nichts ueber das Postfach nachgewiesen wird. + */ +function saas_change_password( + PDO $pdo, + int $userId, + string $currentPassword, + string $password, + string $passwordConfirm +): array { + $errors = []; + if (strlen($password) < 8) { + $errors[] = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.'; + } + if ($password !== $passwordConfirm) { + $errors[] = 'Die Passwort-Wiederholung stimmt nicht.'; + } + if ($currentPassword !== '' && $password === $currentPassword) { + $errors[] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.'; + } + + if ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + $stmt = $pdo->prepare('SELECT password_hash, 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.']]; + } + + $hash = (string)($user['password_hash'] ?? ''); + if ($hash === '' || !password_verify($currentPassword, $hash)) { + return ['ok' => false, 'errors' => ['Das aktuelle Passwort ist nicht korrekt.']]; + } + + try { + $pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?') + ->execute([password_hash($password, PASSWORD_DEFAULT), $userId]); + } catch (Throwable $e) { + return ['ok' => false, 'errors' => ['Das Passwort konnte nicht gespeichert werden.']]; + } + + // Die eigene Session bleibt bestehen, bekommt aber eine neue ID, damit ein + // eventuell mitgelesener Session-Bezeichner nach dem Wechsel wertlos ist. + if (PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + + return ['ok' => true, 'errors' => []]; +} + +/** + * Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen + * geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen - + * siehe saas_require_verified_email(). + */ +function saas_email_verified(?array $user = null): bool +{ + $user = $user ?? saas_current_user(); + + return $user !== null && ($user['email_verified_at'] ?? null) !== null; +} + +/** + * Sperrt Aktionen, die aus dem Konto heraus nach aussen wirken (Mitglieder + * einladen, Massenmail, Jahresabschluss-Mails), solange die eigene Adresse + * nicht bestaetigt ist. Bewusst nicht auf den gesamten Login angewendet: die + * eigene Kaffeeliste darf man auch unbestaetigt fuehren, nur nicht im Namen + * einer moeglicherweise fremden Adresse Mails an Dritte ausloesen. + */ +function saas_require_verified_email(?array $user = null): array +{ + $user = $user ?? saas_require_login(); + + if (saas_email_verified($user)) { + return $user; + } + + http_response_code(403); + die( + 'Für diese Aktion muss die eigene E-Mail-Adresse bestätigt sein. ' + . 'Den Bestätigungslink kannst du unter "Kundenkonto" erneut anfordern.' + ); +} + function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array { $stmt = $pdo->prepare( diff --git a/docs/backlog-druck-und-landingpage.md b/docs/backlog-druck-und-landingpage.md index 3f83ac7..9e113e5 100644 --- a/docs/backlog-druck-und-landingpage.md +++ b/docs/backlog-druck-und-landingpage.md @@ -18,7 +18,9 @@ Vorschlag: Gruppierung in Abschnitte mit Zwischenüberschriften, z. B.: verwalten. - **Konto** – Kundenkonto, Mandant-Einstellungen, FAQ, Logout. -Noch nicht entschieden/umgesetzt. +**Erledigt.** `footer.php` gruppiert die Sidebar inzwischen in +„Kaffeeliste", „Verwaltung" (rollenabhängig eingeblendet) und „Konto"; +dort ist ab zwei Mitgliedschaften auch „Mandant wechseln" ergänzt. ## Kaffeeliste-Ausdruck (PDF-Export) als zentrale Funktion diff --git a/docs/m8-haertung.md b/docs/m8-haertung.md index dcee5cb..86646c5 100644 --- a/docs/m8-haertung.md +++ b/docs/m8-haertung.md @@ -207,4 +207,88 @@ Tabellen leer sind und die globale `users`-Zeile erhalten bleibt. ## Noch offen in M8 - Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles). -- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren. +- ~~Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.~~ + Erledigt in `docs/betrieb-backup-monitoring.md`. + +## Nachtrag: Kontofunktionen vor dem Go-Live (2026-08-06) + +Vier Lücken, die im Alltag sofort aufgefallen wären, geschlossen. + +Umgesetzte Dateien: + +```text +app/saas-auth.php (saas_change_password, saas_email_verified, + saas_require_verified_email) +konto.php (Formular "Passwort ändern") +mandant-auswahl.php(Wechsel aus bestehender Sitzung) +footer.php ("Mandant wechseln" ab zwei Mandanten) +header.php (Hinweisbanner bei unbestätigter Adresse) +login.php (Ziel nach Login) +mitarbeiterverwalten.php, mailversenden.php, jahresauswertung.php + (Verifikationspflicht) +scripts/check-konto-und-mandantenwechsel.php +``` + +### Passwortwechsel im eingeloggten Zustand + +Bisher gab es ausschließlich den Reset über einen Mail-Link; wer sein +Passwort ändern wollte, musste sich abmelden und „Passwort vergessen" +benutzen. `saas_change_password()` verlangt das aktuelle Passwort als +Identitätsnachweis, erzwingt dieselbe Mindestlänge wie der Reset und +verlangt ein tatsächlich anderes Passwort. Nach dem Wechsel wird die +Session-ID erneuert. + +**Bewusst nicht umgesetzt:** ein Abmelden aller anderen Sitzungen. Die +Sessions liegen als Dateien ohne Zuordnung zur User-ID; das bräuchte +einen eigenen Session-Store und lohnt beim aktuellen Umfang nicht. + +### Mandantenwechsel ohne Logout + +`mandant-auswahl.php` war nur direkt nach dem Login erreichbar (die Seite +stieg ohne `saas_pending_tenant_user_id()` sofort aus). Wer bei mehreren +Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite +bedient jetzt beide Wege; die Mandantenprüfung läuft unverändert über +`saas_identity_for_user_tenant()`, ein fremder Mandant wird also auch aus +einer bestehenden Sitzung heraus abgewiesen. Der Menüpunkt erscheint nur +ab zwei Mitgliedschaften. + +### E-Mail-Verifikation wird erzwungen + +`email_verified_at` wurde bisher nur angezeigt, nie geprüft — bei +öffentlicher Selbstregistrierung konnte sich also jemand mit einer +fremden Adresse anmelden und das Produkt voll nutzen. + +Erzwungen wird jetzt gezielt dort, wo eine Aktion **nach außen** wirkt: +Mitglieder einladen, Info-Mail an alle, Jahresabschluss mit +Benachrichtigung. Bei den beiden Mailflows bleibt der Dry-Run erlaubt, +die Einladung wird ganz abgewiesen. + +Bewusst **nicht** erzwungen wird der Login selbst: die eigene Kaffeeliste +darf man auch unbestätigt führen. Der Grund ist der migrierte Bestand — +eine harte Loginsperre hätte alle Nutzer ausgesperrt, deren +`email_verified_at` aus der Legacy-Migration `NULL` ist. + +Ergänzend setzt `saas_reset_password_with_token()` jetzt +`email_verified_at` mit, denn wer den zugestellten Link öffnen konnte, +hat den Zugriff auf das Postfach nachgewiesen. Das betrifft vor allem +eingeladene Mitglieder: deren Zugangsvergabe läuft über genau diesen +Link, ohne dass je eine separate Verifikationsmail verschickt wird — ohne +diese Ergänzung wären eingeladene Admins dauerhaft als unbestätigt +geführt worden. + +### Login-Ziel + +Nach dem Login ging es auf `konto.php`, also auf eine Stammdatentabelle. +Ziel ist jetzt `index.php`. + +### Prüfstatus + +`scripts/check-konto-und-mandantenwechsel.php` deckt alle vier Punkte ab +(18 Assertions, grün): Login-Ziel, abgewiesene und erlaubte Einladung, +Mandantenwechsel inklusive Negativfall fremder Mandant, alle vier +Fehlerfälle des Passwortwechsels plus echter Login mit altem und neuem +Passwort, und die Verifikation über den Reset-Link. Zusätzlich manuell +über das Formular in `konto.php` geprüft: Erfolgs- und Fehlermeldung, +CSRF-Schutz (419 ohne Token). Alle bestehenden Suiten bleiben grün +(HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, +M3-Auth 9, M3-Settings 15). diff --git a/footer.php b/footer.php index d92ce64..1cd1dc9 100644 --- a/footer.php +++ b/footer.php @@ -17,6 +17,19 @@ $saasCanManageMembersNav = $saasNavUser !== null && function_exists('saas_user_has_role') && saas_user_has_role(['owner', 'admin'], $saasNavUser); $canUseVerwaltungGroup = $saasCanUseLedgerNav; + +// Der Mandantenwechsel wird nur eingeblendet, wenn es ueberhaupt etwas zu +// wechseln gibt - bei einem einzigen Mandanten waere der Punkt nur Ballast. +$saasHasMultipleTenants = false; +if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) { + try { + $saasHasMultipleTenants = count( + saas_list_user_memberships(app_db_pdo(), (int)$saasNavUser['user_id']) + ) > 1; + } catch (Throwable $e) { + $saasHasMultipleTenants = false; + } +} ?>