This commit is contained in:
@@ -16,9 +16,11 @@ Aktueller Projektstand
|
|||||||
- Kinder koennen im Mitgliederbereich zusaetzlich nachtraeglich bearbeitet und geloescht werden.
|
- Kinder koennen im Mitgliederbereich zusaetzlich nachtraeglich bearbeitet und geloescht werden.
|
||||||
- Wenn bei einem Kind ein Geburtsdatum hinterlegt ist, wird das Alter automatisch daraus berechnet und spaeter bei Bedarf jaehrlich aktualisiert; ohne Geburtsdatum bleibt das manuell gepflegte Alter massgeblich.
|
- Wenn bei einem Kind ein Geburtsdatum hinterlegt ist, wird das Alter automatisch daraus berechnet und spaeter bei Bedarf jaehrlich aktualisiert; ohne Geburtsdatum bleibt das manuell gepflegte Alter massgeblich.
|
||||||
- Profilbezogene sensible Angaben wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; vorhandene Klartextwerte werden bei der Nutzung schrittweise nachmigriert.
|
- Profilbezogene sensible Angaben wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; vorhandene Klartextwerte werden bei der Nutzung schrittweise nachmigriert.
|
||||||
|
- Auch die Konto-E-Mail wird jetzt app-seitig verschluesselt gespeichert; fuer Login, Verifizierung, Passwort-Reset und Dublettenpruefung dient nur ein separater HMAC-Lookup-Hash.
|
||||||
- Der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist auf der Datenschutz-Seite jetzt zusaetzlich deutlich hervorgehoben.
|
- Der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist auf der Datenschutz-Seite jetzt zusaetzlich deutlich hervorgehoben.
|
||||||
- Das Profil besitzt jetzt zusaetzlich eine vollstaendige Adresse mit verschluesselter Strasse/Hausnummer sowie eine optionale Adress-Lokalisierung ueber die vorhandenen externen Karten-/Geocoding-Dienste.
|
- Das Profil besitzt jetzt zusaetzlich eine vollstaendige Adresse mit verschluesselter Strasse/Hausnummer sowie eine optionale Adress-Lokalisierung ueber die vorhandenen externen Karten-/Geocoding-Dienste.
|
||||||
- Die Profiladresse unterstuetzt jetzt ausserdem eine Auswahl moeglicher Adress-Treffer, die Uebernahme aus dem Browser-Standort und einen kurzen Hinweisdialog zur lokalen Relevanz.
|
- Die Profiladresse unterstuetzt jetzt ausserdem eine Auswahl moeglicher Adress-Treffer, die Uebernahme aus dem Browser-Standort und einen kurzen Hinweisdialog zur lokalen Relevanz.
|
||||||
|
- Fuer Umkreis- und Distanzfunktionen bleiben nur `lat`/`lng` technisch im Klartext; die eigentliche Strasse/Hausnummer ist verschluesselt.
|
||||||
- Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt.
|
- Alte statische Avatar-Presets und frühere Layer-Assets sind entfernt.
|
||||||
- Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden.
|
- Änderungen an Funktionen mit Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern müssen immer auch in Datenschutz-/Cookie-Hinweisen und im Consent-Flow berücksichtigt werden.
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
|||||||
- Kinder koennen im Mitgliederbereich angelegt, nachtraeglich bearbeitet und wieder geloescht werden.
|
- Kinder koennen im Mitgliederbereich angelegt, nachtraeglich bearbeitet und wieder geloescht werden.
|
||||||
- Wenn ein Geburtsdatum gesetzt ist, wird das Alter im Mitgliederbereich automatisch berechnet und spaeter bei Bedarf anhand des Datums aktualisiert; ohne Geburtsdatum gilt der manuelle Alterswert.
|
- Wenn ein Geburtsdatum gesetzt ist, wird das Alter im Mitgliederbereich automatisch berechnet und spaeter bei Bedarf anhand des Datums aktualisiert; ohne Geburtsdatum gilt der manuelle Alterswert.
|
||||||
- Sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden bei Nutzung schrittweise in den verschluesselten Zustand ueberfuehrt.
|
- Sensible Profildaten wie Vorname, Nachname, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden bei Nutzung schrittweise in den verschluesselten Zustand ueberfuehrt.
|
||||||
|
- Die Konto-E-Mail wird ebenfalls app-seitig verschluesselt gespeichert; fuer Login, Verifizierung, Passwort-Reset und Dublettenpruefung existiert nur ein separater HMAC-Lookup-Hash.
|
||||||
- Der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist auf der Datenschutz-Seite jetzt zusaetzlich deutlich hervorgehoben.
|
- Der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist auf der Datenschutz-Seite jetzt zusaetzlich deutlich hervorgehoben.
|
||||||
- Im Profil koennen jetzt auch Strasse/Hausnummer, PLZ, Ort und Region gepflegt werden; fuer die Adress-Lokalisierung werden die bereits vorhandenen externen Karten-/Geocoding-Dienste wiederverwendet.
|
- Im Profil koennen jetzt auch Strasse/Hausnummer, PLZ, Ort und Region gepflegt werden; fuer die Adress-Lokalisierung werden die bereits vorhandenen externen Karten-/Geocoding-Dienste wiederverwendet.
|
||||||
- Die Profiladresse bietet jetzt zusaetzlich eine Trefferauswahl fuer die Suche, eine Uebernahme aus dem Browser-Standort und einen kurzen Hinweisdialog zur Relevanz fuer lokale Termin-Hinweise.
|
- Die Profiladresse bietet jetzt zusaetzlich eine Trefferauswahl fuer die Suche, eine Uebernahme aus dem Browser-Standort und einen kurzen Hinweisdialog zur Relevanz fuer lokale Termin-Hinweise.
|
||||||
@@ -65,6 +66,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l
|
|||||||
- `enabled`
|
- `enabled`
|
||||||
- Im Einstellungsbereich werden außerdem Browser-Grenzen erklärt: Die Browser-Freigabe selbst kann nicht von der Website gesetzt oder aufgehoben werden.
|
- Im Einstellungsbereich werden außerdem Browser-Grenzen erklärt: Die Browser-Freigabe selbst kann nicht von der Website gesetzt oder aufgehoben werden.
|
||||||
- Es gibt eine eigene Seite `/datenschutz` für Datenschutz- und Cookie-Hinweise sowie einen Footer-Link auf die Consent-Einstellungen.
|
- Es gibt eine eigene Seite `/datenschutz` für Datenschutz- und Cookie-Hinweise sowie einen Footer-Link auf die Consent-Einstellungen.
|
||||||
|
- Fuer Distanz- und Umkreissuchen bleiben nur die erforderlichen Standortkoordinaten im Klartext gespeichert; die eigentliche Strasse/Hausnummer ist verschluesselt.
|
||||||
|
|
||||||
## Doku-Regel
|
## Doku-Regel
|
||||||
Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden:
|
Bei jeder künftigen Änderung müssen mindestens diese Dateien mitgeprüft werden:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale
|
|||||||
- im Bereich `Kinder` koennen vorhandene Eintraege jetzt auch nachtraeglich bearbeitet und geloescht werden
|
- im Bereich `Kinder` koennen vorhandene Eintraege jetzt auch nachtraeglich bearbeitet und geloescht werden
|
||||||
- bei Kindern mit Geburtsdatum wird das Alter automatisch berechnet und bei Aufruf des Mitgliederbereichs bei Bedarf jaehrlich nachgezogen; ohne Geburtsdatum gilt das manuell gepflegte Alter
|
- bei Kindern mit Geburtsdatum wird das Alter automatisch berechnet und bei Aufruf des Mitgliederbereichs bei Bedarf jaehrlich nachgezogen; ohne Geburtsdatum gilt das manuell gepflegte Alter
|
||||||
- sensible Profildaten werden im Mitgliederbereich jetzt konsequent app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden beim Lesen schrittweise in den verschluesselten Zustand ueberfuehrt
|
- sensible Profildaten werden im Mitgliederbereich jetzt konsequent app-seitig verschluesselt gespeichert; bestehende Klartextwerte werden beim Lesen schrittweise in den verschluesselten Zustand ueberfuehrt
|
||||||
|
- auch Konto-E-Mails werden jetzt app-seitig verschluesselt gespeichert; fuer Login, Verifizierung, Passwort-Reset und Dublettenpruefung wird nur ein separater HMAC-Lookup-Hash verwendet
|
||||||
- der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist jetzt auch auf der Datenschutz-Seite deutlich hervorgehoben
|
- der Hinweis auf die verschluesselte Speicherung personenbezogener Profilangaben ist jetzt auch auf der Datenschutz-Seite deutlich hervorgehoben
|
||||||
- das Profil unterstuetzt jetzt eine vollstaendige Adresse mit verschluesselter Strasse/Hausnummer sowie optionaler Adress-Lokalisierung ueber die bereits vorhandenen Karten-/Geocoding-Dienste
|
- das Profil unterstuetzt jetzt eine vollstaendige Adresse mit verschluesselter Strasse/Hausnummer sowie optionaler Adress-Lokalisierung ueber die bereits vorhandenen Karten-/Geocoding-Dienste
|
||||||
- bei der Profiladresse gibt es jetzt eine Trefferauswahl fuer Adresssuche, eine Uebernahme aus dem Browser-Standort sowie einen mobilen Hinweisdialog, warum die Adresse fuer lokale Termine relevant ist
|
- bei der Profiladresse gibt es jetzt eine Trefferauswahl fuer Adresssuche, eine Uebernahme aus dem Browser-Standort sowie einen mobilen Hinweisdialog, warum die Adresse fuer lokale Termine relevant ist
|
||||||
@@ -66,6 +67,12 @@ Der aktuelle Code nutzt oder kann nutzen:
|
|||||||
- OpenStreetMap Nominatim (`external_services`)
|
- OpenStreetMap Nominatim (`external_services`)
|
||||||
- DiceBear fuer serverseitig erzeugte Profilbilder; das Ergebnis wird lokal als Benutzer-SVG gespeichert
|
- DiceBear fuer serverseitig erzeugte Profilbilder; das Ergebnis wird lokal als Benutzer-SVG gespeichert
|
||||||
|
|
||||||
|
Zur Datensicherheit:
|
||||||
|
- Passwort-Hashes laufen ueber Argon2id.
|
||||||
|
- Konto-E-Mails werden verschluesselt gespeichert und nur ueber einen separaten HMAC-Lookup-Hash adressiert.
|
||||||
|
- Profilfelder wie Vorname, Nachname, Strasse/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung werden app-seitig verschluesselt gespeichert.
|
||||||
|
- Standortkoordinaten (`lat`, `lng`) bleiben fuer Distanz- und Umkreissuchen technisch im Klartext gespeichert.
|
||||||
|
|
||||||
Wichtig:
|
Wichtig:
|
||||||
- Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden.
|
- Nicht notwendige Analyse- und Drittanbieterfunktionen dürfen erst nach Einwilligung aktiv werden.
|
||||||
- Änderungen an Cookies, Tracking oder Drittanbietern müssen immer auch in Consent und rechtlichen Hinweisen nachgezogen werden.
|
- Änderungen an Cookies, Tracking oder Drittanbietern müssen immer auch in Consent und rechtlichen Hinweisen nachgezogen werden.
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ $clientCookie = $config->cookiePrefix() . 'client';
|
|||||||
<div class="note" style="margin:18px 0 22px;">
|
<div class="note" style="margin:18px 0 22px;">
|
||||||
<strong>Wichtiger Hinweis zum Datenschutz:</strong>
|
<strong>Wichtiger Hinweis zum Datenschutz:</strong>
|
||||||
Personenbezogene und sensible Profilangaben werden bei Papa-Kind-Treff app-seitig verschluesselt gespeichert.
|
Personenbezogene und sensible Profilangaben werden bei Papa-Kind-Treff app-seitig verschluesselt gespeichert.
|
||||||
Das betrifft insbesondere Profilangaben wie Vorname, Nachname, Strasse/Hausnummer, Telefonnummer, Beruf,
|
Das betrifft insbesondere auch die Konto-E-Mail sowie Profilangaben wie Vorname, Nachname, Strasse/Hausnummer,
|
||||||
Sprachen, Kurzvorstellung sowie einzelne Kinderdaten.
|
Telefonnummer, Beruf, Sprachen, Kurzvorstellung und einzelne Kinderdaten. Fuer Login, Verifizierung und
|
||||||
|
Passwort-Reset wird die E-Mail technisch nur ueber einen separaten HMAC-Lookup-Hash adressiert.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ $app = app();
|
|||||||
$pdo = $app->pdo();
|
$pdo = $app->pdo();
|
||||||
$users = [];
|
$users = [];
|
||||||
$error = '';
|
$error = '';
|
||||||
|
$emailStore = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!$pdo) {
|
if (!$pdo) {
|
||||||
throw new RuntimeException('Keine Datenbankverbindung verfügbar.');
|
throw new RuntimeException('Keine Datenbankverbindung verfügbar.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$emailStore = new App\UserEmailStore($pdo);
|
||||||
|
$emailStore->ensureSchema();
|
||||||
|
|
||||||
$hasRolesTable = false;
|
$hasRolesTable = false;
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'user_roles' LIMIT 1");
|
$stmt = $pdo->query("SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'user_roles' LIMIT 1");
|
||||||
@@ -36,6 +40,7 @@ try {
|
|||||||
';
|
';
|
||||||
$stmt = $pdo->query($sql);
|
$stmt = $pdo->query($sql);
|
||||||
$users = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
$users = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$users = $emailStore->decryptRowEmails($users);
|
||||||
|
|
||||||
if ($hasRolesTable && $users) {
|
if ($hasRolesTable && $users) {
|
||||||
$roleStmt = $pdo->query('SELECT user_id, role FROM user_roles ORDER BY user_id ASC, role ASC');
|
$roleStmt = $pdo->query('SELECT user_id, role FROM user_roles ORDER BY user_id ASC, role ASC');
|
||||||
|
|||||||
@@ -3,18 +3,22 @@
|
|||||||
-- - Passwörter mit Argon2id hashen.
|
-- - Passwörter mit Argon2id hashen.
|
||||||
-- - Sensible Felder werden app-seitig mit libsodium (XChaCha20-Poly1305) verschlüsselt
|
-- - Sensible Felder werden app-seitig mit libsodium (XChaCha20-Poly1305) verschlüsselt
|
||||||
-- und als base64 in VARBINARY-Spalten abgelegt (nonce + cipher).
|
-- und als base64 in VARBINARY-Spalten abgelegt (nonce + cipher).
|
||||||
|
-- - Konto-E-Mails werden app-seitig verschlüsselt gespeichert; für die technische Suche
|
||||||
|
-- und Dublettenprüfung wird zusätzlich ein HMAC-SHA-256-Lookup-Hash abgelegt.
|
||||||
-- - share_level steuert Papa-Infos (basic, papa, papa_contact).
|
-- - share_level steuert Papa-Infos (basic, papa, papa_contact).
|
||||||
-- - children_visibility steuert Kinder-Infos separat (hidden, age_only, details).
|
-- - children_visibility steuert Kinder-Infos separat (hidden, age_only, details).
|
||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARBINARY(512) NOT NULL,
|
||||||
|
email_lookup_hash CHAR(64) NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
status ENUM('active','pending','blocked') DEFAULT 'pending',
|
status ENUM('active','pending','blocked') DEFAULT 'pending',
|
||||||
email_verified_at DATETIME NULL,
|
email_verified_at DATETIME NULL,
|
||||||
last_login_at DATETIME NULL,
|
last_login_at DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_users_email_lookup_hash (email_lookup_hash)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE user_profiles (
|
CREATE TABLE user_profiles (
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ final class AccountPages
|
|||||||
|
|
||||||
public static function verify(App $app): array
|
public static function verify(App $app): array
|
||||||
{
|
{
|
||||||
$pdo = $app->pdo();
|
|
||||||
$flash = $app->flash()->get();
|
$flash = $app->flash()->get();
|
||||||
$error = '';
|
$error = '';
|
||||||
$info = '';
|
$info = '';
|
||||||
@@ -119,20 +118,19 @@ final class AccountPages
|
|||||||
|
|
||||||
if ($action === 'resend') {
|
if ($action === 'resend') {
|
||||||
try {
|
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');
|
$row = $auth->findUserMetaByEmail($email);
|
||||||
$stmt?->execute(['email' => $email]);
|
|
||||||
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new \RuntimeException('E-Mail nicht gefunden.');
|
throw new \RuntimeException('E-Mail nicht gefunden.');
|
||||||
}
|
}
|
||||||
$userId = (int)$row['id'];
|
$userId = (int)$row['id'];
|
||||||
$codeNew = $auth->createVerifyCode($userId, $email);
|
$storedEmail = $auth->getEmailByUserId($userId);
|
||||||
$mailer->sendTemplate('registration_resend_code', $email, [
|
$codeNew = $auth->createVerifyCode($userId, $storedEmail);
|
||||||
|
$mailer->sendTemplate('registration_resend_code', $storedEmail, [
|
||||||
'code' => $codeNew,
|
'code' => $codeNew,
|
||||||
'display_name' => $row['display_name'] ?? '',
|
'display_name' => $row['display_name'] ?? '',
|
||||||
]);
|
]);
|
||||||
$info = 'Neuer Code wurde versendet.';
|
$info = 'Neuer Code wurde versendet.';
|
||||||
$_SESSION['verify_email'] = $email;
|
$_SESSION['verify_email'] = $storedEmail;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
}
|
}
|
||||||
@@ -141,7 +139,11 @@ final class AccountPages
|
|||||||
$userId = $auth->verifyCode($email, $code);
|
$userId = $auth->verifyCode($email, $code);
|
||||||
$_SESSION['user_id'] = $userId;
|
$_SESSION['user_id'] = $userId;
|
||||||
unset($_SESSION['verify_email']);
|
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!');
|
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
|
||||||
redirect('/dashboard');
|
redirect('/dashboard');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -471,7 +473,7 @@ final class AccountPages
|
|||||||
static fn(string $column): string => 'p.' . $column,
|
static fn(string $column): string => 'p.' . $column,
|
||||||
AvatarManager::allProfileColumns()
|
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]);
|
$stmt?->execute(['id' => $userId]);
|
||||||
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
if ($row) {
|
if ($row) {
|
||||||
@@ -488,6 +490,7 @@ final class AccountPages
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$profile['email'] = (new Auth($app))->getEmailByUserId($userId);
|
||||||
$profile = AvatarManager::normalizeProfile($profile, $userId);
|
$profile = AvatarManager::normalizeProfile($profile, $userId);
|
||||||
|
|
||||||
$editChildId = isset($_GET['edit_child']) ? (int)$_GET['edit_child'] : 0;
|
$editChildId = isset($_GET['edit_child']) ? (int)$_GET['edit_child'] : 0;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App;
|
|||||||
|
|
||||||
final class Auth
|
final class Auth
|
||||||
{
|
{
|
||||||
|
private ?UserEmailStore $emailStore = null;
|
||||||
|
|
||||||
public function __construct(private App $app) {}
|
public function __construct(private App $app) {}
|
||||||
|
|
||||||
private function pdo(): \PDO
|
private function pdo(): \PDO
|
||||||
@@ -16,6 +18,16 @@ final class Auth
|
|||||||
return $pdo;
|
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
|
public function register(string $displayName, string $email, string $password): int
|
||||||
{
|
{
|
||||||
$pdo = $this->pdo();
|
$pdo = $this->pdo();
|
||||||
@@ -26,18 +38,18 @@ final class Auth
|
|||||||
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
|
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$emailStore = $this->emailStore();
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
if ($emailStore->findUserByEmail($email, 'id')) {
|
||||||
$stmt->execute(['email' => $email]);
|
|
||||||
if ($stmt->fetchColumn()) {
|
|
||||||
throw new \RuntimeException('E-Mail ist bereits registriert.');
|
throw new \RuntimeException('E-Mail ist bereits registriert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
$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([
|
$stmt->execute([
|
||||||
'email' => $email,
|
'email' => $emailStore->encrypt($email),
|
||||||
|
'lookup' => $emailStore->lookupHash($email),
|
||||||
'pw' => $hash,
|
'pw' => $hash,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
@@ -80,11 +92,12 @@ final class Auth
|
|||||||
public function verifyCode(string $email, string $code): int
|
public function verifyCode(string $email, string $code): int
|
||||||
{
|
{
|
||||||
$pdo = $this->pdo();
|
$pdo = $this->pdo();
|
||||||
$email = strtolower(trim($email));
|
$email = $this->emailStore()->normalize($email);
|
||||||
$hash = hash('sha256', $code);
|
$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');
|
$lookupHash = $this->emailStore()->lookupHash($email);
|
||||||
$stmt->execute(['type' => 'verify', 'email' => $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);
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
||||||
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
||||||
@@ -109,17 +122,17 @@ final class Auth
|
|||||||
public function createResetCode(string $email): array
|
public function createResetCode(string $email): array
|
||||||
{
|
{
|
||||||
$pdo = $this->pdo();
|
$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');
|
$row = $this->emailStore()->findUserByEmail($email, 'id');
|
||||||
$stmt->execute(['email' => $email]);
|
|
||||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new \RuntimeException('E-Mail ist nicht registriert.');
|
throw new \RuntimeException('E-Mail ist nicht registriert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = (int)$row['id'];
|
$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);
|
$code = $this->generateCode(6);
|
||||||
$hash = hash('sha256', $code);
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
@@ -138,11 +151,12 @@ final class Auth
|
|||||||
public function verifyResetCode(string $email, string $code): int
|
public function verifyResetCode(string $email, string $code): int
|
||||||
{
|
{
|
||||||
$pdo = $this->pdo();
|
$pdo = $this->pdo();
|
||||||
$email = strtolower(trim($email));
|
$email = $this->emailStore()->normalize($email);
|
||||||
$hash = hash('sha256', $code);
|
$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');
|
$lookupHash = $this->emailStore()->lookupHash($email);
|
||||||
$stmt->execute(['type' => 'reset', 'email' => $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);
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
||||||
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
||||||
@@ -191,11 +205,9 @@ final class Auth
|
|||||||
public function login(string $email, string $password): array
|
public function login(string $email, string $password): array
|
||||||
{
|
{
|
||||||
$pdo = $this->pdo();
|
$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');
|
$row = $this->emailStore()->findUserByEmail($email, 'id, password_hash, status');
|
||||||
$stmt->execute(['email' => $email]);
|
|
||||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
|
||||||
|
|
||||||
if (!$row) {
|
if (!$row) {
|
||||||
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
|
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
|
||||||
@@ -214,4 +226,23 @@ final class Auth
|
|||||||
|
|
||||||
return ['id' => $userId, 'status' => $status];
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,22 @@ final class CommunityAccess
|
|||||||
{
|
{
|
||||||
private array $tableCache = [];
|
private array $tableCache = [];
|
||||||
private array $columnCache = [];
|
private array $columnCache = [];
|
||||||
|
private ?UserEmailStore $emailStore = null;
|
||||||
|
|
||||||
public function __construct(private \PDO $pdo, private array $communityConfig)
|
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
|
public function getUserRoles(int $userId): array
|
||||||
{
|
{
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
@@ -182,7 +193,7 @@ final class CommunityAccess
|
|||||||
|
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
$stmt->execute($params);
|
$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
|
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
|
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
|
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
|
public function setRestriction(int $actingUserId, int $targetUserId, string $type, string $reason): void
|
||||||
|
|||||||
247
src/App/UserEmailStore.php
Normal file
247
src/App/UserEmailStore.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user