dsfdsf
All checks were successful
Deploy / deploy (push) Successful in 59s

This commit is contained in:
2026-07-31 21:33:04 +02:00
parent 92442a57c7
commit 002108ac83
10 changed files with 348 additions and 35 deletions

View File

@@ -104,7 +104,6 @@ final class AccountPages
public static function verify(App $app): array
{
$pdo = $app->pdo();
$flash = $app->flash()->get();
$error = '';
$info = '';
@@ -119,20 +118,19 @@ final class AccountPages
if ($action === 'resend') {
try {
$stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt?->execute(['email' => $email]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
$row = $auth->findUserMetaByEmail($email);
if (!$row) {
throw new \RuntimeException('E-Mail nicht gefunden.');
}
$userId = (int)$row['id'];
$codeNew = $auth->createVerifyCode($userId, $email);
$mailer->sendTemplate('registration_resend_code', $email, [
$storedEmail = $auth->getEmailByUserId($userId);
$codeNew = $auth->createVerifyCode($userId, $storedEmail);
$mailer->sendTemplate('registration_resend_code', $storedEmail, [
'code' => $codeNew,
'display_name' => $row['display_name'] ?? '',
]);
$info = 'Neuer Code wurde versendet.';
$_SESSION['verify_email'] = $email;
$_SESSION['verify_email'] = $storedEmail;
} catch (\Throwable $e) {
$error = $e->getMessage();
}
@@ -141,7 +139,11 @@ final class AccountPages
$userId = $auth->verifyCode($email, $code);
$_SESSION['user_id'] = $userId;
unset($_SESSION['verify_email']);
$mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]);
$storedEmail = $auth->getEmailByUserId($userId);
$meta = $auth->findUserMetaByEmail($storedEmail);
$mailer->sendTemplate('registration_welcome', $storedEmail, [
'display_name' => $meta['display_name'] ?? $storedEmail,
]);
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
redirect('/dashboard');
} catch (\Throwable $e) {
@@ -471,7 +473,7 @@ final class AccountPages
static fn(string $column): string => 'p.' . $column,
AvatarManager::allProfileColumns()
));
$stmt = $pdo?->prepare("SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.street, p.zip, p.city, p.region, p.lat, p.lng, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1");
$stmt = $pdo?->prepare("SELECT u.status, p.display_name, p.first_name, p.last_name, p.street, p.zip, p.city, p.region, p.lat, p.lng, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference, $avatarColumns FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1");
$stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) {
@@ -488,6 +490,7 @@ final class AccountPages
);
}
}
$profile['email'] = (new Auth($app))->getEmailByUserId($userId);
$profile = AvatarManager::normalizeProfile($profile, $userId);
$editChildId = isset($_GET['edit_child']) ? (int)$_GET['edit_child'] : 0;

View File

@@ -5,6 +5,8 @@ namespace App;
final class Auth
{
private ?UserEmailStore $emailStore = null;
public function __construct(private App $app) {}
private function pdo(): \PDO
@@ -16,6 +18,16 @@ final class Auth
return $pdo;
}
private function emailStore(): UserEmailStore
{
if ($this->emailStore === null) {
$this->emailStore = new UserEmailStore($this->pdo());
$this->emailStore->ensureSchema();
}
return $this->emailStore;
}
public function register(string $displayName, string $email, string $password): int
{
$pdo = $this->pdo();
@@ -26,18 +38,18 @@ final class Auth
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
}
$emailStore = $this->emailStore();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
if ($stmt->fetchColumn()) {
if ($emailStore->findUserByEmail($email, 'id')) {
throw new \RuntimeException('E-Mail ist bereits registriert.');
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())');
$stmt = $pdo->prepare('INSERT INTO users (email, email_lookup_hash, password_hash, status, created_at, updated_at) VALUES (:email, :lookup, :pw, :status, NOW(), NOW())');
$stmt->execute([
'email' => $email,
'email' => $emailStore->encrypt($email),
'lookup' => $emailStore->lookupHash($email),
'pw' => $hash,
'status' => 'pending',
]);
@@ -80,11 +92,12 @@ final class Auth
public function verifyCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'verify', 'email' => $email]);
$lookupHash = $this->emailStore()->lookupHash($email);
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email_lookup_hash = :lookup AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'verify', 'lookup' => $lookupHash]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
@@ -109,17 +122,17 @@ final class Auth
public function createResetCode(string $email): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$row = $this->emailStore()->findUserByEmail($email, 'id');
if (!$row) {
throw new \RuntimeException('E-Mail ist nicht registriert.');
}
$userId = (int)$row['id'];
$displayName = (string)($row['display_name'] ?? $email);
$stmt = $pdo->prepare('SELECT display_name FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => $userId]);
$displayName = (string)($stmt->fetchColumn() ?: $email);
$code = $this->generateCode(6);
$hash = hash('sha256', $code);
@@ -138,11 +151,12 @@ final class Auth
public function verifyResetCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'reset', 'email' => $email]);
$lookupHash = $this->emailStore()->lookupHash($email);
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email_lookup_hash = :lookup AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'reset', 'lookup' => $lookupHash]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
@@ -191,11 +205,9 @@ final class Auth
public function login(string $email, string $password): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$email = $this->emailStore()->normalize($email);
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$row = $this->emailStore()->findUserByEmail($email, 'id, password_hash, status');
if (!$row) {
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
@@ -214,4 +226,23 @@ final class Auth
return ['id' => $userId, 'status' => $status];
}
public function findUserMetaByEmail(string $email): ?array
{
$row = $this->emailStore()->findUserByEmail($email, 'id, status');
if (!$row) {
return null;
}
$stmt = $this->pdo()->prepare('SELECT display_name FROM user_profiles WHERE user_id = :id LIMIT 1');
$stmt->execute(['id' => (int)$row['id']]);
$row['display_name'] = (string)($stmt->fetchColumn() ?: '');
return $row;
}
public function getEmailByUserId(int $userId): string
{
return $this->emailStore()->getEmailByUserId($userId);
}
}

