Fix #147: Benutzerverwaltung nur noch für Admins zugänglich

This commit is contained in:
fds2610-bot 2026-08-09 20:21:04 +02:00
parent 88d9cc53ff
commit cc9f497165
11 changed files with 181 additions and 13 deletions

View file

@ -2,6 +2,19 @@
--- ---
## 2026-08-09 – Fix: Fehlende Autorisierung in der Benutzerverwaltung (#147)
- `users` hatte kein Rollen-Feld – `Auth::require()` prüfte für alle `/useradmin`-Routen (`GET/POST /useradmin`, `.../{id}`, `.../{id}/delete`, `.../{id}/toggle`) nur eine gültige Session, keine besondere Berechtigung. Jeder eingeloggte Benutzer (auch einer, der nur für die Belegerfassung angelegt wurde) konnte damit beliebige Konten anlegen, das Passwort jedes anderen Benutzers – inklusive des ursprünglichen Admin-Accounts – setzen sowie andere Benutzer löschen oder deaktivieren (Privilege Escalation, CWE-862/CWE-269).
- **`migrations/093_users_role.sql`** (neu): `users.role ENUM('admin','user') NOT NULL DEFAULT 'user'`. Auf Bestandsinstallationen wird der älteste Benutzer (`MIN(id)`) automatisch zu `admin`, damit die Benutzerverwaltung nach dem Update nicht für alle gesperrt ist.
- **`src/Models/User.php`:** `create()` akzeptiert jetzt eine optionale Rolle (Default `user`), neue Methode `setRole()`; `all()`/`find()`/`findByUsername()` liefern das Rollen-Feld mit.
- **`src/Auth.php`:** `login()` legt die Rolle in der Session ab (`$_SESSION['role']`). Neue Methoden `isAdmin()` und `requireAdmin()` (analog zu `require()`, antwortet bei fehlender Admin-Rolle mit HTTP 403).
- **`src/Controllers/LoginController.php`:** Der bei der Ersteinrichtung angelegte erste Benutzer erhält automatisch die Rolle `admin`.
- **`public/index.php`:** Zentraler Guard vor dem Routing – jede Anfrage unter `/useradmin` durchläuft zusätzlich `Auth::requireAdmin()`.
- **`src/Views/layout/nav.php`:** Der Menüpunkt „Benutzer“ wird nur noch für Admins gerendert (rein kosmetisch, ersetzt nicht die serverseitige Prüfung).
- **`scripts/e2e-user.php`:** Der E2E-Testnutzer wird als Admin angelegt bzw. beim erneuten Ausführen auf die Rolle `admin` zurückgesetzt, damit die E2E-Suite weiterhin auf `/useradmin` zugreifen kann.
- **`tests/83_UserRoleTest.php`** (neu): Rollen-Default `user`, explizite Vergabe von `admin`, `setRole()`, `Auth::isAdmin()` anhand der Session.
- **`tests/e2e/specs/useradmin-authorization.spec.js`** (neu): Ein frisch angelegter Benutzer ohne Admin-Rolle sieht den Nav-Link nicht und bekommt beim direkten Aufruf von `/useradmin` HTTP 403.
## 2026-08-03 – Fix: AfA-Buchungen umgehen das Booking-Modell (#155) ## 2026-08-03 – Fix: AfA-Buchungen umgehen das Booking-Modell (#155)
- `AssetController::bookAfa()` und `reverseAfaBooking()` setzten ihre `INSERT INTO bookings`-Statements bislang direkt ab statt über `Booking::create()`/`reverse()`. Vier Folgen: Die Sperre eines abgeschlossenen Geschäftsjahres griff nicht, ein zweiter, unabhängiger Nummernkreis (`AFA-NNNNN`) lief neben `BU-YYYY-NNNNNN` auf derselben Spalte, die Nummernvergabe stand außerhalb der Transaktion, und das Storno einer AfA-Buchung wurde immer dem *aktuellen* Kalenderjahr statt dem Jahr der Originalbuchung zugeordnet. - `AssetController::bookAfa()` und `reverseAfaBooking()` setzten ihre `INSERT INTO bookings`-Statements bislang direkt ab statt über `Booking::create()`/`reverse()`. Vier Folgen: Die Sperre eines abgeschlossenen Geschäftsjahres griff nicht, ein zweiter, unabhängiger Nummernkreis (`AFA-NNNNN`) lief neben `BU-YYYY-NNNNNN` auf derselben Spalte, die Nummernvergabe stand außerhalb der Transaktion, und das Storno einer AfA-Buchung wurde immer dem *aktuellen* Kalenderjahr statt dem Jahr der Originalbuchung zugeordnet.

