feat(auth): Benutzer, Gruppen und Rechte als Datenmodell
Loest das eine fest verdrahtete Admin-Konto durch beliebig viele Konten ab, die in Gruppen organisiert sind. Die Zugriffskette lautet Benutzer -> Gruppe -> Domain -> Kurzlink: eine Domain gehoert zu genau einer Gruppe, ein Kurzlink haengt an seiner Domain. Wer Mitglied einer Gruppe ist, sieht damit alle Kurzlinks aller Domains dieser Gruppe. Ein Kurzlink hat bewusst keinen persoenlichen Eigentuemer - sonst wuerde das Loeschen eines Kontos gedruckte QR-Codes unbrauchbar machen. Zwei Rollenebenen: global "admin" (verwaltet Benutzer, Gruppen und Domains, sieht alles) bzw. "user", innerhalb einer Gruppe "editor" (anlegen und aendern) bzw. "viewer" (nur lesen). AccessPolicy beantwortet je Request, welche Domains sichtbar bzw. beschreibbar sind; die Repositories nehmen diese Liste als Filter. Dabei ist die Unterscheidung wesentlich: null heisst "keine Einschraenkung" (Administrator), ein leeres Array heisst "nichts" - und darf gerade nicht zu einer Abfrage ohne Bedingung werden. Der Zugang aus der .env (ADMIN_USER / ADMIN_PASSWORD_HASH) bleibt gueltig, steht aber ab jetzt als Notfallzugang neben den regulaeren Konten, statt sie zu ueberstimmen. Er hat Administratorrechte und existiert nur fuer den Fall, dass das letzte Administratorkonto gesperrt wurde oder sein Passwort verloren ging. Migration 003 uebernimmt das Bestandskonto aus der settings-Tabelle als Benutzer Nr. 1 mit unveraendertem Hash, legt die Gruppe "Standard" an, ordnet ihr alle vorhandenen Domains zu und raeumt die beiden alten settings-Schluessel auf. Nebenbei geschlossen: das Rate-Limiting am Login zaehlte nur nach IP-Adresse. Es zaehlt jetzt auch je Benutzername, sonst reicht ein Wechsel der Adresse, um alle Konten der Reihe nach durchzuprobieren. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mehrere Benutzer, organisiert in Gruppen.
|
||||
*
|
||||
* Aufbau:
|
||||
* - "users" loest das eine Konto aus der settings-Tabelle ab. Die globale
|
||||
* Rolle ist entweder "admin" (Benutzer, Gruppen und Domains verwalten)
|
||||
* oder "user" (nur das, was die Gruppenmitgliedschaft hergibt).
|
||||
* - "user_groups" buendelt Benutzer. Der Name lautet bewusst nicht
|
||||
* "groups": das ist seit MySQL 8.0 ein reserviertes Wort.
|
||||
* - "group_members" verbindet beides und traegt die Rolle innerhalb der
|
||||
* Gruppe: "editor" darf anlegen und aendern, "viewer" nur lesen.
|
||||
* - Eine Domain gehoert zu genau einer Gruppe. Ein Kurzlink haengt an
|
||||
* seiner Domain und damit ebenfalls an genau einer Gruppe - deshalb
|
||||
* braucht die links-Tabelle keine eigene Spalte.
|
||||
*
|
||||
* Bestandsdaten: alle vorhandenen Domains wandern in die Gruppe
|
||||
* "Standard" (Kennung 1), das bisherige Konto aus der settings-Tabelle
|
||||
* wird zum ersten Administrator. Erst danach werden die beiden
|
||||
* settings-Schluessel geloescht, damit keine veralteten Zugangsdaten
|
||||
* zurueckbleiben.
|
||||
*/
|
||||
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
|
||||
/**
|
||||
* Uebernimmt das bisherige Einzelkonto. Laeuft ins Leere, wenn noch
|
||||
* keines eingerichtet war (frische Installation) - dann legt der
|
||||
* Setup-Assistent den ersten Benutzer an.
|
||||
*/
|
||||
$adoptAdmin = "INSERT INTO users (username, password_hash, role, is_active, created_at)
|
||||
SELECT COALESCE(
|
||||
(SELECT value FROM settings WHERE key_name = 'admin_user' AND value IS NOT NULL AND value <> ''),
|
||||
'admin'
|
||||
),
|
||||
s.value,
|
||||
'admin',
|
||||
1,
|
||||
'{$now}'
|
||||
FROM settings s
|
||||
WHERE s.key_name = 'admin_password_hash' AND s.value IS NOT NULL AND s.value <> ''";
|
||||
|
||||
return [
|
||||
'sqlite' => [
|
||||
'CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT "user",
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT NULL
|
||||
)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS group_members (
|
||||
group_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT "editor",
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members (user_id)',
|
||||
|
||||
'ALTER TABLE domains ADD COLUMN group_id INTEGER NOT NULL DEFAULT 0',
|
||||
'CREATE INDEX IF NOT EXISTS idx_domains_group ON domains (group_id)',
|
||||
|
||||
"INSERT INTO user_groups (id, name, created_at) VALUES (1, 'Standard', '{$now}')",
|
||||
'UPDATE domains SET group_id = 1',
|
||||
|
||||
$adoptAdmin,
|
||||
|
||||
"INSERT INTO group_members (group_id, user_id, role, created_at)
|
||||
SELECT 1, id, 'editor', '{$now}' FROM users",
|
||||
|
||||
"DELETE FROM settings WHERE key_name IN ('admin_user', 'admin_password_hash')",
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(190) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT "user",
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
last_login_at DATETIME NULL,
|
||||
UNIQUE KEY uniq_users_username (username)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uniq_user_groups_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS group_members (
|
||||
group_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT "editor",
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (group_id, user_id),
|
||||
KEY idx_group_members_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'ALTER TABLE domains ADD COLUMN group_id INT NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE domains ADD INDEX idx_domains_group (group_id)',
|
||||
|
||||
"INSERT INTO user_groups (id, name, created_at) VALUES (1, 'Standard', '{$now}')",
|
||||
'UPDATE domains SET group_id = 1',
|
||||
|
||||
$adoptAdmin,
|
||||
|
||||
"INSERT INTO group_members (group_id, user_id, role, created_at)
|
||||
SELECT 1, id, 'editor', '{$now}' FROM (SELECT id FROM users) AS existing_users",
|
||||
|
||||
"DELETE FROM settings WHERE key_name IN ('admin_user', 'admin_password_hash')",
|
||||
],
|
||||
];
|
||||
@@ -20,8 +20,11 @@ final class DomainRepository
|
||||
/** Kennung fuer noch nicht zugeordnete Bestandslinks. */
|
||||
public const UNASSIGNED = 0;
|
||||
|
||||
public function __construct(private readonly Database $db)
|
||||
private readonly GroupRepository $groups;
|
||||
|
||||
public function __construct(private readonly Database $db, ?GroupRepository $groups = null)
|
||||
{
|
||||
$this->groups = $groups ?? new GroupRepository($db);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,12 +34,26 @@ final class DomainRepository
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT d.*,
|
||||
(SELECT COUNT(*) FROM links l WHERE l.domain_id = d.id) AS link_count
|
||||
(SELECT COUNT(*) FROM links l WHERE l.domain_id = d.id) AS link_count,
|
||||
(SELECT g.name FROM user_groups g WHERE g.id = d.group_id) AS group_name
|
||||
FROM domains d
|
||||
ORDER BY d.is_default DESC, d.host ASC',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Domains einer Gruppe.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function allInGroup(int $groupId): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT * FROM domains WHERE group_id = ? ORDER BY is_default DESC, host ASC',
|
||||
[$groupId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
@@ -133,14 +150,24 @@ final class DomainRepository
|
||||
return $this->find($id) ?? throw new RuntimeException('Standarddomain konnte nicht angelegt werden.');
|
||||
}
|
||||
|
||||
public function create(string $baseUrl, ?string $label, bool $isDefault): int
|
||||
/**
|
||||
* @param int|null $groupId ohne Angabe die Standardgruppe
|
||||
*/
|
||||
public function create(string $baseUrl, ?string $label, bool $isDefault, ?int $groupId = null): int
|
||||
{
|
||||
$baseUrl = ShortDomain::normalizeBaseUrl($baseUrl);
|
||||
$host = ShortDomain::hostOf($baseUrl);
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO domains (host, base_url, label, is_default, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[$host, $baseUrl, $label, $isDefault ? 1 : 0, gmdate('Y-m-d H:i:s')],
|
||||
'INSERT INTO domains (host, base_url, label, is_default, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$host,
|
||||
$baseUrl,
|
||||
$label,
|
||||
$isDefault ? 1 : 0,
|
||||
$groupId ?? $this->groups->defaultGroupId(),
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
],
|
||||
);
|
||||
|
||||
$id = $this->db->lastInsertId();
|
||||
@@ -152,15 +179,27 @@ final class DomainRepository
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function update(int $id, string $baseUrl, ?string $label): void
|
||||
/**
|
||||
* @param int|null $groupId null laesst die bisherige Gruppe unveraendert
|
||||
*/
|
||||
public function update(int $id, string $baseUrl, ?string $label, ?int $groupId = null): void
|
||||
{
|
||||
$baseUrl = ShortDomain::normalizeBaseUrl($baseUrl);
|
||||
$host = ShortDomain::hostOf($baseUrl);
|
||||
|
||||
if ($groupId === null) {
|
||||
$this->db->execute(
|
||||
'UPDATE domains SET host = ?, base_url = ?, label = ? WHERE id = ?',
|
||||
[$host, $baseUrl, $label, $id],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'UPDATE domains SET host = ?, base_url = ?, label = ?, group_id = ? WHERE id = ?',
|
||||
[$host, $baseUrl, $label, $groupId, $id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Database;
|
||||
use App\Security\Role;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Gruppen buendeln Benutzer und Domains.
|
||||
*
|
||||
* Eine Domain gehoert zu genau einer Gruppe; ein Kurzlink haengt an
|
||||
* seiner Domain. Wer Mitglied einer Gruppe ist, sieht damit alle
|
||||
* Kurzlinks aller Domains dieser Gruppe.
|
||||
*/
|
||||
final class GroupRepository
|
||||
{
|
||||
/** Name der Gruppe, die bei Bedarf automatisch entsteht. */
|
||||
public const DEFAULT_NAME = 'Standard';
|
||||
|
||||
public function __construct(private readonly Database $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT g.*,
|
||||
(SELECT COUNT(*) FROM group_members m WHERE m.group_id = g.id) AS member_count,
|
||||
(SELECT COUNT(*) FROM domains d WHERE d.group_id = g.id) AS domain_count
|
||||
FROM user_groups g
|
||||
ORDER BY g.name ASC',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
return $this->db->fetchOne('SELECT * FROM user_groups WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public function nameExists(string $name, ?int $exceptId = null): bool
|
||||
{
|
||||
$lower = mb_strtolower($name, 'UTF-8');
|
||||
|
||||
if ($exceptId === null) {
|
||||
return $this->db->fetchValue('SELECT 1 FROM user_groups WHERE LOWER(name) = ?', [$lower]) !== null;
|
||||
}
|
||||
|
||||
return $this->db->fetchValue(
|
||||
'SELECT 1 FROM user_groups WHERE LOWER(name) = ? AND id <> ?',
|
||||
[$lower, $exceptId],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
public function create(string $name): int
|
||||
{
|
||||
$this->db->execute(
|
||||
'INSERT INTO user_groups (name, created_at) VALUES (?, ?)',
|
||||
[$name, gmdate('Y-m-d H:i:s')],
|
||||
);
|
||||
|
||||
return $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function rename(int $id, string $name): void
|
||||
{
|
||||
$this->db->execute('UPDATE user_groups SET name = ? WHERE id = ?', [$name, $id]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->db->execute('DELETE FROM group_members WHERE group_id = ?', [$id]);
|
||||
$this->db->execute('DELETE FROM user_groups WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->db->fetchValue('SELECT COUNT(*) FROM user_groups');
|
||||
}
|
||||
|
||||
public function domainCount(int $id): int
|
||||
{
|
||||
return (int) $this->db->fetchValue('SELECT COUNT(*) FROM domains WHERE group_id = ?', [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennung der Gruppe, in die neue Domains ohne ausdrueckliche Angabe
|
||||
* wandern. Legt "Standard" an, falls es noch gar keine Gruppe gibt -
|
||||
* damit funktioniert eine frische Installation ohne Vorarbeit.
|
||||
*/
|
||||
public function defaultGroupId(): int
|
||||
{
|
||||
$id = $this->db->fetchValue('SELECT id FROM user_groups ORDER BY id ASC LIMIT 1');
|
||||
|
||||
if ($id !== null) {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
$created = $this->create(self::DEFAULT_NAME);
|
||||
|
||||
if ($created <= 0) {
|
||||
throw new RuntimeException('Die Standardgruppe konnte nicht angelegt werden.');
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
// --- Mitgliedschaften ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Mitglieder einer Gruppe samt ihrer Rolle darin.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function members(int $groupId): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT u.id, u.username, u.role AS global_role, u.is_active, m.role AS group_role
|
||||
FROM group_members m
|
||||
JOIN users u ON u.id = m.user_id
|
||||
WHERE m.group_id = ?
|
||||
ORDER BY u.username ASC',
|
||||
[$groupId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppen eines Benutzers samt seiner Rolle darin.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function groupsOfUser(int $userId): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT g.id, g.name, m.role AS group_role
|
||||
FROM group_members m
|
||||
JOIN user_groups g ON g.id = m.group_id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY g.name ASC',
|
||||
[$userId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Gruppen, in denen der Benutzer Mitglied ist.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function groupIdsForUser(int $userId): array
|
||||
{
|
||||
return $this->idList(
|
||||
'SELECT group_id FROM group_members WHERE user_id = ?',
|
||||
[$userId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur die Gruppen, in denen der Benutzer schreiben darf.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function editableGroupIdsForUser(int $userId): array
|
||||
{
|
||||
return $this->idList(
|
||||
'SELECT group_id FROM group_members WHERE user_id = ? AND role = ?',
|
||||
[$userId, Role::EDITOR],
|
||||
);
|
||||
}
|
||||
|
||||
public function roleInGroup(int $userId, int $groupId): ?string
|
||||
{
|
||||
$role = $this->db->fetchValue(
|
||||
'SELECT role FROM group_members WHERE user_id = ? AND group_id = ?',
|
||||
[$userId, $groupId],
|
||||
);
|
||||
|
||||
return $role === null ? null : Role::groupOrDefault((string) $role);
|
||||
}
|
||||
|
||||
public function setMember(int $groupId, int $userId, string $role): void
|
||||
{
|
||||
$role = Role::groupOrDefault($role);
|
||||
$exists = $this->db->fetchValue(
|
||||
'SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[$groupId, $userId],
|
||||
) !== null;
|
||||
|
||||
if ($exists) {
|
||||
$this->db->execute(
|
||||
'UPDATE group_members SET role = ? WHERE group_id = ? AND user_id = ?',
|
||||
[$role, $groupId, $userId],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO group_members (group_id, user_id, role, created_at) VALUES (?, ?, ?, ?)',
|
||||
[$groupId, $userId, $role, gmdate('Y-m-d H:i:s')],
|
||||
);
|
||||
}
|
||||
|
||||
public function removeMember(int $groupId, int $userId): void
|
||||
{
|
||||
$this->db->execute(
|
||||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[$groupId, $userId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $params
|
||||
* @return list<int>
|
||||
*/
|
||||
private function idList(string $sql, array $params): array
|
||||
{
|
||||
$rows = $this->db->fetchAll($sql, $params);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (array $row): int => (int) reset($row),
|
||||
$rows,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,41 @@ final class LinkRepository
|
||||
* Spalten der Domain, die bei Listen und Detailabfragen mitgelesen
|
||||
* werden, damit die Kurz-URL ohne zweite Abfrage gebaut werden kann.
|
||||
*/
|
||||
private const DOMAIN_COLUMNS = 'd.base_url AS domain_base_url, d.host AS domain_host, d.label AS domain_label';
|
||||
private const DOMAIN_COLUMNS = 'd.base_url AS domain_base_url, d.host AS domain_host, d.label AS domain_label, d.group_id AS domain_group_id';
|
||||
|
||||
public function __construct(private readonly Database $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Bedingung, die eine Abfrage auf die fuer den angemeldeten
|
||||
* Benutzer sichtbaren Domains einschraenkt.
|
||||
*
|
||||
* Die drei Faelle sind bewusst verschieden: null heisst "keine
|
||||
* Einschraenkung" (Administrator), eine leere Liste heisst "nichts
|
||||
* sichtbar" - und darf gerade nicht zu einer Abfrage ohne Bedingung
|
||||
* werden.
|
||||
*
|
||||
* @param list<int>|null $allowedDomainIds
|
||||
* @param array<int,mixed> $params wird um die Platzhalterwerte ergaenzt
|
||||
*/
|
||||
private static function domainScope(?array $allowedDomainIds, string $column, array &$params): ?string
|
||||
{
|
||||
if ($allowedDomainIds === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($allowedDomainIds === []) {
|
||||
return '0 = 1';
|
||||
}
|
||||
|
||||
foreach ($allowedDomainIds as $id) {
|
||||
$params[] = $id;
|
||||
}
|
||||
|
||||
return $column . ' IN (' . implode(', ', array_fill(0, count($allowedDomainIds), '?')) . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Slug ist nur innerhalb seiner Domain eindeutig.
|
||||
*
|
||||
@@ -57,15 +86,23 @@ final class LinkRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kurzlink - aber nur, wenn seine Domain fuer den Aufrufer
|
||||
* sichtbar ist. Ein fremder Link ist damit nicht von einem gar nicht
|
||||
* vorhandenen zu unterscheiden.
|
||||
*
|
||||
* @param list<int>|null $allowedDomainIds null = keine Einschraenkung
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find(int $id): ?array
|
||||
public function find(int $id, ?array $allowedDomainIds = null): ?array
|
||||
{
|
||||
$params = [$id];
|
||||
$scope = self::domainScope($allowedDomainIds, 'l.domain_id', $params);
|
||||
|
||||
return $this->db->fetchOne(
|
||||
'SELECT l.*, ' . self::DOMAIN_COLUMNS . '
|
||||
FROM links l LEFT JOIN domains d ON d.id = l.domain_id
|
||||
WHERE l.id = ?',
|
||||
[$id],
|
||||
WHERE l.id = ?' . ($scope === null ? '' : ' AND ' . $scope),
|
||||
$params,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +131,7 @@ final class LinkRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int>|null $allowedDomainIds null = keine Einschraenkung
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function all(
|
||||
@@ -102,6 +140,7 @@ final class LinkRepository
|
||||
int $limit = 200,
|
||||
int $offset = 0,
|
||||
?int $domainId = null,
|
||||
?array $allowedDomainIds = null,
|
||||
): array {
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
@@ -117,6 +156,11 @@ final class LinkRepository
|
||||
$params[] = $domainId;
|
||||
}
|
||||
|
||||
$scope = self::domainScope($allowedDomainIds, 'l.domain_id', $params);
|
||||
if ($scope !== null) {
|
||||
$conditions[] = $scope;
|
||||
}
|
||||
|
||||
$where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions);
|
||||
|
||||
$order = match ($sort) {
|
||||
@@ -142,7 +186,10 @@ final class LinkRepository
|
||||
);
|
||||
}
|
||||
|
||||
public function count(string $search = '', ?int $domainId = null): int
|
||||
/**
|
||||
* @param list<int>|null $allowedDomainIds
|
||||
*/
|
||||
public function count(string $search = '', ?int $domainId = null, ?array $allowedDomainIds = null): int
|
||||
{
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
@@ -158,6 +205,11 @@ final class LinkRepository
|
||||
$params[] = $domainId;
|
||||
}
|
||||
|
||||
$scope = self::domainScope($allowedDomainIds, 'domain_id', $params);
|
||||
if ($scope !== null) {
|
||||
$conditions[] = $scope;
|
||||
}
|
||||
|
||||
$where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions);
|
||||
|
||||
return (int) $this->db->fetchValue('SELECT COUNT(*) FROM links ' . $where, $params);
|
||||
@@ -225,12 +277,25 @@ final class LinkRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int>|null $allowedDomainIds
|
||||
* @return array{links:int,clicks:int,active:int}
|
||||
*/
|
||||
public function summary(?int $domainId = null): array
|
||||
public function summary(?int $domainId = null, ?array $allowedDomainIds = null): array
|
||||
{
|
||||
$where = $domainId === null ? '' : 'WHERE domain_id = ?';
|
||||
$params = $domainId === null ? [] : [$domainId];
|
||||
$conditions = [];
|
||||
$params = [];
|
||||
|
||||
if ($domainId !== null) {
|
||||
$conditions[] = 'domain_id = ?';
|
||||
$params[] = $domainId;
|
||||
}
|
||||
|
||||
$scope = self::domainScope($allowedDomainIds, 'domain_id', $params);
|
||||
if ($scope !== null) {
|
||||
$conditions[] = $scope;
|
||||
}
|
||||
|
||||
$where = $conditions === [] ? '' : 'WHERE ' . implode(' AND ', $conditions);
|
||||
|
||||
$row = $this->db->fetchOne(
|
||||
'SELECT COUNT(*) AS links,
|
||||
|
||||
@@ -60,6 +60,43 @@ final class QrDesignRepository
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehoert die Logodatei zu einem Kurzlink, den der Aufrufer sehen darf?
|
||||
*
|
||||
* Der Dateiname allein ist kein Zugriffsschutz: alle Logos liegen in
|
||||
* einem gemeinsamen Verzeichnis. Geprueft wird deshalb ueber die
|
||||
* Zuordnung in der Datenbank.
|
||||
*
|
||||
* @param list<int>|null $allowedDomainIds null = keine Einschraenkung
|
||||
*/
|
||||
public function logoIsVisible(string $filename, ?array $allowedDomainIds): bool
|
||||
{
|
||||
if ($filename === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($allowedDomainIds === null) {
|
||||
return $this->db->fetchValue(
|
||||
'SELECT 1 FROM qr_designs WHERE logo_path = ?',
|
||||
[$filename],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
if ($allowedDomainIds === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$placeholders = implode(', ', array_fill(0, count($allowedDomainIds), '?'));
|
||||
|
||||
return $this->db->fetchValue(
|
||||
'SELECT 1
|
||||
FROM qr_designs qd
|
||||
JOIN links l ON l.id = qd.link_id
|
||||
WHERE qd.logo_path = ? AND l.domain_id IN (' . $placeholders . ')',
|
||||
[$filename, ...$allowedDomainIds],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
public function clearLogo(int $linkId): void
|
||||
{
|
||||
$this->db->execute(
|
||||
|
||||
@@ -7,13 +7,15 @@ namespace App\Repository;
|
||||
use App\Database;
|
||||
|
||||
/**
|
||||
* Key-Value-Speicher fuer Einstellungen, die zur Laufzeit gesetzt werden
|
||||
* (z. B. das Admin-Passwort aus dem Setup-Assistenten).
|
||||
* Key-Value-Speicher fuer Einstellungen, die zur Laufzeit gesetzt werden.
|
||||
*
|
||||
* Hier lagen bis zur Version mit mehreren Benutzern auch die Zugangsdaten
|
||||
* des einen Admin-Kontos ("admin_user", "admin_password_hash"). Sie stehen
|
||||
* jetzt in der users-Tabelle; die Migration 003 raeumt die beiden
|
||||
* Schluessel auf.
|
||||
*/
|
||||
final class SettingRepository
|
||||
{
|
||||
public const ADMIN_USER = 'admin_user';
|
||||
public const ADMIN_PASSWORD_HASH = 'admin_password_hash';
|
||||
public const APP_SECRET = 'app_secret';
|
||||
|
||||
public function __construct(private readonly Database $db)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Database;
|
||||
use App\Security\Role;
|
||||
|
||||
/**
|
||||
* Die Benutzerkonten der Installation.
|
||||
*
|
||||
* Passwoerter verlassen diese Klasse nie im Klartext: gespeichert wird
|
||||
* ausschliesslich das Ergebnis von password_hash().
|
||||
*/
|
||||
final class UserRepository
|
||||
{
|
||||
public function __construct(private readonly Database $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->db->fetchAll(
|
||||
'SELECT u.*,
|
||||
(SELECT COUNT(*) FROM group_members m WHERE m.user_id = u.id) AS group_count
|
||||
FROM users u
|
||||
ORDER BY u.username ASC',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
return $this->db->fetchOne('SELECT * FROM users WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzernamen werden ohne Ruecksicht auf Gross- und Kleinschreibung
|
||||
* verglichen: "Anna" und "anna" sollen nicht zwei Konten sein.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function findByUsername(string $username): ?array
|
||||
{
|
||||
return $this->db->fetchOne(
|
||||
'SELECT * FROM users WHERE LOWER(username) = ?',
|
||||
[mb_strtolower($username, 'UTF-8')],
|
||||
);
|
||||
}
|
||||
|
||||
public function usernameExists(string $username, ?int $exceptId = null): bool
|
||||
{
|
||||
$lower = mb_strtolower($username, 'UTF-8');
|
||||
|
||||
if ($exceptId === null) {
|
||||
return $this->db->fetchValue('SELECT 1 FROM users WHERE LOWER(username) = ?', [$lower]) !== null;
|
||||
}
|
||||
|
||||
return $this->db->fetchValue(
|
||||
'SELECT 1 FROM users WHERE LOWER(username) = ? AND id <> ?',
|
||||
[$lower, $exceptId],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
public function create(string $username, string $plainPassword, string $role = Role::USER): int
|
||||
{
|
||||
$this->db->execute(
|
||||
'INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, ?)',
|
||||
[
|
||||
$username,
|
||||
password_hash($plainPassword, PASSWORD_DEFAULT),
|
||||
Role::globalOrDefault($role),
|
||||
gmdate('Y-m-d H:i:s'),
|
||||
],
|
||||
);
|
||||
|
||||
return $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(int $id, string $username, string $role, bool $isActive): void
|
||||
{
|
||||
$this->db->execute(
|
||||
'UPDATE users SET username = ?, role = ?, is_active = ? WHERE id = ?',
|
||||
[$username, Role::globalOrDefault($role), $isActive ? 1 : 0, $id],
|
||||
);
|
||||
}
|
||||
|
||||
public function updatePassword(int $id, string $plainPassword): void
|
||||
{
|
||||
$this->db->execute(
|
||||
'UPDATE users SET password_hash = ? WHERE id = ?',
|
||||
[password_hash($plainPassword, PASSWORD_DEFAULT), $id],
|
||||
);
|
||||
}
|
||||
|
||||
public function touchLogin(int $id): void
|
||||
{
|
||||
$this->db->execute('UPDATE users SET last_login_at = ? WHERE id = ?', [gmdate('Y-m-d H:i:s'), $id]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->db->execute('DELETE FROM group_members WHERE user_id = ?', [$id]);
|
||||
$this->db->execute('DELETE FROM users WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return (int) $this->db->fetchValue('SELECT COUNT(*) FROM users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele aktive Administratoren gibt es noch? Verhindert, dass sich
|
||||
* die Installation selbst aussperrt.
|
||||
*/
|
||||
public function activeAdminCount(?int $exceptId = null): int
|
||||
{
|
||||
if ($exceptId === null) {
|
||||
return (int) $this->db->fetchValue(
|
||||
'SELECT COUNT(*) FROM users WHERE role = ? AND is_active = 1',
|
||||
[Role::ADMIN],
|
||||
);
|
||||
}
|
||||
|
||||
return (int) $this->db->fetchValue(
|
||||
'SELECT COUNT(*) FROM users WHERE role = ? AND is_active = 1 AND id <> ?',
|
||||
[Role::ADMIN, $exceptId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist ueberhaupt ein anmeldbares Konto vorhanden?
|
||||
*/
|
||||
public function hasAnyActive(): bool
|
||||
{
|
||||
return $this->db->fetchValue('SELECT 1 FROM users WHERE is_active = 1') !== null;
|
||||
}
|
||||
}
|
||||
@@ -158,22 +158,39 @@ final class VisitRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Klicks aller Links der letzten $days Tage (Dashboard).
|
||||
* Klicks aller sichtbaren Links der letzten $days Tage (Dashboard).
|
||||
*
|
||||
* @param list<int>|null $allowedDomainIds null = keine Einschraenkung
|
||||
* @return list<array{date:string,clicks:int}>
|
||||
*/
|
||||
public function clicksPerDayGlobal(int $days = 30): array
|
||||
public function clicksPerDayGlobal(int $days = 30, ?array $allowedDomainIds = null): array
|
||||
{
|
||||
$since = gmdate('Y-m-d', strtotime(sprintf('-%d days', $days - 1)) ?: time());
|
||||
|
||||
$params = [$since];
|
||||
$scope = '';
|
||||
|
||||
if ($allowedDomainIds === []) {
|
||||
// Nichts sichtbar: die Reihe bleibt leer, aber vollstaendig.
|
||||
$rows = [];
|
||||
} else {
|
||||
if ($allowedDomainIds !== null) {
|
||||
$placeholders = implode(', ', array_fill(0, count($allowedDomainIds), '?'));
|
||||
$scope = ' AND link_id IN (SELECT id FROM links WHERE domain_id IN (' . $placeholders . '))';
|
||||
foreach ($allowedDomainIds as $id) {
|
||||
$params[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $this->db->fetchAll(
|
||||
'SELECT SUBSTR(visited_at, 1, 10) AS day, COUNT(*) AS clicks
|
||||
FROM visits
|
||||
WHERE SUBSTR(visited_at, 1, 10) >= ?
|
||||
WHERE SUBSTR(visited_at, 1, 10) >= ?' . $scope . '
|
||||
GROUP BY SUBSTR(visited_at, 1, 10)
|
||||
ORDER BY day ASC',
|
||||
[$since],
|
||||
$params,
|
||||
);
|
||||
}
|
||||
|
||||
$byDay = [];
|
||||
foreach ($rows as $row) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Repository\DomainRepository;
|
||||
use App\Repository\GroupRepository;
|
||||
|
||||
/**
|
||||
* Beantwortet fuer den angemeldeten Benutzer die Frage, welche Domains -
|
||||
* und damit welche Kurzlinks - er sehen und aendern darf.
|
||||
*
|
||||
* Die Kette lautet: Benutzer -> Gruppenmitgliedschaft -> Domains der
|
||||
* Gruppe -> Kurzlinks dieser Domains. Ein Administrator umgeht sie und
|
||||
* sieht alles.
|
||||
*
|
||||
* "null" als Rueckgabe einer Kennungsliste bedeutet ausdruecklich
|
||||
* "keine Einschraenkung" - nicht "nichts". Ein leeres Array bedeutet
|
||||
* dagegen "nichts", und genau so werten es die Repositories aus.
|
||||
*/
|
||||
final class AccessPolicy
|
||||
{
|
||||
/** @var list<array<string,mixed>>|null */
|
||||
private ?array $domainCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Auth $auth,
|
||||
private readonly GroupRepository $groups,
|
||||
private readonly DomainRepository $domains,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->auth->isAdmin();
|
||||
}
|
||||
|
||||
// --- Domains ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Alle Domains, die der Benutzer sehen darf.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function visibleDomains(): array
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return $this->allDomains();
|
||||
}
|
||||
|
||||
return $this->domainsInGroups($this->groups->groupIdsForUser($this->userId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains, auf denen der Benutzer Kurzlinks anlegen und aendern darf.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function editableDomains(): array
|
||||
{
|
||||
if ($this->isAdmin()) {
|
||||
return $this->allDomains();
|
||||
}
|
||||
|
||||
return $this->domainsInGroups($this->groups->editableGroupIdsForUser($this->userId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null null = keine Einschraenkung
|
||||
*/
|
||||
public function visibleDomainIds(): ?array
|
||||
{
|
||||
return $this->isAdmin() ? null : $this->idsOf($this->visibleDomains());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>|null null = keine Einschraenkung
|
||||
*/
|
||||
public function editableDomainIds(): ?array
|
||||
{
|
||||
return $this->isAdmin() ? null : $this->idsOf($this->editableDomains());
|
||||
}
|
||||
|
||||
public function canViewDomain(int $domainId): bool
|
||||
{
|
||||
$allowed = $this->visibleDomainIds();
|
||||
|
||||
return $allowed === null || in_array($domainId, $allowed, true);
|
||||
}
|
||||
|
||||
public function canEditDomain(int $domainId): bool
|
||||
{
|
||||
$allowed = $this->editableDomainIds();
|
||||
|
||||
return $allowed === null || in_array($domainId, $allowed, true);
|
||||
}
|
||||
|
||||
// --- Kurzlinks -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $link
|
||||
*/
|
||||
public function canViewLink(array $link): bool
|
||||
{
|
||||
return $this->canViewDomain((int) ($link['domain_id'] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $link
|
||||
*/
|
||||
public function canEditLink(array $link): bool
|
||||
{
|
||||
return $this->canEditDomain((int) ($link['domain_id'] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf der Benutzer ueberhaupt irgendwo Kurzlinks anlegen?
|
||||
*/
|
||||
public function canCreateLinks(): bool
|
||||
{
|
||||
return $this->editableDomains() !== [];
|
||||
}
|
||||
|
||||
// --- Intern ----------------------------------------------------------
|
||||
|
||||
private function userId(): int
|
||||
{
|
||||
return $this->auth->id() ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function allDomains(): array
|
||||
{
|
||||
return $this->domainCache ??= $this->domains->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $groupIds
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function domainsInGroups(array $groupIds): array
|
||||
{
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->allDomains(),
|
||||
static fn (array $domain): bool => in_array((int) $domain['group_id'], $groupIds, true),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $domains
|
||||
* @return list<int>
|
||||
*/
|
||||
private function idsOf(array $domains): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (array $domain): int => (int) $domain['id'],
|
||||
$domains,
|
||||
));
|
||||
}
|
||||
}
|
||||
+171
-66
@@ -5,126 +5,231 @@ declare(strict_types=1);
|
||||
namespace App\Security;
|
||||
|
||||
use App\Config;
|
||||
use App\Repository\SettingRepository;
|
||||
use App\Repository\UserRepository;
|
||||
|
||||
/**
|
||||
* Session-basierte Authentifizierung mit genau einem Admin-Konto.
|
||||
* Session-basierte Authentifizierung gegen die Benutzertabelle.
|
||||
*
|
||||
* Die Zugangsdaten kommen entweder aus der .env (ADMIN_USER /
|
||||
* ADMIN_PASSWORD_HASH) oder - falls dort nichts gesetzt ist - aus der
|
||||
* settings-Tabelle, die der Setup-Assistent befuellt.
|
||||
* Zusaetzlich gibt es den Notfallzugang aus der .env (ADMIN_USER /
|
||||
* ADMIN_PASSWORD_HASH). Er hat Administratorrechte, taucht in der
|
||||
* Benutzerverwaltung nicht auf und laesst sich dort auch nicht aendern -
|
||||
* er existiert nur, damit eine Installation sich nicht aussperren kann,
|
||||
* wenn das letzte Konto gesperrt wurde oder das Passwort verloren ging.
|
||||
* Ein echtes Konto gleichen Namens gewinnt bei der Anmeldung.
|
||||
*/
|
||||
final class Auth
|
||||
{
|
||||
private const SESSION_KEY = '_admin_authenticated';
|
||||
private const SESSION_USER = '_admin_user';
|
||||
private const SESSION_FINGERPRINT = '_admin_fingerprint';
|
||||
private const SESSION_USER_ID = '_auth_user_id';
|
||||
private const SESSION_FINGERPRINT = '_auth_fingerprint';
|
||||
|
||||
/** Kennung des Notfallzugangs. Negativ, damit sie nie mit einer echten kollidiert. */
|
||||
public const EMERGENCY_ID = -1;
|
||||
|
||||
/**
|
||||
* Damit ein unbekannter Benutzername genauso lange braucht wie ein
|
||||
* falsches Passwort, wird auch dann ein Hash geprueft. Der Wert ist
|
||||
* ein gueltiger bcrypt-Hash eines Zufallswerts - er passt zu keiner
|
||||
* Eingabe.
|
||||
*/
|
||||
private const DUMMY_HASH = '$2y$10$eycI5xBqet0KRRyTeDeIs.e.RWe8Ij8u1O8j6LlIGyiY7ceOFzGm.';
|
||||
|
||||
/** @var array<string,mixed>|null|false false = in diesem Request noch nicht geladen */
|
||||
private array|null|false $current = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly Session $session,
|
||||
private readonly SettingRepository $settings,
|
||||
private readonly UserRepository $users,
|
||||
) {
|
||||
}
|
||||
|
||||
public function username(): string
|
||||
{
|
||||
$fromEnv = $this->config->getString('ADMIN_USER', 'admin');
|
||||
|
||||
// Steht das Passwort in der .env, gilt auch der Benutzername von dort.
|
||||
if ($this->config->adminPasswordHash() !== null) {
|
||||
return $fromEnv;
|
||||
}
|
||||
|
||||
return $this->settings->get(SettingRepository::ADMIN_USER, $fromEnv) ?? $fromEnv;
|
||||
}
|
||||
|
||||
public function passwordHash(): ?string
|
||||
{
|
||||
return $this->config->adminPasswordHash()
|
||||
?? $this->settings->get(SettingRepository::ADMIN_PASSWORD_HASH);
|
||||
}
|
||||
// --- Zustand ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ist bereits ein Admin-Konto eingerichtet?
|
||||
* Ist ueberhaupt ein Konto vorhanden? Solange nicht, fuehrt jeder Weg
|
||||
* in den Setup-Assistenten.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$hash = $this->passwordHash();
|
||||
return $this->emergencyAccount() !== null || $this->users->hasAnyActive();
|
||||
}
|
||||
|
||||
return is_string($hash) && $hash !== '';
|
||||
public function check(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt Zugangsdaten in der Datenbank an (Setup-Assistent).
|
||||
* Der angemeldete Benutzer als Datensatz - oder null.
|
||||
*
|
||||
* Wird je Request einmal geladen: so wirkt das Sperren oder Loeschen
|
||||
* eines Kontos sofort, auch auf laufende Sitzungen.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function configure(string $username, string $plainPassword): void
|
||||
public function user(): ?array
|
||||
{
|
||||
$this->settings->set(SettingRepository::ADMIN_USER, $username);
|
||||
$this->settings->set(
|
||||
SettingRepository::ADMIN_PASSWORD_HASH,
|
||||
password_hash($plainPassword, PASSWORD_DEFAULT),
|
||||
);
|
||||
if ($this->current !== false) {
|
||||
return $this->current;
|
||||
}
|
||||
|
||||
$this->current = null;
|
||||
|
||||
$id = $this->session->get(self::SESSION_USER_ID);
|
||||
if (!is_int($id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = $id === self::EMERGENCY_ID
|
||||
? $this->emergencyAccount()
|
||||
: $this->users->find($id);
|
||||
|
||||
if ($user === null || (int) $user['is_active'] !== 1) {
|
||||
$this->session->destroy();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ein Passwortwechsel macht bestehende Sitzungen ungueltig.
|
||||
if (!hash_equals($this->fingerprint($user), (string) $this->session->get(self::SESSION_FINGERPRINT))) {
|
||||
$this->session->destroy();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->current = $user;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function id(): ?int
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return $user === null ? null : (int) $user['id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft Benutzername und Passwort in konstanter Zeit.
|
||||
* Benutzername des angemeldeten Kontos (fuer die Anzeige im Layout).
|
||||
*/
|
||||
public function verifyCredentials(string $username, string $password): bool
|
||||
public function currentUser(): ?string
|
||||
{
|
||||
$hash = $this->passwordHash();
|
||||
$user = $this->user();
|
||||
|
||||
if (!is_string($hash) || $hash === '') {
|
||||
return false;
|
||||
return $user === null ? null : (string) $user['username'];
|
||||
}
|
||||
|
||||
// Immer beide Pruefungen ausfuehren, damit ein falscher
|
||||
// Benutzername nicht schneller beantwortet wird als ein
|
||||
// falsches Passwort.
|
||||
$userOk = hash_equals($this->username(), $username);
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
return $user !== null && Role::globalOrDefault($user['role']) === Role::ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet, ob gerade der Notfallzugang aus der .env benutzt wird. Fuer
|
||||
* dieses Konto gibt es keinen Datensatz - Profil und Passwortwechsel
|
||||
* sind deshalb gesperrt.
|
||||
*/
|
||||
public function isEmergency(): bool
|
||||
{
|
||||
return $this->id() === self::EMERGENCY_ID;
|
||||
}
|
||||
|
||||
// --- Anmeldung -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Prueft Benutzername und Passwort und liefert das Konto zurueck.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function verifyCredentials(string $username, string $password): ?array
|
||||
{
|
||||
$user = $this->users->findByUsername($username);
|
||||
|
||||
// Auch ohne Treffer wird ein Hash geprueft, damit ein unbekannter
|
||||
// Benutzername nicht schneller beantwortet wird als ein falsches
|
||||
// Passwort.
|
||||
$hash = $user === null ? self::DUMMY_HASH : (string) $user['password_hash'];
|
||||
$passwordOk = password_verify($password, $hash);
|
||||
|
||||
return $userOk && $passwordOk;
|
||||
if ($user !== null && $passwordOk && (int) $user['is_active'] === 1) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function login(string $username): void
|
||||
return $this->verifyEmergency($username, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $user
|
||||
*/
|
||||
public function login(array $user): void
|
||||
{
|
||||
$this->session->regenerate();
|
||||
$this->session->set(self::SESSION_KEY, true);
|
||||
$this->session->set(self::SESSION_USER, $username);
|
||||
$this->session->set(self::SESSION_FINGERPRINT, $this->fingerprint());
|
||||
$this->session->set(self::SESSION_USER_ID, (int) $user['id']);
|
||||
$this->session->set(self::SESSION_FINGERPRINT, $this->fingerprint($user));
|
||||
|
||||
$this->current = $user;
|
||||
|
||||
if ((int) $user['id'] !== self::EMERGENCY_ID) {
|
||||
$this->users->touchLogin((int) $user['id']);
|
||||
}
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$this->session->destroy();
|
||||
$this->current = null;
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
// --- Notfallzugang ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Das Konto aus der .env, sofern dort ein Passwort-Hash hinterlegt ist.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function emergencyAccount(): ?array
|
||||
{
|
||||
if ($this->session->get(self::SESSION_KEY) !== true) {
|
||||
return false;
|
||||
$hash = $this->config->adminPasswordHash();
|
||||
|
||||
if ($hash === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Passwortwechsel invalidiert bestehende Sessions.
|
||||
if ($this->session->get(self::SESSION_FINGERPRINT) !== $this->fingerprint()) {
|
||||
$this->session->destroy();
|
||||
|
||||
return false;
|
||||
return [
|
||||
'id' => self::EMERGENCY_ID,
|
||||
'username' => $this->config->adminUser(),
|
||||
'password_hash' => $hash,
|
||||
'role' => Role::ADMIN,
|
||||
'is_active' => 1,
|
||||
'created_at' => null,
|
||||
'last_login_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function currentUser(): ?string
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function verifyEmergency(string $username, string $password): ?array
|
||||
{
|
||||
$user = $this->session->get(self::SESSION_USER);
|
||||
$account = $this->emergencyAccount();
|
||||
|
||||
return is_string($user) ? $user : null;
|
||||
if ($account === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private function fingerprint(): string
|
||||
$userOk = hash_equals((string) $account['username'], $username);
|
||||
$passwordOk = password_verify($password, (string) $account['password_hash']);
|
||||
|
||||
return $userOk && $passwordOk ? $account : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $user
|
||||
*/
|
||||
private function fingerprint(array $user): string
|
||||
{
|
||||
return hash('sha256', (string) $this->passwordHash());
|
||||
return hash('sha256', $user['id'] . '|' . (string) $user['password_hash']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ namespace App\Security;
|
||||
use App\Database;
|
||||
|
||||
/**
|
||||
* Rate-Limiting fuer den Login. Speichert nur einen gesalzenen Hash
|
||||
* der IP-Adresse, niemals die IP selbst.
|
||||
* Rate-Limiting fuer den Login.
|
||||
*
|
||||
* Gezaehlt wird je Schluessel - die Anwendung sperrt sowohl nach
|
||||
* IP-Adresse als auch nach Benutzername, damit ein Wechsel der IP nicht
|
||||
* reicht, um alle Konten der Reihe nach durchzuprobieren. Gespeichert
|
||||
* wird davon nur ein gesalzener Hash, nie der Schluessel selbst.
|
||||
*/
|
||||
final class RateLimiter
|
||||
{
|
||||
@@ -19,46 +23,46 @@ final class RateLimiter
|
||||
) {
|
||||
}
|
||||
|
||||
private function identifier(string $ip): string
|
||||
private function identifier(string $key): string
|
||||
{
|
||||
return substr(hash('sha256', 'login|' . $ip), 0, 64);
|
||||
return substr(hash('sha256', 'login|' . $key), 0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzahl der Fehlversuche im aktuellen Zeitfenster.
|
||||
*/
|
||||
public function failedAttempts(string $ip): int
|
||||
public function failedAttempts(string $key): int
|
||||
{
|
||||
$since = gmdate('Y-m-d H:i:s', time() - $this->lockoutSeconds);
|
||||
|
||||
$count = $this->db->fetchValue(
|
||||
'SELECT COUNT(*) FROM login_attempts WHERE identifier = ? AND successful = 0 AND attempted_at > ?',
|
||||
[$this->identifier($ip), $since],
|
||||
[$this->identifier($key), $since],
|
||||
);
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
|
||||
public function isLocked(string $ip): bool
|
||||
public function isLocked(string $key): bool
|
||||
{
|
||||
return $this->failedAttempts($ip) >= $this->maxAttempts;
|
||||
return $this->failedAttempts($key) >= $this->maxAttempts;
|
||||
}
|
||||
|
||||
public function remainingAttempts(string $ip): int
|
||||
public function remainingAttempts(string $key): int
|
||||
{
|
||||
return max(0, $this->maxAttempts - $this->failedAttempts($ip));
|
||||
return max(0, $this->maxAttempts - $this->failedAttempts($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbleibende Sperrzeit in Sekunden.
|
||||
*/
|
||||
public function retryAfter(string $ip): int
|
||||
public function retryAfter(string $key): int
|
||||
{
|
||||
$row = $this->db->fetchOne(
|
||||
'SELECT attempted_at FROM login_attempts
|
||||
WHERE identifier = ? AND successful = 0
|
||||
ORDER BY attempted_at DESC LIMIT 1',
|
||||
[$this->identifier($ip)],
|
||||
[$this->identifier($key)],
|
||||
);
|
||||
|
||||
if ($row === null) {
|
||||
@@ -70,22 +74,22 @@ final class RateLimiter
|
||||
return max(0, $this->lockoutSeconds - (time() - $last));
|
||||
}
|
||||
|
||||
public function recordFailure(string $ip): void
|
||||
public function recordFailure(string $key): void
|
||||
{
|
||||
$this->db->execute(
|
||||
'INSERT INTO login_attempts (identifier, attempted_at, successful) VALUES (?, ?, 0)',
|
||||
[$this->identifier($ip), gmdate('Y-m-d H:i:s')],
|
||||
[$this->identifier($key), gmdate('Y-m-d H:i:s')],
|
||||
);
|
||||
|
||||
$this->pruneOldEntries();
|
||||
}
|
||||
|
||||
public function recordSuccess(string $ip): void
|
||||
public function recordSuccess(string $key): void
|
||||
{
|
||||
// Erfolgreicher Login setzt den Zaehler zurueck.
|
||||
$this->db->execute(
|
||||
'DELETE FROM login_attempts WHERE identifier = ?',
|
||||
[$this->identifier($ip)],
|
||||
[$this->identifier($key)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
/**
|
||||
* Die beiden Rollenebenen der Anwendung.
|
||||
*
|
||||
* Global gilt "admin" oder "user": ein Administrator verwaltet Benutzer,
|
||||
* Gruppen und Domains und sieht alle Kurzlinks. Ein Benutzer sieht
|
||||
* ausschliesslich das, was seine Gruppenmitgliedschaften hergeben.
|
||||
*
|
||||
* Innerhalb einer Gruppe gilt "editor" oder "viewer": ein Editor legt
|
||||
* Kurzlinks an und aendert sie, ein Viewer darf sie nur ansehen -
|
||||
* einschliesslich Statistik und QR-Download.
|
||||
*/
|
||||
final class Role
|
||||
{
|
||||
public const ADMIN = 'admin';
|
||||
public const USER = 'user';
|
||||
|
||||
public const EDITOR = 'editor';
|
||||
public const VIEWER = 'viewer';
|
||||
|
||||
/** @var array<string,string> */
|
||||
public const GLOBAL_LABELS = [
|
||||
self::ADMIN => 'Administrator',
|
||||
self::USER => 'Benutzer',
|
||||
];
|
||||
|
||||
/** @var array<string,string> */
|
||||
public const GROUP_LABELS = [
|
||||
self::EDITOR => 'Editor (anlegen und ändern)',
|
||||
self::VIEWER => 'Viewer (nur ansehen)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Unbekannte Werte fallen auf die jeweils schwaechere Rolle zurueck -
|
||||
* ein Tippfehler in der Datenbank darf keine Rechte verschenken.
|
||||
*/
|
||||
public static function globalOrDefault(mixed $value): string
|
||||
{
|
||||
return $value === self::ADMIN ? self::ADMIN : self::USER;
|
||||
}
|
||||
|
||||
public static function groupOrDefault(mixed $value): string
|
||||
{
|
||||
return $value === self::EDITOR ? self::EDITOR : self::VIEWER;
|
||||
}
|
||||
|
||||
public static function globalLabel(mixed $value): string
|
||||
{
|
||||
return self::GLOBAL_LABELS[self::globalOrDefault($value)];
|
||||
}
|
||||
|
||||
public static function groupLabel(mixed $value): string
|
||||
{
|
||||
return self::GROUP_LABELS[self::groupOrDefault($value)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Eine Stelle fuer die Regeln, die an jedes Passwort angelegt werden -
|
||||
* beim Einrichten, in der Benutzerverwaltung und beim Selbstaendern.
|
||||
*
|
||||
* Bewusst nur eine Mindestlaenge und keine Zeichenklassenpflicht: eine
|
||||
* lange Passphrase ist besser als "Sommer1!", und erzwungene Sonderzeichen
|
||||
* fuehren erfahrungsgemaess zu genau solchen Passwoertern.
|
||||
*/
|
||||
final class Password
|
||||
{
|
||||
public const MIN_LENGTH = 10;
|
||||
|
||||
/**
|
||||
* Prueft Passwort und Wiederholung.
|
||||
*
|
||||
* @return array<string,string> Feldname => Fehlermeldung, leer wenn alles passt
|
||||
*/
|
||||
public static function validationErrors(string $password, string $confirm): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
if (mb_strlen($password) < self::MIN_LENGTH) {
|
||||
$errors['password'] = sprintf('Das Passwort muss mindestens %d Zeichen lang sein.', self::MIN_LENGTH);
|
||||
}
|
||||
|
||||
// Sehr lange Eingaben abweisen: bcrypt schneidet nach 72 Byte ab,
|
||||
// alles darueber waere eine truegerische Sicherheit.
|
||||
if (strlen($password) > 72) {
|
||||
$errors['password'] = 'Das Passwort darf höchstens 72 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
if ($password !== $confirm) {
|
||||
$errors['password_confirm'] = 'Die beiden Passwörter stimmen nicht überein.';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Regeln fuer Benutzernamen.
|
||||
*
|
||||
* Bewusst eng gefasst: der Name taucht in Meldungen und Protokollen auf
|
||||
* und wird ohne Ruecksicht auf Gross- und Kleinschreibung verglichen.
|
||||
* Ein enger Zeichenvorrat erspart dabei Ueberraschungen durch
|
||||
* Leerzeichen am Rand oder gleich aussehende Unicode-Zeichen.
|
||||
*/
|
||||
final class Username
|
||||
{
|
||||
public const PATTERN = '/^[A-Za-z0-9._@-]{3,64}$/';
|
||||
|
||||
public static function normalize(string $username): string
|
||||
{
|
||||
return trim($username);
|
||||
}
|
||||
|
||||
public static function validationError(string $username): ?string
|
||||
{
|
||||
if (preg_match(self::PATTERN, $username) === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Der Benutzername muss 3-64 Zeichen lang sein (Buchstaben, Ziffern, . _ - @).';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user