M3 Passwort-Reset und E-Mail-Verifikation ergänzen
- Auth-Token-Tabelle mit gehashten Single-Use-Tokens einführen - Passwort-Reset und E-Mail-Verifikation als Dev-Flow bauen - Token-Flow-Test, Smoke-Abdeckung und M3-Dokumentation aktualisieren
This commit is contained in:
@@ -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' => [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$user = saas_require_login();
|
||||
$errors = [];
|
||||
$message = '';
|
||||
$verificationLink = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$result = saas_request_email_verification($pdo, (int)$user['user_id'], (int)$user['tenant_id']);
|
||||
|
||||
if ($result['ok']) {
|
||||
if (!empty($result['already_verified'])) {
|
||||
$message = 'Die E-Mail-Adresse ist bereits bestaetigt.';
|
||||
} else {
|
||||
$message = 'Der Verifizierungslink wurde vorbereitet.';
|
||||
if (saas_should_show_auth_links() && !empty($result['token'])) {
|
||||
$verificationLink = 'email-verifizieren.php?token=' . urlencode((string)$result['token']);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$errors = $result['errors'];
|
||||
}
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>E-Mail verifizieren</h2>
|
||||
|
||||
<?php if ($message !== ''): ?>
|
||||
<div class="hint-box success">
|
||||
<p><?php echo saas_html($message); ?></p>
|
||||
<?php if ($verificationLink !== null): ?>
|
||||
<p><a href="<?php echo saas_html($verificationLink); ?>">Dev-Link zur Verifizierung</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="hint-box error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<p><?php echo saas_html($error); ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="email-verifikation-senden.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<p><?php echo saas_html($user['email']); ?></p>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Verifizierungslink vorbereiten</button></li>
|
||||
<li><a href="konto.php" class="button alt">Zurueck</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$token = trim((string)($_GET['token'] ?? ''));
|
||||
$result = $token !== ''
|
||||
? saas_verify_email_token($pdo, $token)
|
||||
: ['ok' => false, 'errors' => ['Der Link ist ungueltig oder abgelaufen.']];
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>E-Mail verifizieren</h2>
|
||||
|
||||
<?php if ($result['ok']): ?>
|
||||
<div class="hint-box success"><p>Die E-Mail-Adresse wurde bestaetigt.</p></div>
|
||||
<ul class="actions">
|
||||
<li><a href="konto.php" class="button">Zum Konto</a></li>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<div class="hint-box error">
|
||||
<?php foreach ($result['errors'] as $error): ?>
|
||||
<p><?php echo saas_html($error); ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><a href="login.php" class="button">Zum Login</a></li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
@@ -38,6 +38,13 @@ $saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null
|
||||
if ($saasNavUser !== null) {
|
||||
?>
|
||||
<li><a href="konto.php">Kundenkonto</a></li>
|
||||
<?php
|
||||
if (($saasNavUser['email_verified_at'] ?? null) === null) {
|
||||
?>
|
||||
<li><a href="email-verifikation-senden.php">E-Mail verifizieren</a></li>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
<?php
|
||||
if (function_exists('saas_can_manage_tenant_settings') && saas_can_manage_tenant_settings($saasNavUser)) {
|
||||
?>
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$user = saas_require_login();
|
||||
$devVerificationToken = null;
|
||||
if (saas_should_show_auth_links() && !empty($_SESSION['saas_dev_email_verification_token'])) {
|
||||
$devVerificationToken = (string)$_SESSION['saas_dev_email_verification_token'];
|
||||
unset($_SESSION['saas_dev_email_verification_token']);
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
@@ -17,6 +22,13 @@ include 'nav.php';
|
||||
<div class="hint-box success"><p>Das Kundenkonto wurde angelegt.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($devVerificationToken !== null): ?>
|
||||
<div class="hint-box success">
|
||||
<p>Der Verifizierungslink wurde vorbereitet.</p>
|
||||
<p><a href="email-verifizieren.php?token=<?php echo saas_html($devVerificationToken); ?>">Dev-Link zur E-Mail-Verifizierung</a></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -38,8 +50,19 @@ include 'nav.php';
|
||||
<th>Rolle</th>
|
||||
<td><?php echo saas_html($user['role']); ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>E-Mail bestaetigt</th>
|
||||
<td><?php echo $user['email_verified_at'] !== null ? 'ja' : 'nein'; ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<?php if ($user['email_verified_at'] === null): ?>
|
||||
<form method="post" action="email-verifikation-senden.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<button type="submit">E-Mail-Verifizierung vorbereiten</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (saas_can_manage_tenant_settings($user)): ?>
|
||||
<ul class="actions">
|
||||
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
||||
|
||||
@@ -66,6 +66,7 @@ include 'nav.php';
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Einloggen</button></li>
|
||||
<li><a href="register.php" class="button alt">Registrieren</a></li>
|
||||
<li><a href="passwort-vergessen.php" class="button alt">Passwort vergessen</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
$tenantSlug = trim((string)($_POST['tenant_slug'] ?? ''));
|
||||
$errors = [];
|
||||
$message = '';
|
||||
$resetLink = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$result = saas_request_password_reset($pdo, $email, $tenantSlug);
|
||||
|
||||
if ($result['ok']) {
|
||||
$message = $result['message'];
|
||||
if (saas_should_show_auth_links() && !empty($result['token'])) {
|
||||
$resetLink = 'passwort-zuruecksetzen.php?token=' . urlencode((string)$result['token']);
|
||||
}
|
||||
} else {
|
||||
$errors = $result['errors'];
|
||||
}
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>Passwort vergessen</h2>
|
||||
|
||||
<?php if ($message !== ''): ?>
|
||||
<div class="hint-box success">
|
||||
<p><?php echo saas_html($message); ?></p>
|
||||
<?php if ($resetLink !== null): ?>
|
||||
<p><a href="<?php echo saas_html($resetLink); ?>">Dev-Link zum Zuruecksetzen</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="hint-box error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<p><?php echo saas_html($error); ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="passwort-vergessen.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<div class="row">
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="email">E-Mail</label>
|
||||
<input type="email" name="email" id="email" value="<?php echo saas_html($email); ?>" required>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="tenant_slug">Kundenkuerzel</label>
|
||||
<input type="text" name="tenant_slug" id="tenant_slug" value="<?php echo saas_html($tenantSlug); ?>" placeholder="optional">
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Link vorbereiten</button></li>
|
||||
<li><a href="login.php" class="button alt">Zum Login</a></li>
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$token = trim((string)($_POST['token'] ?? $_GET['token'] ?? ''));
|
||||
$errors = [];
|
||||
$success = false;
|
||||
$tokenValid = $token !== '' && saas_fetch_valid_auth_token($pdo, $token, 'password_reset') !== null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
app_require_csrf();
|
||||
$result = saas_reset_password_with_token(
|
||||
$pdo,
|
||||
$token,
|
||||
(string)($_POST['password'] ?? ''),
|
||||
(string)($_POST['password_confirm'] ?? '')
|
||||
);
|
||||
|
||||
if ($result['ok']) {
|
||||
$success = true;
|
||||
$tokenValid = false;
|
||||
} else {
|
||||
$errors = $result['errors'];
|
||||
$tokenValid = $token !== '' && saas_fetch_valid_auth_token($pdo, $token, 'password_reset') !== null;
|
||||
}
|
||||
}
|
||||
|
||||
include 'header.php';
|
||||
include 'headerline.php';
|
||||
include 'nav.php';
|
||||
?>
|
||||
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
<h2>Passwort zuruecksetzen</h2>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="hint-box success"><p>Das Passwort wurde geaendert.</p></div>
|
||||
<ul class="actions">
|
||||
<li><a href="login.php" class="button">Zum Login</a></li>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<?php if ($errors !== []): ?>
|
||||
<div class="hint-box error">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<p><?php echo saas_html($error); ?></p>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php elseif (!$tokenValid): ?>
|
||||
<div class="hint-box error"><p>Der Link ist ungueltig oder abgelaufen.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tokenValid): ?>
|
||||
<form method="post" action="passwort-zuruecksetzen.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<input type="hidden" name="token" value="<?php echo saas_html($token); ?>">
|
||||
<div class="row">
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input type="password" name="password" id="password" minlength="8" required>
|
||||
</div>
|
||||
<div class="col-6 col-12-small">
|
||||
<label for="password_confirm">Passwort wiederholen</label>
|
||||
<input type="password" name="password_confirm" id="password_confirm" minlength="8" required>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="actions">
|
||||
<li><button type="submit">Passwort speichern</button></li>
|
||||
</ul>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<ul class="actions">
|
||||
<li><a href="passwort-vergessen.php" class="button">Neuen Link anfordern</a></li>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php include 'footer.php'; ?>
|
||||
@@ -25,6 +25,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
|
||||
if ($result['ok']) {
|
||||
saas_session_login($result['identity']);
|
||||
if (saas_should_show_auth_links() && !empty($result['email_verification_token'])) {
|
||||
$_SESSION['saas_dev_email_verification_token'] = (string)$result['email_verification_token'];
|
||||
}
|
||||
header('Location: konto.php?registered=1');
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/dev-db.php';
|
||||
require __DIR__ . '/../app/saas-auth.php';
|
||||
|
||||
function password_email_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";
|
||||
}
|
||||
|
||||
function password_email_check_cleanup(PDO $pdo, string $slug, string $emailNorm): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
|
||||
$stmt->execute([$slug]);
|
||||
$tenantId = $stmt->fetchColumn();
|
||||
|
||||
if ($tenantId !== false) {
|
||||
$tenantId = (int)$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 email_norm = ?')->execute([$emailNorm]);
|
||||
}
|
||||
|
||||
$pdo = dev_pdo();
|
||||
dev_apply_schema($pdo);
|
||||
|
||||
$slug = 'm3-password-email-check';
|
||||
$email = 'm3-password-email-check@test.local';
|
||||
$emailNorm = saas_email_norm($email);
|
||||
$password = 'M3-password-email-123';
|
||||
$newPassword = 'M3-password-email-456';
|
||||
$failures = [];
|
||||
$passes = 0;
|
||||
|
||||
password_email_check_cleanup($pdo, $slug, $emailNorm);
|
||||
|
||||
$registration = saas_register_tenant_owner($pdo, [
|
||||
'tenant_name' => 'M3 Password Email Check',
|
||||
'tenant_slug' => $slug,
|
||||
'display_name' => 'M3 Password Email',
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'password_confirm' => $password,
|
||||
]);
|
||||
|
||||
password_email_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
|
||||
password_email_check_assert('registration returns verification token', !empty($registration['email_verification_token']), $failures, $passes);
|
||||
|
||||
if ($registration['ok']) {
|
||||
$userId = (int)$registration['identity']['user_id'];
|
||||
|
||||
$stmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
password_email_check_assert('email starts unverified', $stmt->fetchColumn() === null, $failures, $passes);
|
||||
|
||||
$verification = saas_verify_email_token($pdo, (string)$registration['email_verification_token']);
|
||||
password_email_check_assert('email verification succeeds', $verification['ok'] === true, $failures, $passes);
|
||||
|
||||
$stmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
password_email_check_assert('email is verified', $stmt->fetchColumn() !== null, $failures, $passes);
|
||||
|
||||
$verificationAgain = saas_verify_email_token($pdo, (string)$registration['email_verification_token']);
|
||||
password_email_check_assert('email verification token is single use', $verificationAgain['ok'] === false, $failures, $passes);
|
||||
|
||||
$resetRequest = saas_request_password_reset($pdo, $email, $slug);
|
||||
password_email_check_assert('password reset request succeeds', $resetRequest['ok'] === true, $failures, $passes);
|
||||
password_email_check_assert('password reset returns token', !empty($resetRequest['token']), $failures, $passes);
|
||||
|
||||
$reset = saas_reset_password_with_token(
|
||||
$pdo,
|
||||
(string)$resetRequest['token'],
|
||||
$newPassword,
|
||||
$newPassword
|
||||
);
|
||||
password_email_check_assert('password reset succeeds', $reset['ok'] === true, $failures, $passes);
|
||||
|
||||
$oldLogin = saas_authenticate($pdo, $email, $password, $slug);
|
||||
password_email_check_assert('old password is rejected', $oldLogin['ok'] === false, $failures, $passes);
|
||||
|
||||
$newLogin = saas_authenticate($pdo, $email, $newPassword, $slug);
|
||||
password_email_check_assert('new password works', $newLogin['ok'] === true, $failures, $passes);
|
||||
|
||||
$reuse = saas_reset_password_with_token(
|
||||
$pdo,
|
||||
(string)$resetRequest['token'],
|
||||
'M3-password-email-789',
|
||||
'M3-password-email-789'
|
||||
);
|
||||
password_email_check_assert('password reset token is single use', $reuse['ok'] === false, $failures, $passes);
|
||||
}
|
||||
|
||||
$unknownRequest = saas_request_password_reset($pdo, 'unknown-password-email@test.local', $slug);
|
||||
password_email_check_assert('unknown reset request is generic success', $unknownRequest['ok'] === true, $failures, $passes);
|
||||
password_email_check_assert('unknown reset request has no token', empty($unknownRequest['token']), $failures, $passes);
|
||||
|
||||
password_email_check_cleanup($pdo, $slug, $emailNorm);
|
||||
|
||||
if ($failures !== []) {
|
||||
echo "\nM3 password/email flow check failed with " . count($failures) . " failure(s):\n";
|
||||
foreach ($failures as $failure) {
|
||||
echo "- {$failure}\n";
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\nM3 password/email flow check passed with {$passes} assertions.\n";
|
||||
+16
-1
@@ -54,13 +54,28 @@ $checks = [
|
||||
[
|
||||
'label' => 'Login',
|
||||
'path' => 'login.php',
|
||||
'contains' => ['Login', 'Registrieren'],
|
||||
'contains' => ['Login', 'Registrieren', 'Passwort vergessen'],
|
||||
],
|
||||
[
|
||||
'label' => 'Registrierung',
|
||||
'path' => 'register.php',
|
||||
'contains' => ['Registrierung', 'Kundenkonto anlegen'],
|
||||
],
|
||||
[
|
||||
'label' => 'Passwort vergessen',
|
||||
'path' => 'passwort-vergessen.php',
|
||||
'contains' => ['Passwort vergessen', 'Link vorbereiten'],
|
||||
],
|
||||
[
|
||||
'label' => 'Passwort zuruecksetzen Invalid',
|
||||
'path' => 'passwort-zuruecksetzen.php',
|
||||
'contains' => ['Passwort zuruecksetzen', 'ungueltig oder abgelaufen'],
|
||||
],
|
||||
[
|
||||
'label' => 'E-Mail verifizieren Invalid',
|
||||
'path' => 'email-verifizieren.php',
|
||||
'contains' => ['E-Mail verifizieren', 'ungueltig oder abgelaufen'],
|
||||
],
|
||||
[
|
||||
'label' => 'Mandant-Einstellungen Login-Schutz',
|
||||
'path' => 'mandant-einstellungen.php',
|
||||
|
||||
Reference in New Issue
Block a user