View file

@ -145,4 +145,5 @@ Zuordnungsregeln: [berechnungslogik.md](berechnungslogik.md)
- Benutzer anlegen, bearbeiten, deaktivieren und löschen - Benutzer anlegen, bearbeiten, deaktivieren und löschen
- Session-basierte Authentifizierung mit 24 h Timeout - Session-basierte Authentifizierung mit 24 h Timeout
- Erste Benutzeranlage über Ersteinrichtungs-Dialog beim ersten Aufruf - Erste Benutzeranlage über Ersteinrichtungs-Dialog beim ersten Aufruf, dieser Benutzer erhält automatisch die Rolle `admin`
- Rollen `admin`/`user`: `/useradmin` (Benutzer anlegen, Passwörter setzen, löschen, deaktivieren) ist ausschließlich Admins vorbehalten (`Auth::requireAdmin()`); der Menüpunkt ist für andere Benutzer ausgeblendet

View file

@ -0,0 +1,13 @@
-- Migration 093: Rollen-Feld für users (Fix #147 - fehlende Autorisierung
-- in der Benutzerverwaltung, jeder eingeloggte Benutzer konnte bislang
-- beliebige Konten anlegen/löschen/übernehmen).
ALTER TABLE users
ADD COLUMN IF NOT EXISTS role ENUM('admin','user') NOT NULL DEFAULT 'user' AFTER password_hash;
-- Bestandsinstallationen: den ältesten Benutzer automatisch zum Admin
-- machen, damit die Benutzerverwaltung nach dem Update nicht für alle
-- Bestandsnutzer gesperrt ist.
UPDATE users u
JOIN (SELECT MIN(id) AS min_id FROM users) m ON u.id = m.min_id
SET u.role = 'admin'
WHERE u.role != 'admin';

View file

@ -109,6 +109,9 @@ $requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if ($requestPath !== '/login') { if ($requestPath !== '/login') {
Auth::require(); Auth::require();
} }
if (str_starts_with($requestPath ?? '', '/useradmin')) {
Auth::requireAdmin();
}
$router = new Router(); $router = new Router();

View file

@ -59,10 +59,13 @@ if ($existing) {
$id = (int)$existing['id']; $id = (int)$existing['id'];
User::update($id, $username, $password); User::update($id, $username, $password);
User::setActive($id, true); User::setActive($id, true);
User::setRole($id, 'admin');
User::resetFailedLogins($id); User::resetFailedLogins($id);
echo "Testnutzer \"{$username}\" aktualisiert (Passwort neu gesetzt, aktiv, entsperrt).\n"; echo "Testnutzer \"{$username}\" aktualisiert (Passwort neu gesetzt, aktiv, entsperrt, Rolle admin).\n";
} else { } else {
User::create($username, $password); // Rolle admin, damit die E2E-Suite auch die Benutzerverwaltung
// (/useradmin) erreichen kann (Fix #147).
User::create($username, $password, 'admin');
$created = User::findByUsername($username); $created = User::findByUsername($username);
if (!$created) { if (!$created) {
fwrite(STDERR, "Fehler: Testnutzer \"{$username}\" konnte nicht angelegt werden.\n"); fwrite(STDERR, "Fehler: Testnutzer \"{$username}\" konnte nicht angelegt werden.\n");

View file

@ -65,12 +65,33 @@ class Auth
} }
} }
public static function login(int $userId): void public static function login(int $userId, string $role = 'user'): void
{ {
self::startSession(); self::startSession();
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['user_id'] = $userId; $_SESSION['user_id'] = $userId;
$_SESSION['login_time'] = time(); $_SESSION['login_time'] = time();
$_SESSION['role'] = $role;
}
/**
* Wie require(), verlangt zusätzlich die Rolle 'admin'. Schützt die
* Benutzerverwaltung vor Privilege Escalation durch normale Benutzer
* (Issue #147) – die Rolle steckt in der Session und wird erst beim
* nächsten Login neu geladen.
*/
public static function requireAdmin(): void
{
self::require();
if (!self::isAdmin()) {
http_response_code(403);
exit('Kein Zugriff.');
}
}
public static function isAdmin(): bool
{
return ($_SESSION['role'] ?? '') === 'admin';
} }
public static function logout(): void public static function logout(): void

