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:
1 parent
4841bf65bc
commit
14031a5880
23 files changed
+1281
-101
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user