From cad05e5fd57c9337da9b4a183299ad666fc88aa0 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 12:02:59 +0200 Subject: [PATCH] feat(auth): Benutzer, Gruppen und Rechte als Datenmodell Loest das eine fest verdrahtete Admin-Konto durch beliebig viele Konten ab, die in Gruppen organisiert sind. Die Zugriffskette lautet Benutzer -> Gruppe -> Domain -> Kurzlink: eine Domain gehoert zu genau einer Gruppe, ein Kurzlink haengt an seiner Domain. Wer Mitglied einer Gruppe ist, sieht damit alle Kurzlinks aller Domains dieser Gruppe. Ein Kurzlink hat bewusst keinen persoenlichen Eigentuemer - sonst wuerde das Loeschen eines Kontos gedruckte QR-Codes unbrauchbar machen. Zwei Rollenebenen: global "admin" (verwaltet Benutzer, Gruppen und Domains, sieht alles) bzw. "user", innerhalb einer Gruppe "editor" (anlegen und aendern) bzw. "viewer" (nur lesen). AccessPolicy beantwortet je Request, welche Domains sichtbar bzw. beschreibbar sind; die Repositories nehmen diese Liste als Filter. Dabei ist die Unterscheidung wesentlich: null heisst "keine Einschraenkung" (Administrator), ein leeres Array heisst "nichts" - und darf gerade nicht zu einer Abfrage ohne Bedingung werden. Der Zugang aus der .env (ADMIN_USER / ADMIN_PASSWORD_HASH) bleibt gueltig, steht aber ab jetzt als Notfallzugang neben den regulaeren Konten, statt sie zu ueberstimmen. Er hat Administratorrechte und existiert nur fuer den Fall, dass das letzte Administratorkonto gesperrt wurde oder sein Passwort verloren ging. Migration 003 uebernimmt das Bestandskonto aus der settings-Tabelle als Benutzer Nr. 1 mit unveraendertem Hash, legt die Gruppe "Standard" an, ordnet ihr alle vorhandenen Domains zu und raeumt die beiden alten settings-Schluessel auf. Nebenbei geschlossen: das Rate-Limiting am Login zaehlte nur nach IP-Adresse. Es zaehlt jetzt auch je Benutzername, sonst reicht ein Wechsel der Adresse, um alle Konten der Reihe nach durchzuprobieren. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/003_users_groups.php | 128 +++++++++++++ src/Repository/DomainRepository.php | 55 +++++- src/Repository/GroupRepository.php | 232 +++++++++++++++++++++++ src/Repository/LinkRepository.php | 81 ++++++++- src/Repository/QrDesignRepository.php | 37 ++++ src/Repository/SettingRepository.php | 10 +- src/Repository/UserRepository.php | 144 +++++++++++++++ src/Repository/VisitRepository.php | 37 +++- src/Security/AccessPolicy.php | 167 +++++++++++++++++ src/Security/Auth.php | 253 ++++++++++++++++++-------- src/Security/RateLimiter.php | 36 ++-- src/Security/Role.php | 61 +++++++ src/Support/Password.php | 44 +++++ src/Support/Username.php | 32 ++++ 14 files changed, 1197 insertions(+), 120 deletions(-) create mode 100644 migrations/003_users_groups.php create mode 100644 src/Repository/GroupRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Security/AccessPolicy.php create mode 100644 src/Security/Role.php create mode 100644 src/Support/Password.php create mode 100644 src/Support/Username.php diff --git a/migrations/003_users_groups.php b/migrations/003_users_groups.php new file mode 100644 index 0000000..7967677 --- /dev/null +++ b/migrations/003_users_groups.php @@ -0,0 +1,128 @@ + ''), + 'admin' + ), + s.value, + 'admin', + 1, + '{$now}' + FROM settings s + WHERE s.key_name = 'admin_password_hash' AND s.value IS NOT NULL AND s.value <> ''"; + +return [ + 'sqlite' => [ + 'CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT "user", + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + last_login_at TEXT NULL + )', + + 'CREATE TABLE IF NOT EXISTS user_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL + )', + + 'CREATE TABLE IF NOT EXISTS group_members ( + group_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT "editor", + created_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id) + )', + 'CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members (user_id)', + + 'ALTER TABLE domains ADD COLUMN group_id INTEGER NOT NULL DEFAULT 0', + 'CREATE INDEX IF NOT EXISTS idx_domains_group ON domains (group_id)', + + "INSERT INTO user_groups (id, name, created_at) VALUES (1, 'Standard', '{$now}')", + 'UPDATE domains SET group_id = 1', + + $adoptAdmin, + + "INSERT INTO group_members (group_id, user_id, role, created_at) + SELECT 1, id, 'editor', '{$now}' FROM users", + + "DELETE FROM settings WHERE key_name IN ('admin_user', 'admin_password_hash')", + ], + + 'mysql' => [ + 'CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(190) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT "user", + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + last_login_at DATETIME NULL, + UNIQUE KEY uniq_users_username (username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'CREATE TABLE IF NOT EXISTS user_groups ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(190) NOT NULL, + created_at DATETIME NOT NULL, + UNIQUE KEY uniq_user_groups_name (name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'CREATE TABLE IF NOT EXISTS group_members ( + group_id INT NOT NULL, + user_id INT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT "editor", + created_at DATETIME NOT NULL, + PRIMARY KEY (group_id, user_id), + KEY idx_group_members_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4', + + 'ALTER TABLE domains ADD COLUMN group_id INT NOT NULL DEFAULT 0', + 'ALTER TABLE domains ADD INDEX idx_domains_group (group_id)', + + "INSERT INTO user_groups (id, name, created_at) VALUES (1, 'Standard', '{$now}')", + 'UPDATE domains SET group_id = 1', + + $adoptAdmin, + + "INSERT INTO group_members (group_id, user_id, role, created_at) + SELECT 1, id, 'editor', '{$now}' FROM (SELECT id FROM users) AS existing_users", + + "DELETE FROM settings WHERE key_name IN ('admin_user', 'admin_password_hash')", + ], +]; diff --git a/src/Repository/DomainRepository.php b/src/Repository/DomainRepository.php index d876d48..ae04a69 100644 --- a/src/Repository/DomainRepository.php +++ b/src/Repository/DomainRepository.php @@ -20,8 +20,11 @@ final class DomainRepository /** Kennung fuer noch nicht zugeordnete Bestandslinks. */ public const UNASSIGNED = 0; - public function __construct(private readonly Database $db) + private readonly GroupRepository $groups; + + public function __construct(private readonly Database $db, ?GroupRepository $groups = null) { + $this->groups = $groups ?? new GroupRepository($db); } /** @@ -31,12 +34,26 @@ final class DomainRepository { return $this->db->fetchAll( 'SELECT d.*, - (SELECT COUNT(*) FROM links l WHERE l.domain_id = d.id) AS link_count + (SELECT COUNT(*) FROM links l WHERE l.domain_id = d.id) AS link_count, + (SELECT g.name FROM user_groups g WHERE g.id = d.group_id) AS group_name FROM domains d ORDER BY d.is_default DESC, d.host ASC', ); } + /** + * Alle Domains einer Gruppe. + * + * @return list> + */ + public function allInGroup(int $groupId): array + { + return $this->db->fetchAll( + 'SELECT * FROM domains WHERE group_id = ? ORDER BY is_default DESC, host ASC', + [$groupId], + ); + } + /** * @return array|null */ @@ -133,14 +150,24 @@ final class DomainRepository return $this->find($id) ?? throw new RuntimeException('Standarddomain konnte nicht angelegt werden.'); } - public function create(string $baseUrl, ?string $label, bool $isDefault): int + /** + * @param int|null $groupId ohne Angabe die Standardgruppe + */ + public function create(string $baseUrl, ?string $label, bool $isDefault, ?int $groupId = null): int { $baseUrl = ShortDomain::normalizeBaseUrl($baseUrl); $host = ShortDomain::hostOf($baseUrl); $this->db->execute( - 'INSERT INTO domains (host, base_url, label, is_default, created_at) VALUES (?, ?, ?, ?, ?)', - [$host, $baseUrl, $label, $isDefault ? 1 : 0, gmdate('Y-m-d H:i:s')], + 'INSERT INTO domains (host, base_url, label, is_default, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?)', + [ + $host, + $baseUrl, + $label, + $isDefault ? 1 : 0, + $groupId ?? $this->groups->defaultGroupId(), + gmdate('Y-m-d H:i:s'), + ], ); $id = $this->db->lastInsertId(); @@ -152,14 +179,26 @@ final class DomainRepository return $id; } - public function update(int $id, string $baseUrl, ?string $label): void + /** + * @param int|null $groupId null laesst die bisherige Gruppe unveraendert + */ + public function update(int $id, string $baseUrl, ?string $label, ?int $groupId = null): void { $baseUrl = ShortDomain::normalizeBaseUrl($baseUrl); $host = ShortDomain::hostOf($baseUrl); + if ($groupId === null) { + $this->db->execute( + 'UPDATE domains SET host = ?, base_url = ?, label = ? WHERE id = ?', + [$host, $baseUrl, $label, $id], + ); + + return; + } + $this->db->execute( - 'UPDATE domains SET host = ?, base_url = ?, label = ? WHERE id = ?', - [$host, $baseUrl, $label, $id], + 'UPDATE domains SET host = ?, base_url = ?, label = ?, group_id = ? WHERE id = ?', + [$host, $baseUrl, $label, $groupId, $id], ); } diff --git a/src/Repository/GroupRepository.php b/src/Repository/GroupRepository.php new file mode 100644 index 0000000..2cecf2b --- /dev/null +++ b/src/Repository/GroupRepository.php @@ -0,0 +1,232 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT g.*, + (SELECT COUNT(*) FROM group_members m WHERE m.group_id = g.id) AS member_count, + (SELECT COUNT(*) FROM domains d WHERE d.group_id = g.id) AS domain_count + FROM user_groups g + ORDER BY g.name ASC', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetchOne('SELECT * FROM user_groups WHERE id = ?', [$id]); + } + + public function nameExists(string $name, ?int $exceptId = null): bool + { + $lower = mb_strtolower($name, 'UTF-8'); + + if ($exceptId === null) { + return $this->db->fetchValue('SELECT 1 FROM user_groups WHERE LOWER(name) = ?', [$lower]) !== null; + } + + return $this->db->fetchValue( + 'SELECT 1 FROM user_groups WHERE LOWER(name) = ? AND id <> ?', + [$lower, $exceptId], + ) !== null; + } + + public function create(string $name): int + { + $this->db->execute( + 'INSERT INTO user_groups (name, created_at) VALUES (?, ?)', + [$name, gmdate('Y-m-d H:i:s')], + ); + + return $this->db->lastInsertId(); + } + + public function rename(int $id, string $name): void + { + $this->db->execute('UPDATE user_groups SET name = ? WHERE id = ?', [$name, $id]); + } + + public function delete(int $id): void + { + $this->db->execute('DELETE FROM group_members WHERE group_id = ?', [$id]); + $this->db->execute('DELETE FROM user_groups WHERE id = ?', [$id]); + } + + public function count(): int + { + return (int) $this->db->fetchValue('SELECT COUNT(*) FROM user_groups'); + } + + public function domainCount(int $id): int + { + return (int) $this->db->fetchValue('SELECT COUNT(*) FROM domains WHERE group_id = ?', [$id]); + } + + /** + * Kennung der Gruppe, in die neue Domains ohne ausdrueckliche Angabe + * wandern. Legt "Standard" an, falls es noch gar keine Gruppe gibt - + * damit funktioniert eine frische Installation ohne Vorarbeit. + */ + public function defaultGroupId(): int + { + $id = $this->db->fetchValue('SELECT id FROM user_groups ORDER BY id ASC LIMIT 1'); + + if ($id !== null) { + return (int) $id; + } + + $created = $this->create(self::DEFAULT_NAME); + + if ($created <= 0) { + throw new RuntimeException('Die Standardgruppe konnte nicht angelegt werden.'); + } + + return $created; + } + + // --- Mitgliedschaften ---------------------------------------------- + + /** + * Mitglieder einer Gruppe samt ihrer Rolle darin. + * + * @return list> + */ + public function members(int $groupId): array + { + return $this->db->fetchAll( + 'SELECT u.id, u.username, u.role AS global_role, u.is_active, m.role AS group_role + FROM group_members m + JOIN users u ON u.id = m.user_id + WHERE m.group_id = ? + ORDER BY u.username ASC', + [$groupId], + ); + } + + /** + * Gruppen eines Benutzers samt seiner Rolle darin. + * + * @return list> + */ + public function groupsOfUser(int $userId): array + { + return $this->db->fetchAll( + 'SELECT g.id, g.name, m.role AS group_role + FROM group_members m + JOIN user_groups g ON g.id = m.group_id + WHERE m.user_id = ? + ORDER BY g.name ASC', + [$userId], + ); + } + + /** + * Alle Gruppen, in denen der Benutzer Mitglied ist. + * + * @return list + */ + public function groupIdsForUser(int $userId): array + { + return $this->idList( + 'SELECT group_id FROM group_members WHERE user_id = ?', + [$userId], + ); + } + + /** + * Nur die Gruppen, in denen der Benutzer schreiben darf. + * + * @return list + */ + public function editableGroupIdsForUser(int $userId): array + { + return $this->idList( + 'SELECT group_id FROM group_members WHERE user_id = ? AND role = ?', + [$userId, Role::EDITOR], + ); + } + + public function roleInGroup(int $userId, int $groupId): ?string + { + $role = $this->db->fetchValue( + 'SELECT role FROM group_members WHERE user_id = ? AND group_id = ?', + [$userId, $groupId], + ); + + return $role === null ? null : Role::groupOrDefault((string) $role); + } + + public function setMember(int $groupId, int $userId, string $role): void + { + $role = Role::groupOrDefault($role); + $exists = $this->db->fetchValue( + 'SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?', + [$groupId, $userId], + ) !== null; + + if ($exists) { + $this->db->execute( + 'UPDATE group_members SET role = ? WHERE group_id = ? AND user_id = ?', + [$role, $groupId, $userId], + ); + + return; + } + + $this->db->execute( + 'INSERT INTO group_members (group_id, user_id, role, created_at) VALUES (?, ?, ?, ?)', + [$groupId, $userId, $role, gmdate('Y-m-d H:i:s')], + ); + } + + public function removeMember(int $groupId, int $userId): void + { + $this->db->execute( + 'DELETE FROM group_members WHERE group_id = ? AND user_id = ?', + [$groupId, $userId], + ); + } + + /** + * @param array $params + * @return list + */ + private function idList(string $sql, array $params): array + { + $rows = $this->db->fetchAll($sql, $params); + + return array_values(array_map( + static fn (array $row): int => (int) reset($row), + $rows, + )); + } +} diff --git a/src/Repository/LinkRepository.php b/src/Repository/LinkRepository.php index 7283b3b..9250e9d 100644 --- a/src/Repository/LinkRepository.php +++ b/src/Repository/LinkRepository.php @@ -12,12 +12,41 @@ final class LinkRepository * Spalten der Domain, die bei Listen und Detailabfragen mitgelesen * werden, damit die Kurz-URL ohne zweite Abfrage gebaut werden kann. */ - private const DOMAIN_COLUMNS = 'd.base_url AS domain_base_url, d.host AS domain_host, d.label AS domain_label'; + private const DOMAIN_COLUMNS = 'd.base_url AS domain_base_url, d.host AS domain_host, d.label AS domain_label, d.group_id AS domain_group_id'; public function __construct(private readonly Database $db) { } + /** + * Baut die Bedingung, die eine Abfrage auf die fuer den angemeldeten + * Benutzer sichtbaren Domains einschraenkt. + * + * Die drei Faelle sind bewusst verschieden: null heisst "keine + * Einschraenkung" (Administrator), eine leere Liste heisst "nichts + * sichtbar" - und darf gerade nicht zu einer Abfrage ohne Bedingung + * werden. + * + * @param list|null $allowedDomainIds + * @param array $params wird um die Platzhalterwerte ergaenzt + */ + private static function domainScope(?array $allowedDomainIds, string $column, array &$params): ?string + { + if ($allowedDomainIds === null) { + return null; + } + + if ($allowedDomainIds === []) { + return '0 = 1'; + } + + foreach ($allowedDomainIds as $id) { + $params[] = $id; + } + + return $column . ' IN (' . implode(', ', array_fill(0, count($allowedDomainIds), '?')) . ')'; + } + /** * Ein Slug ist nur innerhalb seiner Domain eindeutig. * @@ -57,15 +86,23 @@ final class LinkRepository } /** + * Ein Kurzlink - aber nur, wenn seine Domain fuer den Aufrufer + * sichtbar ist. Ein fremder Link ist damit nicht von einem gar nicht + * vorhandenen zu unterscheiden. + * + * @param list|null $allowedDomainIds null = keine Einschraenkung * @return array|null */ - public function find(int $id): ?array + public function find(int $id, ?array $allowedDomainIds = null): ?array { + $params = [$id]; + $scope = self::domainScope($allowedDomainIds, 'l.domain_id', $params); + return $this->db->fetchOne( 'SELECT l.*, ' . self::DOMAIN_COLUMNS . ' FROM links l LEFT JOIN domains d ON d.id = l.domain_id - WHERE l.id = ?', - [$id], + WHERE l.id = ?' . ($scope === null ? '' : ' AND ' . $scope), + $params, ); } @@ -94,6 +131,7 @@ final class LinkRepository } /** + * @param list|null $allowedDomainIds null = keine Einschraenkung * @return list> */ public function all( @@ -102,6 +140,7 @@ final class LinkRepository int $limit = 200, int $offset = 0, ?int $domainId = null, + ?array $allowedDomainIds = null, ): array { $conditions = []; $params = []; @@ -117,6 +156,11 @@ final class LinkRepository $params[] = $domainId; } + $scope = self::domainScope($allowedDomainIds, 'l.domain_id', $params); + if ($scope !== null) { + $conditions[] = $scope; + } + $where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions); $order = match ($sort) { @@ -142,7 +186,10 @@ final class LinkRepository ); } - public function count(string $search = '', ?int $domainId = null): int + /** + * @param list|null $allowedDomainIds + */ + public function count(string $search = '', ?int $domainId = null, ?array $allowedDomainIds = null): int { $conditions = []; $params = []; @@ -158,6 +205,11 @@ final class LinkRepository $params[] = $domainId; } + $scope = self::domainScope($allowedDomainIds, 'domain_id', $params); + if ($scope !== null) { + $conditions[] = $scope; + } + $where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions); return (int) $this->db->fetchValue('SELECT COUNT(*) FROM links ' . $where, $params); @@ -225,12 +277,25 @@ final class LinkRepository } /** + * @param list|null $allowedDomainIds * @return array{links:int,clicks:int,active:int} */ - public function summary(?int $domainId = null): array + public function summary(?int $domainId = null, ?array $allowedDomainIds = null): array { - $where = $domainId === null ? '' : 'WHERE domain_id = ?'; - $params = $domainId === null ? [] : [$domainId]; + $conditions = []; + $params = []; + + if ($domainId !== null) { + $conditions[] = 'domain_id = ?'; + $params[] = $domainId; + } + + $scope = self::domainScope($allowedDomainIds, 'domain_id', $params); + if ($scope !== null) { + $conditions[] = $scope; + } + + $where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions); $row = $this->db->fetchOne( 'SELECT COUNT(*) AS links, diff --git a/src/Repository/QrDesignRepository.php b/src/Repository/QrDesignRepository.php index d5a80c3..0bec78b 100644 --- a/src/Repository/QrDesignRepository.php +++ b/src/Repository/QrDesignRepository.php @@ -60,6 +60,43 @@ final class QrDesignRepository ); } + /** + * Gehoert die Logodatei zu einem Kurzlink, den der Aufrufer sehen darf? + * + * Der Dateiname allein ist kein Zugriffsschutz: alle Logos liegen in + * einem gemeinsamen Verzeichnis. Geprueft wird deshalb ueber die + * Zuordnung in der Datenbank. + * + * @param list|null $allowedDomainIds null = keine Einschraenkung + */ + public function logoIsVisible(string $filename, ?array $allowedDomainIds): bool + { + if ($filename === '') { + return false; + } + + if ($allowedDomainIds === null) { + return $this->db->fetchValue( + 'SELECT 1 FROM qr_designs WHERE logo_path = ?', + [$filename], + ) !== null; + } + + if ($allowedDomainIds === []) { + return false; + } + + $placeholders = implode(', ', array_fill(0, count($allowedDomainIds), '?')); + + return $this->db->fetchValue( + 'SELECT 1 + FROM qr_designs qd + JOIN links l ON l.id = qd.link_id + WHERE qd.logo_path = ? AND l.domain_id IN (' . $placeholders . ')', + [$filename, ...$allowedDomainIds], + ) !== null; + } + public function clearLogo(int $linkId): void { $this->db->execute( diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php index 2fc02ea..b6b1c7f 100644 --- a/src/Repository/SettingRepository.php +++ b/src/Repository/SettingRepository.php @@ -7,13 +7,15 @@ namespace App\Repository; use App\Database; /** - * Key-Value-Speicher fuer Einstellungen, die zur Laufzeit gesetzt werden - * (z. B. das Admin-Passwort aus dem Setup-Assistenten). + * Key-Value-Speicher fuer Einstellungen, die zur Laufzeit gesetzt werden. + * + * Hier lagen bis zur Version mit mehreren Benutzern auch die Zugangsdaten + * des einen Admin-Kontos ("admin_user", "admin_password_hash"). Sie stehen + * jetzt in der users-Tabelle; die Migration 003 raeumt die beiden + * Schluessel auf. */ final class SettingRepository { - public const ADMIN_USER = 'admin_user'; - public const ADMIN_PASSWORD_HASH = 'admin_password_hash'; public const APP_SECRET = 'app_secret'; public function __construct(private readonly Database $db) diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..1fd98a1 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,144 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT u.*, + (SELECT COUNT(*) FROM group_members m WHERE m.user_id = u.id) AS group_count + FROM users u + ORDER BY u.username ASC', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetchOne('SELECT * FROM users WHERE id = ?', [$id]); + } + + /** + * Benutzernamen werden ohne Ruecksicht auf Gross- und Kleinschreibung + * verglichen: "Anna" und "anna" sollen nicht zwei Konten sein. + * + * @return array|null + */ + public function findByUsername(string $username): ?array + { + return $this->db->fetchOne( + 'SELECT * FROM users WHERE LOWER(username) = ?', + [mb_strtolower($username, 'UTF-8')], + ); + } + + public function usernameExists(string $username, ?int $exceptId = null): bool + { + $lower = mb_strtolower($username, 'UTF-8'); + + if ($exceptId === null) { + return $this->db->fetchValue('SELECT 1 FROM users WHERE LOWER(username) = ?', [$lower]) !== null; + } + + return $this->db->fetchValue( + 'SELECT 1 FROM users WHERE LOWER(username) = ? AND id <> ?', + [$lower, $exceptId], + ) !== null; + } + + public function create(string $username, string $plainPassword, string $role = Role::USER): int + { + $this->db->execute( + 'INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, ?)', + [ + $username, + password_hash($plainPassword, PASSWORD_DEFAULT), + Role::globalOrDefault($role), + gmdate('Y-m-d H:i:s'), + ], + ); + + return $this->db->lastInsertId(); + } + + public function update(int $id, string $username, string $role, bool $isActive): void + { + $this->db->execute( + 'UPDATE users SET username = ?, role = ?, is_active = ? WHERE id = ?', + [$username, Role::globalOrDefault($role), $isActive ? 1 : 0, $id], + ); + } + + public function updatePassword(int $id, string $plainPassword): void + { + $this->db->execute( + 'UPDATE users SET password_hash = ? WHERE id = ?', + [password_hash($plainPassword, PASSWORD_DEFAULT), $id], + ); + } + + public function touchLogin(int $id): void + { + $this->db->execute('UPDATE users SET last_login_at = ? WHERE id = ?', [gmdate('Y-m-d H:i:s'), $id]); + } + + public function delete(int $id): void + { + $this->db->execute('DELETE FROM group_members WHERE user_id = ?', [$id]); + $this->db->execute('DELETE FROM users WHERE id = ?', [$id]); + } + + public function count(): int + { + return (int) $this->db->fetchValue('SELECT COUNT(*) FROM users'); + } + + /** + * Wie viele aktive Administratoren gibt es noch? Verhindert, dass sich + * die Installation selbst aussperrt. + */ + public function activeAdminCount(?int $exceptId = null): int + { + if ($exceptId === null) { + return (int) $this->db->fetchValue( + 'SELECT COUNT(*) FROM users WHERE role = ? AND is_active = 1', + [Role::ADMIN], + ); + } + + return (int) $this->db->fetchValue( + 'SELECT COUNT(*) FROM users WHERE role = ? AND is_active = 1 AND id <> ?', + [Role::ADMIN, $exceptId], + ); + } + + /** + * Ist ueberhaupt ein anmeldbares Konto vorhanden? + */ + public function hasAnyActive(): bool + { + return $this->db->fetchValue('SELECT 1 FROM users WHERE is_active = 1') !== null; + } +} diff --git a/src/Repository/VisitRepository.php b/src/Repository/VisitRepository.php index 8baae9b..6987ff4 100644 --- a/src/Repository/VisitRepository.php +++ b/src/Repository/VisitRepository.php @@ -158,22 +158,39 @@ final class VisitRepository } /** - * Klicks aller Links der letzten $days Tage (Dashboard). + * Klicks aller sichtbaren Links der letzten $days Tage (Dashboard). * + * @param list|null $allowedDomainIds null = keine Einschraenkung * @return list */ - public function clicksPerDayGlobal(int $days = 30): array + public function clicksPerDayGlobal(int $days = 30, ?array $allowedDomainIds = null): array { $since = gmdate('Y-m-d', strtotime(sprintf('-%d days', $days - 1)) ?: time()); - $rows = $this->db->fetchAll( - 'SELECT SUBSTR(visited_at, 1, 10) AS day, COUNT(*) AS clicks - FROM visits - WHERE SUBSTR(visited_at, 1, 10) >= ? - GROUP BY SUBSTR(visited_at, 1, 10) - ORDER BY day ASC', - [$since], - ); + $params = [$since]; + $scope = ''; + + if ($allowedDomainIds === []) { + // Nichts sichtbar: die Reihe bleibt leer, aber vollstaendig. + $rows = []; + } else { + if ($allowedDomainIds !== null) { + $placeholders = implode(', ', array_fill(0, count($allowedDomainIds), '?')); + $scope = ' AND link_id IN (SELECT id FROM links WHERE domain_id IN (' . $placeholders . '))'; + foreach ($allowedDomainIds as $id) { + $params[] = $id; + } + } + + $rows = $this->db->fetchAll( + 'SELECT SUBSTR(visited_at, 1, 10) AS day, COUNT(*) AS clicks + FROM visits + WHERE SUBSTR(visited_at, 1, 10) >= ?' . $scope . ' + GROUP BY SUBSTR(visited_at, 1, 10) + ORDER BY day ASC', + $params, + ); + } $byDay = []; foreach ($rows as $row) { diff --git a/src/Security/AccessPolicy.php b/src/Security/AccessPolicy.php new file mode 100644 index 0000000..71826c9 --- /dev/null +++ b/src/Security/AccessPolicy.php @@ -0,0 +1,167 @@ + Gruppenmitgliedschaft -> Domains der + * Gruppe -> Kurzlinks dieser Domains. Ein Administrator umgeht sie und + * sieht alles. + * + * "null" als Rueckgabe einer Kennungsliste bedeutet ausdruecklich + * "keine Einschraenkung" - nicht "nichts". Ein leeres Array bedeutet + * dagegen "nichts", und genau so werten es die Repositories aus. + */ +final class AccessPolicy +{ + /** @var list>|null */ + private ?array $domainCache = null; + + public function __construct( + private readonly Auth $auth, + private readonly GroupRepository $groups, + private readonly DomainRepository $domains, + ) { + } + + public function isAdmin(): bool + { + return $this->auth->isAdmin(); + } + + // --- Domains --------------------------------------------------------- + + /** + * Alle Domains, die der Benutzer sehen darf. + * + * @return list> + */ + public function visibleDomains(): array + { + if ($this->isAdmin()) { + return $this->allDomains(); + } + + return $this->domainsInGroups($this->groups->groupIdsForUser($this->userId())); + } + + /** + * Domains, auf denen der Benutzer Kurzlinks anlegen und aendern darf. + * + * @return list> + */ + public function editableDomains(): array + { + if ($this->isAdmin()) { + return $this->allDomains(); + } + + return $this->domainsInGroups($this->groups->editableGroupIdsForUser($this->userId())); + } + + /** + * @return list|null null = keine Einschraenkung + */ + public function visibleDomainIds(): ?array + { + return $this->isAdmin() ? null : $this->idsOf($this->visibleDomains()); + } + + /** + * @return list|null null = keine Einschraenkung + */ + public function editableDomainIds(): ?array + { + return $this->isAdmin() ? null : $this->idsOf($this->editableDomains()); + } + + public function canViewDomain(int $domainId): bool + { + $allowed = $this->visibleDomainIds(); + + return $allowed === null || in_array($domainId, $allowed, true); + } + + public function canEditDomain(int $domainId): bool + { + $allowed = $this->editableDomainIds(); + + return $allowed === null || in_array($domainId, $allowed, true); + } + + // --- Kurzlinks ------------------------------------------------------- + + /** + * @param array $link + */ + public function canViewLink(array $link): bool + { + return $this->canViewDomain((int) ($link['domain_id'] ?? 0)); + } + + /** + * @param array $link + */ + public function canEditLink(array $link): bool + { + return $this->canEditDomain((int) ($link['domain_id'] ?? 0)); + } + + /** + * Darf der Benutzer ueberhaupt irgendwo Kurzlinks anlegen? + */ + public function canCreateLinks(): bool + { + return $this->editableDomains() !== []; + } + + // --- Intern ---------------------------------------------------------- + + private function userId(): int + { + return $this->auth->id() ?? 0; + } + + /** + * @return list> + */ + private function allDomains(): array + { + return $this->domainCache ??= $this->domains->all(); + } + + /** + * @param list $groupIds + * @return list> + */ + private function domainsInGroups(array $groupIds): array + { + if ($groupIds === []) { + return []; + } + + return array_values(array_filter( + $this->allDomains(), + static fn (array $domain): bool => in_array((int) $domain['group_id'], $groupIds, true), + )); + } + + /** + * @param list> $domains + * @return list + */ + private function idsOf(array $domains): array + { + return array_values(array_map( + static fn (array $domain): int => (int) $domain['id'], + $domains, + )); + } +} diff --git a/src/Security/Auth.php b/src/Security/Auth.php index f314214..1a2298a 100644 --- a/src/Security/Auth.php +++ b/src/Security/Auth.php @@ -5,126 +5,231 @@ declare(strict_types=1); namespace App\Security; use App\Config; -use App\Repository\SettingRepository; +use App\Repository\UserRepository; /** - * Session-basierte Authentifizierung mit genau einem Admin-Konto. + * Session-basierte Authentifizierung gegen die Benutzertabelle. * - * Die Zugangsdaten kommen entweder aus der .env (ADMIN_USER / - * ADMIN_PASSWORD_HASH) oder - falls dort nichts gesetzt ist - aus der - * settings-Tabelle, die der Setup-Assistent befuellt. + * Zusaetzlich gibt es den Notfallzugang aus der .env (ADMIN_USER / + * ADMIN_PASSWORD_HASH). Er hat Administratorrechte, taucht in der + * Benutzerverwaltung nicht auf und laesst sich dort auch nicht aendern - + * er existiert nur, damit eine Installation sich nicht aussperren kann, + * wenn das letzte Konto gesperrt wurde oder das Passwort verloren ging. + * Ein echtes Konto gleichen Namens gewinnt bei der Anmeldung. */ final class Auth { - private const SESSION_KEY = '_admin_authenticated'; - private const SESSION_USER = '_admin_user'; - private const SESSION_FINGERPRINT = '_admin_fingerprint'; + private const SESSION_USER_ID = '_auth_user_id'; + private const SESSION_FINGERPRINT = '_auth_fingerprint'; + + /** Kennung des Notfallzugangs. Negativ, damit sie nie mit einer echten kollidiert. */ + public const EMERGENCY_ID = -1; + + /** + * Damit ein unbekannter Benutzername genauso lange braucht wie ein + * falsches Passwort, wird auch dann ein Hash geprueft. Der Wert ist + * ein gueltiger bcrypt-Hash eines Zufallswerts - er passt zu keiner + * Eingabe. + */ + private const DUMMY_HASH = '$2y$10$eycI5xBqet0KRRyTeDeIs.e.RWe8Ij8u1O8j6LlIGyiY7ceOFzGm.'; + + /** @var array|null|false false = in diesem Request noch nicht geladen */ + private array|null|false $current = false; public function __construct( private readonly Config $config, private readonly Session $session, - private readonly SettingRepository $settings, + private readonly UserRepository $users, ) { } - public function username(): string - { - $fromEnv = $this->config->getString('ADMIN_USER', 'admin'); - - // Steht das Passwort in der .env, gilt auch der Benutzername von dort. - if ($this->config->adminPasswordHash() !== null) { - return $fromEnv; - } - - return $this->settings->get(SettingRepository::ADMIN_USER, $fromEnv) ?? $fromEnv; - } - - public function passwordHash(): ?string - { - return $this->config->adminPasswordHash() - ?? $this->settings->get(SettingRepository::ADMIN_PASSWORD_HASH); - } + // --- Zustand --------------------------------------------------------- /** - * Ist bereits ein Admin-Konto eingerichtet? + * Ist ueberhaupt ein Konto vorhanden? Solange nicht, fuehrt jeder Weg + * in den Setup-Assistenten. */ public function isConfigured(): bool { - $hash = $this->passwordHash(); + return $this->emergencyAccount() !== null || $this->users->hasAnyActive(); + } - return is_string($hash) && $hash !== ''; + public function check(): bool + { + return $this->user() !== null; } /** - * Legt Zugangsdaten in der Datenbank an (Setup-Assistent). + * Der angemeldete Benutzer als Datensatz - oder null. + * + * Wird je Request einmal geladen: so wirkt das Sperren oder Loeschen + * eines Kontos sofort, auch auf laufende Sitzungen. + * + * @return array|null */ - public function configure(string $username, string $plainPassword): void + public function user(): ?array { - $this->settings->set(SettingRepository::ADMIN_USER, $username); - $this->settings->set( - SettingRepository::ADMIN_PASSWORD_HASH, - password_hash($plainPassword, PASSWORD_DEFAULT), - ); - } - - /** - * Prueft Benutzername und Passwort in konstanter Zeit. - */ - public function verifyCredentials(string $username, string $password): bool - { - $hash = $this->passwordHash(); - - if (!is_string($hash) || $hash === '') { - return false; + if ($this->current !== false) { + return $this->current; } - // Immer beide Pruefungen ausfuehren, damit ein falscher - // Benutzername nicht schneller beantwortet wird als ein - // falsches Passwort. - $userOk = hash_equals($this->username(), $username); - $passwordOk = password_verify($password, $hash); + $this->current = null; - return $userOk && $passwordOk; + $id = $this->session->get(self::SESSION_USER_ID); + if (!is_int($id)) { + return null; + } + + $user = $id === self::EMERGENCY_ID + ? $this->emergencyAccount() + : $this->users->find($id); + + if ($user === null || (int) $user['is_active'] !== 1) { + $this->session->destroy(); + + return null; + } + + // Ein Passwortwechsel macht bestehende Sitzungen ungueltig. + if (!hash_equals($this->fingerprint($user), (string) $this->session->get(self::SESSION_FINGERPRINT))) { + $this->session->destroy(); + + return null; + } + + $this->current = $user; + + return $user; } - public function login(string $username): void + public function id(): ?int + { + $user = $this->user(); + + return $user === null ? null : (int) $user['id']; + } + + /** + * Benutzername des angemeldeten Kontos (fuer die Anzeige im Layout). + */ + public function currentUser(): ?string + { + $user = $this->user(); + + return $user === null ? null : (string) $user['username']; + } + + public function isAdmin(): bool + { + $user = $this->user(); + + return $user !== null && Role::globalOrDefault($user['role']) === Role::ADMIN; + } + + /** + * Meldet, ob gerade der Notfallzugang aus der .env benutzt wird. Fuer + * dieses Konto gibt es keinen Datensatz - Profil und Passwortwechsel + * sind deshalb gesperrt. + */ + public function isEmergency(): bool + { + return $this->id() === self::EMERGENCY_ID; + } + + // --- Anmeldung ------------------------------------------------------- + + /** + * Prueft Benutzername und Passwort und liefert das Konto zurueck. + * + * @return array|null + */ + public function verifyCredentials(string $username, string $password): ?array + { + $user = $this->users->findByUsername($username); + + // Auch ohne Treffer wird ein Hash geprueft, damit ein unbekannter + // Benutzername nicht schneller beantwortet wird als ein falsches + // Passwort. + $hash = $user === null ? self::DUMMY_HASH : (string) $user['password_hash']; + $passwordOk = password_verify($password, $hash); + + if ($user !== null && $passwordOk && (int) $user['is_active'] === 1) { + return $user; + } + + return $this->verifyEmergency($username, $password); + } + + /** + * @param array $user + */ + public function login(array $user): void { $this->session->regenerate(); - $this->session->set(self::SESSION_KEY, true); - $this->session->set(self::SESSION_USER, $username); - $this->session->set(self::SESSION_FINGERPRINT, $this->fingerprint()); + $this->session->set(self::SESSION_USER_ID, (int) $user['id']); + $this->session->set(self::SESSION_FINGERPRINT, $this->fingerprint($user)); + + $this->current = $user; + + if ((int) $user['id'] !== self::EMERGENCY_ID) { + $this->users->touchLogin((int) $user['id']); + } } public function logout(): void { $this->session->destroy(); + $this->current = null; } - public function check(): bool + // --- Notfallzugang --------------------------------------------------- + + /** + * Das Konto aus der .env, sofern dort ein Passwort-Hash hinterlegt ist. + * + * @return array|null + */ + public function emergencyAccount(): ?array { - if ($this->session->get(self::SESSION_KEY) !== true) { - return false; + $hash = $this->config->adminPasswordHash(); + + if ($hash === null) { + return null; } - // Passwortwechsel invalidiert bestehende Sessions. - if ($this->session->get(self::SESSION_FINGERPRINT) !== $this->fingerprint()) { - $this->session->destroy(); + return [ + 'id' => self::EMERGENCY_ID, + 'username' => $this->config->adminUser(), + 'password_hash' => $hash, + 'role' => Role::ADMIN, + 'is_active' => 1, + 'created_at' => null, + 'last_login_at' => null, + ]; + } - return false; + /** + * @return array|null + */ + private function verifyEmergency(string $username, string $password): ?array + { + $account = $this->emergencyAccount(); + + if ($account === null) { + return null; } - return true; + $userOk = hash_equals((string) $account['username'], $username); + $passwordOk = password_verify($password, (string) $account['password_hash']); + + return $userOk && $passwordOk ? $account : null; } - public function currentUser(): ?string + /** + * @param array $user + */ + private function fingerprint(array $user): string { - $user = $this->session->get(self::SESSION_USER); - - return is_string($user) ? $user : null; - } - - private function fingerprint(): string - { - return hash('sha256', (string) $this->passwordHash()); + return hash('sha256', $user['id'] . '|' . (string) $user['password_hash']); } } diff --git a/src/Security/RateLimiter.php b/src/Security/RateLimiter.php index d05ce5e..c8e1de0 100644 --- a/src/Security/RateLimiter.php +++ b/src/Security/RateLimiter.php @@ -7,8 +7,12 @@ namespace App\Security; use App\Database; /** - * Rate-Limiting fuer den Login. Speichert nur einen gesalzenen Hash - * der IP-Adresse, niemals die IP selbst. + * Rate-Limiting fuer den Login. + * + * Gezaehlt wird je Schluessel - die Anwendung sperrt sowohl nach + * IP-Adresse als auch nach Benutzername, damit ein Wechsel der IP nicht + * reicht, um alle Konten der Reihe nach durchzuprobieren. Gespeichert + * wird davon nur ein gesalzener Hash, nie der Schluessel selbst. */ final class RateLimiter { @@ -19,46 +23,46 @@ final class RateLimiter ) { } - private function identifier(string $ip): string + private function identifier(string $key): string { - return substr(hash('sha256', 'login|' . $ip), 0, 64); + return substr(hash('sha256', 'login|' . $key), 0, 64); } /** * Anzahl der Fehlversuche im aktuellen Zeitfenster. */ - public function failedAttempts(string $ip): int + public function failedAttempts(string $key): int { $since = gmdate('Y-m-d H:i:s', time() - $this->lockoutSeconds); $count = $this->db->fetchValue( 'SELECT COUNT(*) FROM login_attempts WHERE identifier = ? AND successful = 0 AND attempted_at > ?', - [$this->identifier($ip), $since], + [$this->identifier($key), $since], ); return (int) $count; } - public function isLocked(string $ip): bool + public function isLocked(string $key): bool { - return $this->failedAttempts($ip) >= $this->maxAttempts; + return $this->failedAttempts($key) >= $this->maxAttempts; } - public function remainingAttempts(string $ip): int + public function remainingAttempts(string $key): int { - return max(0, $this->maxAttempts - $this->failedAttempts($ip)); + return max(0, $this->maxAttempts - $this->failedAttempts($key)); } /** * Verbleibende Sperrzeit in Sekunden. */ - public function retryAfter(string $ip): int + public function retryAfter(string $key): int { $row = $this->db->fetchOne( 'SELECT attempted_at FROM login_attempts WHERE identifier = ? AND successful = 0 ORDER BY attempted_at DESC LIMIT 1', - [$this->identifier($ip)], + [$this->identifier($key)], ); if ($row === null) { @@ -70,22 +74,22 @@ final class RateLimiter return max(0, $this->lockoutSeconds - (time() - $last)); } - public function recordFailure(string $ip): void + public function recordFailure(string $key): void { $this->db->execute( 'INSERT INTO login_attempts (identifier, attempted_at, successful) VALUES (?, ?, 0)', - [$this->identifier($ip), gmdate('Y-m-d H:i:s')], + [$this->identifier($key), gmdate('Y-m-d H:i:s')], ); $this->pruneOldEntries(); } - public function recordSuccess(string $ip): void + public function recordSuccess(string $key): void { // Erfolgreicher Login setzt den Zaehler zurueck. $this->db->execute( 'DELETE FROM login_attempts WHERE identifier = ?', - [$this->identifier($ip)], + [$this->identifier($key)], ); } diff --git a/src/Security/Role.php b/src/Security/Role.php new file mode 100644 index 0000000..ad483cd --- /dev/null +++ b/src/Security/Role.php @@ -0,0 +1,61 @@ + */ + public const GLOBAL_LABELS = [ + self::ADMIN => 'Administrator', + self::USER => 'Benutzer', + ]; + + /** @var array */ + public const GROUP_LABELS = [ + self::EDITOR => 'Editor (anlegen und ändern)', + self::VIEWER => 'Viewer (nur ansehen)', + ]; + + /** + * Unbekannte Werte fallen auf die jeweils schwaechere Rolle zurueck - + * ein Tippfehler in der Datenbank darf keine Rechte verschenken. + */ + public static function globalOrDefault(mixed $value): string + { + return $value === self::ADMIN ? self::ADMIN : self::USER; + } + + public static function groupOrDefault(mixed $value): string + { + return $value === self::EDITOR ? self::EDITOR : self::VIEWER; + } + + public static function globalLabel(mixed $value): string + { + return self::GLOBAL_LABELS[self::globalOrDefault($value)]; + } + + public static function groupLabel(mixed $value): string + { + return self::GROUP_LABELS[self::groupOrDefault($value)]; + } +} diff --git a/src/Support/Password.php b/src/Support/Password.php new file mode 100644 index 0000000..85391b3 --- /dev/null +++ b/src/Support/Password.php @@ -0,0 +1,44 @@ + Feldname => Fehlermeldung, leer wenn alles passt + */ + public static function validationErrors(string $password, string $confirm): array + { + $errors = []; + + if (mb_strlen($password) < self::MIN_LENGTH) { + $errors['password'] = sprintf('Das Passwort muss mindestens %d Zeichen lang sein.', self::MIN_LENGTH); + } + + // Sehr lange Eingaben abweisen: bcrypt schneidet nach 72 Byte ab, + // alles darueber waere eine truegerische Sicherheit. + if (strlen($password) > 72) { + $errors['password'] = 'Das Passwort darf höchstens 72 Zeichen lang sein.'; + } + + if ($password !== $confirm) { + $errors['password_confirm'] = 'Die beiden Passwörter stimmen nicht überein.'; + } + + return $errors; + } +} diff --git a/src/Support/Username.php b/src/Support/Username.php new file mode 100644 index 0000000..e418b1c --- /dev/null +++ b/src/Support/Username.php @@ -0,0 +1,32 @@ +