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' => [],
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user