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 @@ +