'Administrator', 'ROLE_PUBLISHER' => 'Veröffentlicher', 'ROLE_EDITOR' => 'Redakteur', ]; public const ROLE_HELP = [ 'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.', 'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, dazu Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite – aber keine Benutzer, kein Design, keine Unternehmensdaten und keine Skripte.', 'ROLE_EDITOR' => 'Darf Entwürfe von Seiten und Beiträgen bearbeiten, Medien hochladen und die Statistik sehen, aber nichts veröffentlichen.', ]; /** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */ public const MIN_PASSWORD = 14; public const MAX_PASSWORD = 72; public function __construct(private Connection $db, private UserPasswordHasherInterface $hasher) { } /** * @return list */ public function all(): array { return array_map( fn (array $row): array => [ 'id' => $row['id'], 'username' => $row['username'], 'role' => $role = $this->roleOf($row['roles']), 'roleName' => self::ROLES[$role], 'createdAt' => $row['created_at'], 'lastLoginAt' => $row['last_login_at'], 'passwordChangedAt' => $row['password_changed_at'], ], $this->db->fetchAllAssociative('SELECT * FROM cms_user ORDER BY username'), ); } public function create(string $username, string $password, string $role): string { $username = $this->checkUsername($username); $this->checkPassword($password); $this->checkRole($role); if (false !== $this->db->fetchOne('SELECT id FROM cms_user WHERE username = ?', [$username])) { throw new InvalidAccount(sprintf('Der Benutzername „%s“ ist schon vergeben.', $username)); } $id = bin2hex(random_bytes(16)); $now = gmdate('Y-m-d H:i:s'); $this->db->insert('cms_user', [ 'id' => $id, 'username' => $username, 'password_hash' => $this->hash($username, $password), 'roles' => json_encode([$role], JSON_THROW_ON_ERROR), 'created_at' => $now, 'password_changed_at' => $now, ]); return $id; } public function changeRole(string $id, string $role, CmsUser $actor): void { $this->checkRole($role); $account = $this->require($id); if ($account['id'] === $actor->id) { throw new InvalidAccount('Die eigene Rolle lässt sich hier nicht ändern. Das soll ein zweiter Administrator tun.'); } $this->db->executeStatement('UPDATE cms_user SET roles = ? WHERE id = ?', [json_encode([$role], JSON_THROW_ON_ERROR), $id]); } /** An administrator setting someone else's password. Ends every session of that account. */ public function setPassword(string $id, string $password): void { $account = $this->require($id); $this->checkPassword($password); $this->db->executeStatement( 'UPDATE cms_user SET password_hash = ?, password_changed_at = ? WHERE id = ?', [$this->hash($account['username'], $password), gmdate('Y-m-d H:i:s'), $id], ); } /** * Somebody changing their own password. The current one has to be right, so a forgotten open * session cannot be used to take the account over. */ public function changeOwnPassword(CmsUser $user, string $current, string $password): CmsUser { if (!$this->hasher->isPasswordValid($user, $current)) { throw new InvalidAccount('Das aktuelle Passwort stimmt nicht.'); } if (hash_equals($current, $password)) { throw new InvalidAccount('Das neue Passwort ist das alte.'); } $this->checkPassword($password); $hash = $this->hash($user->getUserIdentifier(), $password); $this->db->executeStatement( 'UPDATE cms_user SET password_hash = ?, password_changed_at = ? WHERE id = ?', [$hash, gmdate('Y-m-d H:i:s'), $user->id], ); // The caller puts this user into the session, so this one session stays signed in while // every other session of the account is logged out on its next request. return new CmsUser($user->id, $user->getUserIdentifier(), $hash, $user->getRoles()); } public function delete(string $id, CmsUser $actor): void { $account = $this->require($id); if ($account['id'] === $actor->id) { throw new InvalidAccount('Das eigene Konto lässt sich nicht löschen. Das soll ein zweiter Administrator tun.'); } $this->db->executeStatement('DELETE FROM cms_user WHERE id = ?', [$id]); } public function recordLogin(string $username): void { $this->db->executeStatement( 'UPDATE cms_user SET last_login_at = ? WHERE username = ?', [gmdate('Y-m-d H:i:s'), strtolower(trim($username))], ); } public function checkUsername(string $username): string { $username = strtolower(trim($username)); if (1 !== preg_match('/^[a-z0-9][a-z0-9._-]{2,99}$/D', $username)) { throw new InvalidAccount('Benutzername: 3 bis 100 Zeichen, Kleinbuchstaben, Ziffern, Punkt, Unterstrich oder Bindestrich, beginnend mit Buchstabe oder Ziffer.'); } return $username; } public function checkPassword(string $password): void { if (str_contains($password, "\0")) { throw new InvalidAccount('Das Passwort darf kein Nullbyte enthalten.'); } $length = strlen($password); if ($length < self::MIN_PASSWORD || $length > self::MAX_PASSWORD) { throw new InvalidAccount(sprintf('Das Passwort braucht mindestens %d Zeichen und darf höchstens %d Bytes lang sein.', self::MIN_PASSWORD, self::MAX_PASSWORD)); } } /** @return array{id: string, username: string, role: string} */ private function require(string $id): array { $row = $this->db->fetchAssociative('SELECT id, username, roles FROM cms_user WHERE id = ?', [$id]); if (false === $row) { throw new InvalidAccount('Dieses Konto gibt es nicht mehr.'); } return ['id' => $row['id'], 'username' => $row['username'], 'role' => $this->roleOf($row['roles'])]; } private function checkRole(string $role): void { if (!isset(self::ROLES[$role])) { throw new InvalidAccount('Unbekannte Rolle.'); } } /** The stored list can hold several roles; the highest one is what the administration shows. */ private function roleOf(string $stored): string { $roles = json_decode($stored, true, 512, JSON_THROW_ON_ERROR); foreach (array_keys(self::ROLES) as $role) { if (in_array($role, (array) $roles, true)) { return $role; } } return 'ROLE_EDITOR'; } private function hash(string $username, string $password): string { return $this->hasher->hashPassword(new CmsUser('', $username, '', ['ROLE_EDITOR']), $password); } }