diff --git a/.env.example b/.env.example
index 4e76c4f..03ee8f8 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 3e49df4..9c9459b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/install.sh b/install.sh
index bc4f4fd..1423fc5 100755
--- a/install.sh
+++ b/install.sh
@@ -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}"
diff --git a/migrations/005_users.sql b/migrations/005_users.sql
new file mode 100644
index 0000000..ae38596
--- /dev/null
+++ b/migrations/005_users.sql
@@ -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;
diff --git a/public/index.php b/public/index.php
index 493782d..97d84a0 100644
--- a/public/index.php
+++ b/public/index.php
@@ -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();
diff --git a/src/Auth.php b/src/Auth.php
index 104f321..a00e8d6 100644
--- a/src/Auth.php
+++ b/src/Auth.php
@@ -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 '
401 Unauthorized
';
- 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;
+ }
}
diff --git a/src/Config.php b/src/Config.php
index ab7bfff..42020c7 100644
--- a/src/Config.php
+++ b/src/Config.php
@@ -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' => '',
diff --git a/src/Controllers/LoginController.php b/src/Controllers/LoginController.php
new file mode 100644
index 0000000..7db940e
--- /dev/null
+++ b/src/Controllers/LoginController.php
@@ -0,0 +1,100 @@
+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;
+ }
+}
diff --git a/src/Controllers/UserAdminController.php b/src/Controllers/UserAdminController.php
new file mode 100644
index 0000000..3ac80bd
--- /dev/null
+++ b/src/Controllers/UserAdminController.php
@@ -0,0 +1,107 @@
+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();
+ }
+}
diff --git a/src/Models/User.php b/src/Models/User.php
new file mode 100644
index 0000000..0e45d41
--- /dev/null
+++ b/src/Models/User.php
@@ -0,0 +1,61 @@
+
+
+
+
+
diff --git a/src/Views/login.php b/src/Views/login.php
new file mode 100644
index 0000000..c5c78f4
--- /dev/null
+++ b/src/Views/login.php
@@ -0,0 +1,83 @@
+
+
+
+
+
+ Anmelden – Steuerkram
+
+
+
+
+
+
+
+
+
Steuerkram
+
+
+
+
+ = \Steuerkram\View::h($f['message']) ?>
+
+
+
+
+
+
+ = \Steuerkram\View::h($error) ?>
+
+
+
+
+
+ Kein Benutzer vorhanden. Legen Sie jetzt den ersten Administrator-Account an.
+
+
+
+
+
+
+
diff --git a/src/Views/useradmin/list.php b/src/Views/useradmin/list.php
new file mode 100644
index 0000000..4e1a61c
--- /dev/null
+++ b/src/Views/useradmin/list.php
@@ -0,0 +1,77 @@
+ View::h($v); ?>
+
+
+
+
+
+
+
Keine Benutzer vorhanden.
+
+
+
+
+ | Benutzername |
+ Status |
+ Erstellt am |
+ |
+
+
+
+
+
+ | = $h($u['username']) ?> |
+
+
+ Aktiv
+
+ Deaktiviert
+
+ |
+ = $h(View::formatDate(substr($u['created_at'], 0, 10))) ?> |
+
+
+
+ |
+
+
+
+
+
+