Session-basierte Authentifizierung + Benutzerverwaltung

- HTTP Basic Auth durch cookie-basierte Session-Auth ersetzt (24 h absolutes Timeout)
- Neue users-Tabelle (Migration 005) mit bcrypt-Passwort-Hashing
- Login-/Logout-Routen und eigenständiges Login-Template
- Ersteinrichtungsflow: erster Benutzer beim ersten Start direkt über /login anlegen
- Neuer Menüpunkt "Benutzer" (/useradmin) zum Anlegen, Deaktivieren und Löschen von Benutzern
- AUTH_USER/AUTH_PASS aus Konfiguration und docker-compose.yml entfernt
This commit is contained in:
fds2610 2026-03-25 15:28:07 +01:00
parent 83276fff09
commit a338631dbd
13 changed files with 508 additions and 24 deletions

View file

@ -4,9 +4,5 @@ DB_NAME=steuerkram
DB_USER=steuerkram
DB_PASS=steuerkram
# HTTP Basic Auth
AUTH_USER=admin
AUTH_PASS=changeme
# App
APP_SECRET=change-this-to-a-random-string

View file

@ -14,8 +14,6 @@ services:
DB_NAME: steuerkram
DB_USER: steuerkram
DB_PASS: steuerkram
AUTH_USER: admin
AUTH_PASS: changeme
APP_SECRET: change-this-in-production
depends_on:
db:

View file

@ -284,9 +284,7 @@ echo -e " ${BOLD}${ENV_FILE}${NC}"
echo ""
echo " Folgende Werte sollten geändert werden:"
echo " DB_PASS – Datenbankpasswort"
echo " AUTH_USER – Benutzername für den Web-Zugang"
echo " AUTH_PASS – Passwort für den Web-Zugang"
echo " APP_SECRET – Zufälliger geheimer Schlüssel"
echo " APP_SECRET – Zufälliger geheimer Schlüssel (für Sessions)"
echo ""
echo " Danach die Anwendung starten:"
echo -e " ${BOLD}cd ${INSTALL_DIR} && docker compose up -d${NC}"

8
migrations/005_users.sql Normal file
View file

@ -0,0 +1,8 @@
-- Migration 005: Session-basierte Authentifizierung – users-Tabelle
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View file

