Files
KantSites/src/Security/UserDirectory.php
T
kantarciandClaude Fable 5.1 7eb462ac7b Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
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>
2026-09-15 20:15:20 +02:00

208 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}