SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber sends the other spelling there with 301, cms:site:origin writes the setting, the system screen shows it. The roles now match the role help (editor: drafts, publisher: live content, admin: design, settings, users, system). The installer no longer reopens on an unreachable database, the session cookie follows ADMIN_PATH, percent-encoded credentials no longer break the container, consent only accepts same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the editor, updater, download and public-output findings from the full review (ADR 099, ADR 100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
208 lines
8.6 KiB
PHP
208 lines
8.6 KiB
PHP
<?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, 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<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);
|
||
}
|
||
}
|