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
+ + + +
+ +
+ + + + +
+ +
+ + + +

+ 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); ?> + +
+
Neuen Benutzer anlegen
+
+
+
+
+ + + +
+ +
+
+ + + +
+ +
+
+ +
+
+
+
+
+ +
+
Vorhandene Benutzer
+ +

Keine Benutzer vorhanden.

+ + + + + + + + + + + + + + + + + + + + +
BenutzernameStatusErstellt am
+ + Aktiv + + Deaktiviert + + +
+ +
+
+ +
+
+ +