View file

@ -51,9 +51,9 @@ class LoginController
return; return;
} }
User::create($username, $password); User::create($username, $password, 'admin');
$user = User::findByUsername($username); $user = User::findByUsername($username);
Auth::login((int)$user['id']); Auth::login((int)$user['id'], (string)$user['role']);
View::redirect('/'); View::redirect('/');
return; return;
} }
@ -102,7 +102,7 @@ class LoginController
} }
User::resetFailedLogins((int)$user['id']); User::resetFailedLogins((int)$user['id']);
Auth::login((int)$user['id']); Auth::login((int)$user['id'], (string)$user['role']);
return null; return null;
} }
} }

View file

@ -14,14 +14,14 @@ class User
public static function all(): array public static function all(): array
{ {
return Database::fetchAll( return Database::fetchAll(
'SELECT id, username, active, created_at FROM users ORDER BY username' 'SELECT id, username, role, active, created_at FROM users ORDER BY username'
); );
} }
public static function find(int $id): array|false public static function find(int $id): array|false
{ {
return Database::fetchOne( return Database::fetchOne(
'SELECT id, username, active, created_at FROM users WHERE id = ?', 'SELECT id, username, role, active, created_at FROM users WHERE id = ?',
[$id] [$id]
); );
} }
@ -29,17 +29,17 @@ class User
public static function findByUsername(string $username): array|false public static function findByUsername(string $username): array|false
{ {
return Database::fetchOne( return Database::fetchOne(
'SELECT id, username, password_hash, active, failed_attempts, locked_until FROM users WHERE username = ?', 'SELECT id, username, password_hash, role, active, failed_attempts, locked_until FROM users WHERE username = ?',
[$username] [$username]
); );
} }
public static function create(string $username, string $password): void public static function create(string $username, string $password, string $role = 'user'): void
{ {
$hash = password_hash($password, PASSWORD_BCRYPT); $hash = password_hash($password, PASSWORD_BCRYPT);
Database::execute( Database::execute(
'INSERT INTO users (username, password_hash) VALUES (?, ?)', 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
[$username, $hash] [$username, $hash, $role]
); );
} }
@ -72,6 +72,14 @@ class User
); );
} }
public static function setRole(int $id, string $role): void
{
Database::execute(
'UPDATE users SET role = ? WHERE id = ?',
[$role, $id]
);
}
/** /**
* Zählt einen Fehlversuch. Ab LOCK_THRESHOLD Fehlversuchen wird das Konto * Zählt einen Fehlversuch. Ab LOCK_THRESHOLD Fehlversuchen wird das Konto
* für ein mit jedem weiteren Fehlversuch steigendes Zeitfenster gesperrt. * für ein mit jedem weiteren Fehlversuch steigendes Zeitfenster gesperrt.

View file

@ -80,10 +80,12 @@ function navActive(string $prefix, string $current): string {
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-label">Verwaltung</div> <div class="sidebar-label">Verwaltung</div>
<nav> <nav>
<?php if (\Steuerkram\Auth::isAdmin()): ?>
<a href="/useradmin" class="<?= navActive('/useradmin', $path) ?>"> <a href="/useradmin" class="<?= navActive('/useradmin', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
Benutzer Benutzer
</a> </a>
<?php endif; ?>
<a href="/ereignisse" class="<?= navActive('/ereignisse', $path) ?>"> <a href="/ereignisse" class="<?= navActive('/ereignisse', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22V12M12 12L8 16M12 12l4 4"/><circle cx="12" cy="5" r="3"/><path d="M5.5 8.5A7 7 0 0 0 5 12"/><path d="M18.5 8.5A7 7 0 0 1 19 12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22V12M12 12L8 16M12 12l4 4"/><circle cx="12" cy="5" r="3"/><path d="M5.5 8.5A7 7 0 0 0 5 12"/><path d="M18.5 8.5A7 7 0 0 1 19 12"/></svg>
Ereignisse Ereignisse

50
tests/83_UserRoleTest.php Normal file
View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Steuerkram\Auth;
use Steuerkram\Models\User;
class UserRoleTest extends TestCase
{
protected string $testName = 'Test 83: Rollen-Feld und Auth::isAdmin() (Fix #147)';
public function run(): void
{
User::create('roletest-user', 'password123');
$normalUser = User::findByUsername('roletest-user');
$this->assertEquals('user', $normalUser['role'], 'Ohne Angabe wird ein Benutzer mit Rolle "user" angelegt');
User::create('roletest-admin', 'password123', 'admin');
$admin = User::findByUsername('roletest-admin');
$this->assertEquals('admin', $admin['role'], 'Explizit angelegter Benutzer erhält die Rolle "admin"');
User::setRole((int)$normalUser['id'], 'admin');
$promoted = User::find((int)$normalUser['id']);
$this->assertEquals('admin', $promoted['role'], 'setRole() befördert einen Benutzer nachträglich zum Admin');
$listed = User::all();
$found = null;
foreach ($listed as $u) {
if ($u['id'] === $admin['id']) { $found = $u; break; }
}
$this->assertNotEmpty($found, 'User::all() liefert den angelegten Admin-Benutzer');
$this->assertEquals('admin', $found['role'] ?? null, 'User::all() liefert das Rollen-Feld mit');
// Auth::isAdmin() liest ausschließlich aus der Session (kein DB-Zugriff),
// damit sich die Prüfung ohne echten Request isoliert testen lässt.
$backup = $_SESSION ?? null;
try {
$_SESSION = [];
$this->assert(!Auth::isAdmin(), 'Ohne Session-Rolle gilt der Benutzer nicht als Admin');
$_SESSION['role'] = 'user';
$this->assert(!Auth::isAdmin(), 'Rolle "user" in der Session ist kein Admin');
$_SESSION['role'] = 'admin';
$this->assert(Auth::isAdmin(), 'Rolle "admin" in der Session wird als Admin erkannt');
} finally {
$_SESSION = $backup ?? [];
}
}
}

View file

@ -0,0 +1,54 @@
// @ts-check
// Fix #147: Vor der Änderung konnte jeder eingeloggte Benutzer über
// /useradmin beliebige Konten anlegen, Passwörter setzen oder löschen
// (Privilege Escalation). Dieser Test prüft die Durchsetzung auf HTTP-Ebene,
// die die PHP-Suite nicht erreicht (Router-Guard, Redirect, Nav-Rendering).
const { test, expect } = require('@playwright/test')
const { uniqueTag, gotoOk, acceptDialogs } = require('../helpers/app')
test.describe('Autorisierung: Benutzerverwaltung nur für Admins (Fix #147)', () => {
test('Neu angelegter Benutzer ohne Admin-Rolle wird von /useradmin ausgesperrt', async ({ page, browser }) => {
const username = uniqueTag('noadmin').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 64)
const password = 'TempPass123!'
await gotoOk(page, '/useradmin')
await page.fill('#field-username', username)
await page.fill('#field-password', password)
await page.click('#submit-btn')
await page.waitForURL('/useradmin')
await expect(page.locator('body')).toContainText('Benutzer angelegt.')
const row = page.locator('tr', { hasText: username })
await expect(row).toBeVisible()
try {
// Eigener, unangemeldeter Kontext: Der neu angelegte Benutzer meldet
// sich selbst an und ist per Default kein Admin.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
const userPage = await context.newPage()
await userPage.goto('/login')
await userPage.fill('#username', username)
await userPage.fill('#password', password)
await userPage.click('form[action="/login"] button[type="submit"]')
await userPage.waitForURL(url => !url.pathname.startsWith('/login'))
// Kosmetischer Schutz: der Nav-Link ist für Nicht-Admins ausgeblendet.
await expect(userPage.locator('a[href="/useradmin"]')).toHaveCount(0)
// Serverseitige Durchsetzung: direkter Aufruf bleibt trotzdem gesperrt.
const response = await userPage.goto('/useradmin')
expect(response?.status()).toBe(403)
await expect(userPage.locator('body')).toContainText('Kein Zugriff')
await context.close()
} finally {
acceptDialogs(page)
const delForm = row.locator('form[action$="/delete"]')
if (await delForm.count() > 0) {
await delForm.locator('button[type="submit"]').click()
await page.waitForLoadState('networkidle')
}
}
})
})