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:
2026-07-13 19:26:58 +02:00
parent 19ff11c21a
commit 8ab4a8a9bb
15 changed files with 740 additions and 9 deletions
+269
View File
@@ -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' => [],
];
}