View File

@@ -7,11 +7,22 @@ final class CommunityAccess
{
private array $tableCache = [];
private array $columnCache = [];
private ?UserEmailStore $emailStore = null;
public function __construct(private \PDO $pdo, private array $communityConfig)
{
}
private function emailStore(): UserEmailStore
{
if ($this->emailStore === null) {
$this->emailStore = new UserEmailStore($this->pdo);
$this->emailStore->ensureSchema();
}
return $this->emailStore;
}
public function getUserRoles(int $userId): array
{
if ($userId <= 0) {
@@ -182,7 +193,7 @@ final class CommunityAccess
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
return $this->emailStore()->decryptRowEmails($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []);
}
public function decideApplication(int $adminUserId, int $applicationId, string $decision, ?string $reason = null): void
@@ -273,7 +284,7 @@ final class CommunityAccess
LEFT JOIN user_profiles up ON up.user_id = ur.user_id
ORDER BY FIELD(ur.role, "owner", "site_admin", "forum_admin"), ur.assigned_at ASC
');
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
return $this->emailStore()->decryptRowEmails($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [], 'user_id');
}
public function setRestriction(int $actingUserId, int $targetUserId, string $type, string $reason): void

247
src/App/UserEmailStore.php Normal file
View File

@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App;
final class UserEmailStore
{
private bool $schemaEnsured = false;
private ?Crypto $crypto = null;
private string $lookupKey;
public function __construct(private \PDO $pdo)
{
$configDir = dirname(__DIR__, 2) . '/config';
$this->crypto = new Crypto(Config::fromPhpConstants($configDir));
$this->lookupKey = $this->buildLookupKey();
}
public function ensureSchema(): void
{
if ($this->schemaEnsured) {
return;
}
if (!$this->hasColumn('users', 'email_lookup_hash')) {
$this->pdo->exec('ALTER TABLE users ADD COLUMN email_lookup_hash CHAR(64) NULL AFTER email');
}
if ($this->getColumnDataType('users', 'email') !== 'varbinary') {
$this->pdo->exec('ALTER TABLE users MODIFY COLUMN email VARBINARY(512) NOT NULL');
}
$this->migrateEmails();
$this->dropUniqueIndexOnEmailColumn();
$this->ensureLookupHashIndex();
$this->schemaEnsured = true;
}
public function normalize(string $email): string
{
return strtolower(trim($email));
}
public function lookupHash(string $email): string
{
return hash_hmac('sha256', $this->normalize($email), $this->lookupKey);
}
public function encrypt(string $email): string
{
return $this->crypto->encrypt($this->normalize($email));
}
public function decrypt(?string $value): string
{
$raw = (string)($value ?? '');
if ($raw === '') {
return '';
}
if (Crypto::looksEncrypted($raw)) {
return $this->crypto->decrypt($raw) ?: '';
}
return $this->normalize($raw);
}
public function decodeAndMigrateValue(?string $value, int $userId): string
{
$decoded = $this->decrypt($value);
$raw = (string)($value ?? '');
if ($decoded !== '' && !Crypto::looksEncrypted($raw)) {
$this->updateUserEmail($userId, $decoded);
}
return $decoded;
}
public function updateUserEmail(int $userId, string $email): void
{
$normalized = $this->normalize($email);
$stmt = $this->pdo->prepare('UPDATE users SET email = :email, email_lookup_hash = :lookup, updated_at = NOW() WHERE id = :id');
$stmt->execute([
'email' => $this->encrypt($normalized),
'lookup' => $this->lookupHash($normalized),
'id' => $userId,
]);
}
public function findUserByEmail(string $email, string $select = 'id, email, password_hash, status'): array|false
{
$this->ensureSchema();
$stmt = $this->pdo->prepare(sprintf('SELECT %s FROM users WHERE email_lookup_hash = :lookup LIMIT 1', $select));
$stmt->execute([
'lookup' => $this->lookupHash($email),
]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
public function getEmailByUserId(int $userId): string
{
$this->ensureSchema();
$stmt = $this->pdo->prepare('SELECT email FROM users WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $userId]);
$value = $stmt->fetchColumn();
return $this->decodeAndMigrateValue($value === false ? null : (string)$value, $userId);
}
public function decryptRowEmails(array $rows, string $idKey = 'id', string $emailKey = 'email'): array
{
foreach ($rows as &$row) {
if (!isset($row[$emailKey])) {
continue;
}
$row[$emailKey] = $this->decodeAndMigrateValue((string)$row[$emailKey], (int)($row[$idKey] ?? 0));
}
unset($row);
return $rows;
}
private function migrateEmails(): void
{
$stmt = $this->pdo->query('SELECT id, email, email_lookup_hash FROM users');
foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [] as $row) {
$emailValue = (string)($row['email'] ?? '');
if ($emailValue === '') {
continue;
}
$decoded = $this->decrypt($emailValue);
if ($decoded === '') {
continue;
}
$needsEmailUpdate = !Crypto::looksEncrypted($emailValue);
$lookupHash = (string)($row['email_lookup_hash'] ?? '');
$needsLookupUpdate = $lookupHash === '' || !hash_equals($lookupHash, $this->lookupHash($decoded));
if ($needsEmailUpdate || $needsLookupUpdate) {
$this->updateUserEmail((int)$row['id'], $decoded);
}
}
}
private function ensureLookupHashIndex(): void
{
$stmt = $this->pdo->prepare("
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'users'
AND index_name = 'uq_users_email_lookup_hash'
");
$stmt->execute();
if ((int)$stmt->fetchColumn() === 0) {
$this->pdo->exec('ALTER TABLE users ADD UNIQUE INDEX uq_users_email_lookup_hash (email_lookup_hash)');
}
}
private function dropUniqueIndexOnEmailColumn(): void
{
$stmt = $this->pdo->query("
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'users'
AND column_name = 'email'
AND non_unique = 0
AND index_name <> 'PRIMARY'
");
foreach ($stmt->fetchAll(\PDO::FETCH_COLUMN) ?: [] as $indexName) {
$this->pdo->exec(sprintf('ALTER TABLE users DROP INDEX %s', $indexName));
}
}
private function hasColumn(string $table, string $column): bool
{
$stmt = $this->pdo->prepare("
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :tableName
AND COLUMN_NAME = :columnName
");
$stmt->execute([
'tableName' => $table,
'columnName' => $column,
]);
return (int)$stmt->fetchColumn() > 0;
}
private function getColumnDataType(string $table, string $column): ?string
{
$stmt = $this->pdo->prepare("
SELECT DATA_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :tableName
AND COLUMN_NAME = :columnName
LIMIT 1
");
$stmt->execute([
'tableName' => $table,
'columnName' => $column,
]);
$value = $stmt->fetchColumn();
return $value !== false ? strtolower((string)$value) : null;
}
private function buildLookupKey(): string
{
$raw = trim((string)(getenv('EMAIL_LOOKUP_KEY') ?: ''));
if ($raw !== '') {
$decoded = base64_decode(str_starts_with($raw, 'base64:') ? substr($raw, 7) : $raw, true);
if ($decoded !== false && $decoded !== '') {
return $decoded;
}
if (ctype_xdigit($raw) && strlen($raw) % 2 === 0) {
$hex = hex2bin($raw);
if ($hex !== false) {
return $hex;
}
}
return $raw;
}
$dataKey = trim((string)(getenv('DATA_KEY') ?: ''));
if ($dataKey === '') {
throw new \RuntimeException('EMAIL_LOOKUP_KEY oder DATA_KEY wird fuer sichere E-Mail-Speicherung benoetigt.');
}
if (str_starts_with($dataKey, 'base64:')) {
$dataKey = substr($dataKey, 7);
}
$decoded = base64_decode($dataKey, true);
if ($decoded !== false && $decoded !== '') {
$dataKey = $decoded;
} elseif (ctype_xdigit($dataKey) && strlen($dataKey) % 2 === 0) {
$hex = hex2bin($dataKey);
if ($hex !== false) {
$dataKey = $hex;
}
}
return hash_hkdf('sha256', (string)$dataKey, 32, 'pkt-user-email-lookup');
}
}