@ -17,9 +17,15 @@ use Steuerkram\Controllers\InvoiceController;
use Steuerkram\Controllers\UstvaController;
use Steuerkram\Controllers\EuerController;
use Steuerkram\Controllers\SettingsController;
use Steuerkram\Controllers\LoginController;
use Steuerkram\Controllers\UserAdminController;
Config::load();
Auth::require();
$requestPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if ($requestPath !== '/login') {
Auth::require();
}
$router = new Router();
@ -93,4 +99,15 @@ $router->post('/settings', fn($p) => (new SettingsController())->update($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
// ── Login / Logout ────────────────────────────────────────────
$router->get('/login', fn($p) => (new LoginController())->showLogin($p));
$router->post('/login', fn($p) => (new LoginController())->handleLogin($p));
$router->post('/logout', fn($p) => (new LoginController())->handleLogout($p));
// ── Benutzerverwaltung ────────────────────────────────────────
$router->get('/useradmin', fn($p) => (new UserAdminController())->index($p));
$router->post('/useradmin', fn($p) => (new UserAdminController())->store($p));
$router->post('/useradmin/{id}/delete', fn($p) => (new UserAdminController())->delete($p));
$router->post('/useradmin/{id}/toggle', fn($p) => (new UserAdminController())->toggleActive($p));
$router->dispatch();

View file

@ -6,20 +6,50 @@ namespace Steuerkram;
class Auth
{
private const SESSION_LIFETIME = 86400; // 24 h absolutes Timeout
public static function require(): void
{
$user = Config::get('auth_user');
$pass = Config::get('auth_pass');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (
!isset($_SERVER['PHP_AUTH_USER']) ||
!hash_equals($user, $_SERVER['PHP_AUTH_USER']) ||
!hash_equals($pass, $_SERVER['PHP_AUTH_PW'])
) {
header('WWW-Authenticate: Basic realm="Steuerkram"');
header('HTTP/1.1 401 Unauthorized');
echo '<!DOCTYPE html><html><body><h1>401 Unauthorized</h1></body></html>';
exit;
$userId = $_SESSION['user_id'] ?? null;
$loginTime = $_SESSION['login_time'] ?? 0;
if (!$userId || (time() - (int)$loginTime) > self::SESSION_LIFETIME) {
$_SESSION = [];
session_destroy();
View::redirect('/login');
}
}
public static function login(int $userId): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
session_regenerate_id(true);
$_SESSION['user_id'] = $userId;
$_SESSION['login_time'] = time();
}
public static function logout(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
public static function userId(): ?int
{
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
}
}

View file

@ -15,8 +15,6 @@ class Config
'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram',
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
'auth_user' => $_ENV['AUTH_USER'] ?? getenv('AUTH_USER') ?: 'admin',
'auth_pass' => $_ENV['AUTH_PASS'] ?? getenv('AUTH_PASS') ?: 'changeme',
'app_secret' => $_ENV['APP_SECRET'] ?? getenv('APP_SECRET') ?: 'dev-secret',
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
'base_url' => '',

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Auth;
use Steuerkram\Models\User;
use Steuerkram\View;
class LoginController
{
public function showLogin(array $params): void
{
// Bereits eingeloggt → direkt weiter
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (!empty($_SESSION['user_id'])) {
View::redirect('/');
}
$setup = (User::count() === 0);
$view = new View();
$view->render('login.php', [
'pageTitle' => $setup ? 'Ersteinrichtung' : 'Anmelden',
'setup' => $setup,
'error' => '',
]);
}
public function handleLogin(array $params): void
{
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
// Ersteinrichtung: erster Benutzer anlegen
if (User::count() === 0) {
$errors = [];
if ($username === '') $errors[] = 'Benutzername erforderlich.';
if (strlen($password) < 8) $errors[] = 'Passwort muss mindestens 8 Zeichen haben.';
$password2 = $_POST['password2'] ?? '';
if ($password !== $password2) $errors[] = 'Passwörter stimmen nicht überein.';
if ($errors) {
$view = new View();
$view->render('login.php', [
'pageTitle' => 'Ersteinrichtung',
'setup' => true,
'error' => implode(' ', $errors),
'username' => $username,
]);
return;
}
User::create($username, $password);
$user = User::findByUsername($username);
Auth::login((int)$user['id']);
View::redirect('/');
return;
}
$error = $this->authenticate($username, $password);
if ($error !== null) {
$view = new View();
$view->render('login.php', [
'pageTitle' => 'Anmelden',
'setup' => false,
'error' => $error,
'username' => $username,
]);
return;
}
View::redirect('/');
}
public function handleLogout(array $params): void
{
Auth::logout();
View::redirect('/login');
}
private function authenticate(string $username, string $password): ?string
{
if ($username === '' || $password === '') {
return 'Benutzername und Passwort erforderlich.';
}
$user = User::findByUsername($username);
if (!$user || !$user['active'] || !password_verify($password, $user['password_hash'])) {
return 'Ungültige Anmeldedaten oder Konto deaktiviert.';
}
Auth::login((int)$user['id']);
return null;
}
}

View file

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\User;
use Steuerkram\View;
class UserAdminController
{
public function index(array $params): void
{
$users = User::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Benutzerverwaltung',
'content' => $this->renderView('useradmin/list', [
'users' => $users,
'errors' => [],
]),
]);
}
public function store(array $params): void
{
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$errors = [];
if ($username === '') {
$errors['username'] = 'Pflichtfeld';
} elseif (strlen($username) > 64) {
$errors['username'] = 'Maximal 64 Zeichen';
} elseif (User::findByUsername($username)) {
$errors['username'] = 'Benutzername bereits vergeben';
}
if (strlen($password) < 8) {
$errors['password'] = 'Mindestens 8 Zeichen erforderlich';
}
if ($errors) {
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Benutzerverwaltung',
'content' => $this->renderView('useradmin/list', [
'users' => User::all(),
'errors' => $errors,
'formData' => ['username' => $username],
]),
]);
return;
}
User::create($username, $password);
View::redirect('/useradmin', 'Benutzer angelegt.');
}
public function delete(array $params): void
{
$id = (int)($params['id'] ?? 0);
// Letzten aktiven Benutzer nicht löschen
if (User::count() <= 1) {
View::redirect('/useradmin', 'Der letzte Benutzer kann nicht gelöscht werden.', 'error');
return;
}
User::delete($id);
View::redirect('/useradmin', 'Benutzer gelöscht.');
}
public function toggleActive(array $params): void
{
$id = (int)($params['id'] ?? 0);
$user = User::find($id);
if (!$user) {
View::redirect('/useradmin', 'Benutzer nicht gefunden.', 'error');
return;
}
$newState = !(bool)$user['active'];
// Letzten aktiven Benutzer nicht deaktivieren
if (!$newState) {
$activeCount = count(array_filter(User::all(), fn($u) => $u['active']));
if ($activeCount <= 1) {
View::redirect('/useradmin', 'Der letzte aktive Benutzer kann nicht deaktiviert werden.', 'error');
return;
}
}
User::setActive($id, $newState);
$msg = $newState ? 'Benutzer aktiviert.' : 'Benutzer deaktiviert.';
View::redirect('/useradmin', $msg);
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

61
src/Models/User.php Normal file
View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Database;
class User
{
public static function all(): array
{
return Database::fetchAll(
'SELECT id, username, active, created_at FROM users ORDER BY username'
);
}
public static function find(int $id): array|false
{
return Database::fetchOne(
'SELECT id, username, active, created_at FROM users WHERE id = ?',
[$id]
);
}
public static function findByUsername(string $username): array|false
{
return Database::fetchOne(
'SELECT id, username, password_hash, active FROM users WHERE username = ?',
[$username]
);
}
public static function create(string $username, string $password): void
{
$hash = password_hash($password, PASSWORD_BCRYPT);
Database::execute(
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
[$username, $hash]
);
}
public static function delete(int $id): void
{
Database::execute('DELETE FROM users WHERE id = ?', [$id]);
}
public static function setActive(int $id, bool $active): void
{
Database::execute(
'UPDATE users SET active = ? WHERE id = ?',
[(int)$active, $id]
);
}
public static function count(): int
{
$row = Database::fetchOne('SELECT COUNT(*) AS n FROM users');
return (int)($row['n'] ?? 0);
}
}

View file

@ -62,11 +62,22 @@ function navActive(string $prefix, string $current): string {
<div class="sidebar-section">
<div class="sidebar-label">Verwaltung</div>
<nav>
<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>
Benutzer
</a>
<a href="/settings" class="<?= navActive('/settings', $path) ?>">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
Einstellungen
</a>
</nav>
</div>
<div style="margin-top:auto;padding:1rem;">
<form method="POST" action="/logout">
<button type="submit" style="width:100%;background:transparent;border:1px solid #475569;color:var(--color-nav-text);border-radius:var(--radius);padding:.4rem .75rem;cursor:pointer;font-size:.85rem;">
Abmelden
</button>
</form>
</div>
</aside>

83
src/Views/login.php Normal file
View file

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden – Steuerkram</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="icon" type="image/png" href="/static/favicon/favicon-96x96.png" sizes="96x96">
<link rel="icon" type="image/svg+xml" href="/static/favicon/favicon.svg">
<link rel="shortcut icon" href="/static/favicon/favicon.ico">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--color-bg); }
.login-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.5rem 2rem;
width: 100%;
max-width: 380px;
}
.login-brand {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-nav-bg);
margin-bottom: 1.75rem;
text-align: center;
}
.login-brand span { color: var(--color-primary); }
</style>
</head>
<body>
<div class="login-card">
<div class="login-brand">Steuer<span>kram</span></div>
<?php if (!empty($flash)): ?>
<?php foreach ($flash as $f): ?>
<div class="flash flash-<?= \Steuerkram\View::h($f['type']) ?>" style="margin-bottom:1rem;">
<?= \Steuerkram\View::h($f['message']) ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php if (!empty($error)): ?>
<div class="flash flash-error" style="margin-bottom:1rem;">
<?= \Steuerkram\View::h($error) ?>
</div>
<?php endif; ?>
<?php if (!empty($setup)): ?>
<p style="margin-bottom:1.25rem;color:var(--color-muted);font-size:.9rem;">
Kein Benutzer vorhanden. Legen Sie jetzt den ersten Administrator-Account an.
</p>
<?php endif; ?>
<form method="POST" action="/login">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username"
value="<?= \Steuerkram\View::h($username ?? '') ?>"
autofocus autocomplete="username" required>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="password"
autocomplete="<?= !empty($setup) ? 'new-password' : 'current-password' ?>" required>
</div>
<?php if (!empty($setup)): ?>
<div class="form-group">
<label for="password2">Passwort bestätigen</label>
<input type="password" id="password2" name="password2"
autocomplete="new-password" required>
</div>
<?php endif; ?>
<div style="margin-top:1.5rem;">
<button type="submit" class="btn btn-primary" style="width:100%;">
<?= !empty($setup) ? 'Account anlegen' : 'Anmelden' ?>
</button>
</div>
</form>
</div>
</body>
</html>

