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
@@ -8,6 +8,9 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final class MediaLibrary
|
||||
{
|
||||
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
|
||||
public const PER_PAGE = 24;
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
@@ -17,6 +20,36 @@ final class MediaLibrary
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative('SELECT * FROM media ORDER BY created_at DESC, id DESC'));
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the library, newest first.
|
||||
*
|
||||
* The media screen renders the images itself instead of fetching them, so it has to ask for a
|
||||
* slice: a library of a few hundred images would otherwise be one very long page and a lot of
|
||||
* thumbnails at once.
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
|
||||
*/
|
||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE): array
|
||||
{
|
||||
$where = $withArchived ? '' : ' WHERE archived = 0';
|
||||
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where);
|
||||
$perPage = max(1, min(96, $perPage));
|
||||
$pages = max(1, (int) ceil($total / $perPage));
|
||||
$page = max(1, min($pages, $page));
|
||||
$items = $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
|
||||
[$perPage, ($page - 1) * $perPage],
|
||||
);
|
||||
|
||||
return [
|
||||
'items' => array_map($this->present(...), $items),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pages' => $pages,
|
||||
'perPage' => $perPage,
|
||||
];
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||
|
||||
@@ -19,9 +19,80 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return $this->render('admin/media.html.twig');
|
||||
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
||||
// to pick an image while editing, not the way to see what the library holds.
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
|
||||
return $this->render('admin/media.html.twig', [
|
||||
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
|
||||
'archived' => $archived,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||
public function formUpload(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$files = array_filter((array) $request->files->get('files'), static fn ($file): bool => $file instanceof UploadedFile);
|
||||
if ([] === $files) {
|
||||
return $this->back($request, 'Keine Datei empfangen. Bitte die Dateigröße und das PHP-Uploadlimit prüfen.');
|
||||
}
|
||||
$done = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->media->upload($file);
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : ''));
|
||||
} catch (\RuntimeException) {
|
||||
return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdate(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->media->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->media->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'alt' => $request->request->getString('alt'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Bild wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
/** Back to the page the form was on, so a change does not jump to the first page. */
|
||||
private function back(Request $request, string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
$query = array_filter([
|
||||
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
||||
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('admin_media', $query);
|
||||
}
|
||||
|
||||
#[Route('/admin/api/media', methods: ['GET'])]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidAccount;
|
||||
use App\Security\UserDirectory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
|
||||
/**
|
||||
* Accounts. The list and every change to somebody else's account need ROLE_ADMIN; the own password
|
||||
* is the one thing every signed in account may change, and only with the current one in hand.
|
||||
*/
|
||||
final class UserController extends AbstractController
|
||||
{
|
||||
public function __construct(private UserDirectory $users, private TokenStorageInterface $tokens)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer', name: 'admin_users', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return $this->render('admin/users.html.twig', [
|
||||
'users' => $this->users->all(),
|
||||
'roles' => UserDirectory::ROLES,
|
||||
'roleHelp' => UserDirectory::ROLE_HELP,
|
||||
'minPassword' => UserDirectory::MIN_PASSWORD,
|
||||
'maxPassword' => UserDirectory::MAX_PASSWORD,
|
||||
'me' => $this->cmsUser()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->create(
|
||||
$request->request->getString('username'),
|
||||
$request->request->getString('password'),
|
||||
$request->request->getString('role'),
|
||||
);
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Konto wurde angelegt.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function role(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->changeRole($id, $request->request->getString('role'), $this->cmsUser());
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Rolle wurde geändert. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function password(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->setPassword($id, $request->request->getString('password'));
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Passwort wurde gesetzt. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->delete($id, $this->cmsUser());
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Konto wurde gelöscht.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/konto', name: 'admin_account', methods: ['GET'])]
|
||||
public function account(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$me = $this->cmsUser();
|
||||
$mine = null;
|
||||
foreach ($this->users->all() as $account) {
|
||||
if ($account['id'] === $me->id) {
|
||||
$mine = $account;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('admin/account.html.twig', [
|
||||
'account' => $mine,
|
||||
'roles' => UserDirectory::ROLES,
|
||||
'roleHelp' => UserDirectory::ROLE_HELP,
|
||||
'minPassword' => UserDirectory::MIN_PASSWORD,
|
||||
'maxPassword' => UserDirectory::MAX_PASSWORD,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
|
||||
public function ownPassword(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('account', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', 'error', 'admin_account');
|
||||
}
|
||||
$me = $this->cmsUser();
|
||||
try {
|
||||
$fresh = $this->users->changeOwnPassword(
|
||||
$me,
|
||||
$request->request->getString('current'),
|
||||
$request->request->getString('password'),
|
||||
);
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage(), 'error', 'admin_account');
|
||||
}
|
||||
|
||||
// Keep this session signed in with the new hash. Every other session of the account fails
|
||||
// its next refresh against the database and is logged out - which is the point.
|
||||
$this->tokens->setToken(new UsernamePasswordToken($fresh, 'admin', $fresh->getRoles()));
|
||||
|
||||
return $this->back('Das Passwort wurde geändert. Andere offene Sitzungen dieses Kontos sind abgemeldet.', 'success', 'admin_account');
|
||||
}
|
||||
|
||||
/** Every write needs the administrator role and a valid token; the check is the same each time. */
|
||||
private function guard(Request $request): ?Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('users', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function cmsUser(): CmsUser
|
||||
{
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof CmsUser) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error', string $route = 'admin_users'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute($route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
/** An account change the CMS refuses to make, with a message meant for the administrator. */
|
||||
final class InvalidAccount extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use App\Security\UserDirectory;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||
|
||||
/**
|
||||
* Records when an account was last used. Not a security control - it answers the question whether
|
||||
* an account is still in use before somebody deletes it. The column is outside everything
|
||||
* CmsUser::isEqualTo compares, so writing it does not end the session that just started.
|
||||
*/
|
||||
final class LoginSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private UserDirectory $users)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [LoginSuccessEvent::class => 'onLogin'];
|
||||
}
|
||||
|
||||
public function onLogin(LoginSuccessEvent $event): void
|
||||
{
|
||||
$user = $event->getUser();
|
||||
if ($user instanceof CmsUser) {
|
||||
$this->users->recordLogin($user->getUserIdentifier());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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