Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite

Konten gab es bisher nur über die Konsole. Unter "Benutzer" legt ein
Administrator jetzt Konten an, ändert Rollen, setzt Passwörter und löscht
Konten; unter "Mein Konto" ändert jeder sein eigenes Passwort, aber nur mit
dem aktuellen in der Hand.

Eine Rolle je Konto: Administrator, Veröffentlicher, Redakteur. Ein Passwort
oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab - dafür
war nichts zu bauen, CmsUser::isEqualTo vergleicht Hash und Rollen mit der
Datenbank. Genau eine Sitzung überlebt: die, die ihr eigenes Passwort geändert
hat. "Jemanden sofort aussperren" ist damit dasselbe wie "Passwort setzen".

Es bleibt immer ein Administrator übrig, nicht über eine Zählung, sondern weil
niemand sein eigenes Konto löschen oder herabstufen kann und jede Änderung die
Administratorrolle voraussetzt. Die zusätzliche Prüfung auf einen zweiten
Administrator war damit unerreichbar und ist wieder heraus.

Mediathek (Umut: "das sollte direkt laden in pagination"): /admin/media zeigt
die Bilder jetzt selbst, 24 je Seite, neueste zuerst, mit Blättern und einem
Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche
Formulare ohne JavaScript; der Dialog bleibt der Weg, beim Bearbeiten ein Bild
auszuwählen. Die Bild-ID steht in jeder Karte, weil Beitragsbild und
Teilen-Vorschau genau diese ID erwarten.

Die Migrationsliste der Testfixtures stand dreimal da und steht jetzt einmal
in tests/TestSchema.php.

217 PHP-Tests / 845 Assertions unter PHP 8.2.12 und 8.4.22. axe deckt jetzt
elf Bildschirme ab statt drei - alle Verwaltungsseiten, nicht nur Anmeldung,
Seitenliste und Editor -, null Verstöße. Nicht geprüft: kein Screenreader;
Blättern und Upload sind mit zwei Bildern geprüft, nicht mit einer großen
Bibliothek.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 18:14:50 +02:00
1 parent 4841bf65bc
commit 14031a5880
23 files changed
+1281 -101

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
<?php
namespace App\Security;
use App\Domain\CmsUser;
use App\Domain\InvalidAccount;
use Doctrine\DBAL\Connection;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* The accounts, as the administration sees them.
*
* Two rules are enforced here rather than in the controller, because they are the ones that lock
* people out of their own website: nobody can delete or demote their own account. Together with
* the administrator role that every change requires, that keeps at least one administrator alive -
* removing or demoting an administrator takes a second administrator, who then remains. The
* console command stays as the way in when no account works any more.
*
* Changing a password or a role ends every session of that account. That is not extra work: the
* session stores the password hash and the roles, CmsUser::isEqualTo compares them against the
* database on the next request, and a difference logs the session out. The one session that
* survives is the one that made its own change, because the controller hands it the fresh user.
*/
final class UserDirectory
{
/** The roles an account can have, in descending order. ROLE_ADMIN implies the other two. */
public const ROLES = [
'ROLE_ADMIN' => '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, aber keine Benutzer verwalten und keine Skripte setzen.',
'ROLE_EDITOR' => 'Darf Entwürfe bearbeiten und speichern, aber nicht 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<array{id: string, username: string, role: string, roleName: string,
* createdAt: ?string, lastLoginAt: ?string, passwordChangedAt: ?string}>
*/
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);
}
}