View file

@ -0,0 +1,77 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="margin-bottom:1.5rem;">
<div class="card-header"><div class="card-title">Neuen Benutzer anlegen</div></div>
<div class="card-body">
<form method="POST" action="/useradmin">
<div class="form-row">
<div class="form-group" style="flex:1">
<label>Benutzername</label>
<input type="text" name="username"
value="<?= $h($formData['username'] ?? '') ?>"
class="<?= isset($errors['username']) ? 'input-error' : '' ?>"
maxlength="64" required>
<?php if (isset($errors['username'])): ?>
<div class="error-text"><?= $h($errors['username']) ?></div>
<?php endif; ?>
</div>
<div class="form-group" style="flex:1">
<label>Passwort</label>
<input type="password" name="password"
class="<?= isset($errors['password']) ? 'input-error' : '' ?>"
autocomplete="new-password" required>
<?php if (isset($errors['password'])): ?>
<div class="error-text"><?= $h($errors['password']) ?></div>
<?php endif; ?>
</div>
<div style="padding-bottom:.05rem">
<button type="submit" class="btn btn-primary">Anlegen</button>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header"><div class="card-title">Vorhandene Benutzer</div></div>
<?php if (empty($users)): ?>
<div class="card-body"><p style="color:var(--color-muted)">Keine Benutzer vorhanden.</p></div>
<?php else: ?>
<table class="table">
<thead>
<tr>
<th>Benutzername</th>
<th>Status</th>
<th>Erstellt am</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr>
<td><?= $h($u['username']) ?></td>
<td>
<?php if ($u['active']): ?>
<span class="badge badge-green">Aktiv</span>
<?php else: ?>
<span class="badge badge-red">Deaktiviert</span>
<?php endif; ?>
</td>
<td><?= $h(View::formatDate(substr($u['created_at'], 0, 10))) ?></td>
<td style="text-align:right">
<form method="POST" action="/useradmin/<?= (int)$u['id'] ?>/toggle" style="display:inline;">
<button type="submit" class="btn btn-sm <?= $u['active'] ? 'btn-secondary' : 'btn-primary' ?>">
<?= $u['active'] ? 'Deaktivieren' : 'Aktivieren' ?>
</button>
</form>
<form method="POST" action="/useradmin/<?= (int)$u['id'] ?>/delete" style="display:inline;"
onsubmit="return confirm('Benutzer «<?= $h($u['username']) ?>» wirklich löschen?')">
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>