From c360663603b697f08ef206d8593697d143f987a4 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 4 Mar 2026 01:58:26 +0100 Subject: [PATCH] Rebuild --- config/prod/settings.php | 13 ++ config/staging/settings.php | 13 ++ modules/kea/module.json | 7 + partials/structure/layout_end.php | 11 +- partials/structure/layout_start.php | 82 ++++++++-- public/assets/css/app.css | 231 +++++++++++++++++++++------- public/assets/js/app.js | 14 +- public/index.php | 30 +++- public/page/404.php | 11 ++ public/page/auth_callback.php | 49 ++++++ public/page/auth_login.php | 19 +++ public/page/auth_logout.php | 23 +++ public/page/debug.php | 90 +++++++++++ public/page/modules.php | 20 +-- public/page/modules_install.php | 78 ++++++++++ public/page/modules_setup.php | 2 + public/page/settings.php | 47 ++++++ public/page/users.php | 128 +++++++++++++++ src/App/BaseSchema.php | 45 ++++++ src/App/Config.php | 24 +++ src/App/ModuleManager.php | 1 + src/App/OidcClient.php | 153 ++++++++++++++++++ src/App/functions.php | 105 +++++++++++++ 23 files changed, 1115 insertions(+), 81 deletions(-) create mode 100644 public/page/404.php create mode 100644 public/page/auth_callback.php create mode 100644 public/page/auth_login.php create mode 100644 public/page/auth_logout.php create mode 100644 public/page/debug.php create mode 100644 public/page/modules_install.php create mode 100644 public/page/settings.php create mode 100644 public/page/users.php create mode 100644 src/App/OidcClient.php diff --git a/config/prod/settings.php b/config/prod/settings.php index 352725a..b418d17 100755 --- a/config/prod/settings.php +++ b/config/prod/settings.php @@ -10,3 +10,16 @@ define('APP_BASE_DB_ENABLED', true); define('APP_BASIC_AUTH', false); define('APP_SEARCH_DEBUG', false); + define('APP_AUTH_ENABLED', true); + define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin'); + define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/auth'); + define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/token'); + define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/userinfo'); + define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout'); + define('APP_OIDC_CLIENT_ID', 'nexus'); + define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs'); + define('APP_OIDC_REDIRECT_URI', 'https://nexus.int.kusche.berlin/auth/callback'); + define('APP_OIDC_GROUP_CLAIM', 'groups'); + define('APP_OIDC_ADMIN_GROUP', 'admin'); + define('APP_OIDC_USER_GROUP', 'family'); + define('APP_DEBUG_TOOL', false); diff --git a/config/staging/settings.php b/config/staging/settings.php index e8f955c..459de1f 100755 --- a/config/staging/settings.php +++ b/config/staging/settings.php @@ -10,3 +10,16 @@ define('APP_BASE_DB_ENABLED', true); define('APP_BASIC_AUTH', true); define('APP_SEARCH_DEBUG', true); + define('APP_AUTH_ENABLED', true); + define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin'); + define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/auth'); + define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/token'); + define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/userinfo'); + define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout'); + define('APP_OIDC_CLIENT_ID', 'nexus'); + define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs'); + define('APP_OIDC_REDIRECT_URI', 'https://staging.nexus.int.kusche.berlin/auth/callback'); + define('APP_OIDC_GROUP_CLAIM', 'groups'); + define('APP_OIDC_ADMIN_GROUP', 'admin'); + define('APP_OIDC_USER_GROUP', 'family'); + define('APP_DEBUG_TOOL', true); diff --git a/modules/kea/module.json b/modules/kea/module.json index 01e0fa2..6849df7 100644 --- a/modules/kea/module.json +++ b/modules/kea/module.json @@ -2,8 +2,13 @@ "title": "KEA DHCP", "version": "1.0.0", "description": "Verwaltung von KEA DHCP Hosts und Reservierungen.", + "menu": [ + { "label": "Hosts", "href": "/module/kea" }, + { "label": "Setup", "href": "/modules/setup/kea" } + ], "setup": { "fields": [ + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": true }, { "name": "db.host", "label": "DB Host", "type": "text", "required": true }, { "name": "db.port", "label": "DB Port", "type": "number", "required": true }, { "name": "db.dbname", "label": "DB Name", "type": "text", "required": true }, @@ -11,6 +16,8 @@ { "name": "db.user", "label": "DB User", "type": "text", "required": true }, { "name": "db.password", "label": "DB Passwort", "type": "password", "required": true }, { "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false }, + { "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false }, + { "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false }, { "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false } ] }, diff --git a/partials/structure/layout_end.php b/partials/structure/layout_end.php index 3f9df47..e28e1c4 100755 --- a/partials/structure/layout_end.php +++ b/partials/structure/layout_end.php @@ -1,8 +1,9 @@ - - + + + diff --git a/partials/structure/layout_start.php b/partials/structure/layout_start.php index 63ad363..c961e14 100755 --- a/partials/structure/layout_start.php +++ b/partials/structure/layout_start.php @@ -3,6 +3,14 @@ $app = app(); $app->assets()->addStyle('/assets/css/app.css', 'early'); $app->assets()->addScript('/assets/js/app.js', 'footer', true); +$theme = user_theme(); +$currentModule = current_module_name(); +$path = $app->request()->path(); +$moduleMenu = []; +if ($currentModule) { + $module = modules()->get($currentModule); + $moduleMenu = $module['menu'] ?? []; +} ?> @@ -13,11 +21,11 @@ $app->assets()->addScript('/assets/js/app.js', 'footer', true); - +
-
- -
+ + +
+
+
+ +

+ Modul + +

+ Dashboard + +
+ +
+ + +
+ + + + + +
+ + +
+ + + 🐞 + + diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 778151f..7b301aa 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -1,15 +1,51 @@ @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@400;600;700&display=swap'); :root { - --bg: #0b0d12; - --panel: #121622; - --panel-2: #171c2b; - --text: #f4f6ff; - --muted: #b6bdd6; - --accent: #ff5e5b; - --accent-2: #20e3b2; - --line: #2a3147; - --shadow: 0 30px 80px rgba(8, 10, 18, 0.45); + --bg: #f4f6fb; + --panel: #ffffff; + --panel-2: #f0f3fb; + --text: #151a2d; + --muted: #5a6685; + --accent: #ff6b4a; + --accent-2: #0fb4a4; + --line: #d7dceb; + --shadow: 0 20px 50px rgba(22, 32, 74, 0.12); +} + +body[data-theme="light"] { + --bg: #f4f6fb; + --panel: #ffffff; + --panel-2: #f0f3fb; + --text: #151a2d; + --muted: #5a6685; + --accent: #ff6b4a; + --accent-2: #0fb4a4; + --line: #d7dceb; + --shadow: 0 20px 50px rgba(22, 32, 74, 0.12); +} + +body[data-theme="ocean"] { + --bg: #eef6ff; + --panel: #ffffff; + --panel-2: #e6f0ff; + --text: #0b1b33; + --muted: #3a4c6e; + --accent: #2d7bff; + --accent-2: #00b6b2; + --line: #c9d9f3; + --shadow: 0 20px 50px rgba(22, 32, 74, 0.12); +} + +body[data-theme="graphite"] { + --bg: #f7f7f8; + --panel: #ffffff; + --panel-2: #eceff3; + --text: #1e222a; + --muted: #5c667a; + --accent: #ff7a00; + --accent-2: #6b7bff; + --line: #d5d8df; + --shadow: 0 20px 50px rgba(20, 24, 34, 0.12); } * { box-sizing: border-box; } @@ -17,8 +53,8 @@ html, body { height: 100%; } body { margin: 0; font-family: "Space Grotesk", "Segoe UI", sans-serif; - background: radial-gradient(1200px 500px at 10% -10%, rgba(255, 94, 91, 0.2), transparent 60%), - radial-gradient(900px 500px at 90% 10%, rgba(32, 227, 178, 0.18), transparent 55%), + background: radial-gradient(1200px 500px at 10% -10%, rgba(255, 107, 74, 0.12), transparent 60%), + radial-gradient(900px 500px at 90% 10%, rgba(15, 180, 164, 0.12), transparent 55%), var(--bg); color: var(--text); } @@ -36,26 +72,50 @@ body { .orb-a { top: -120px; left: -80px; background: #ff5e5b; } .orb-b { bottom: -160px; right: -120px; background: #20e3b2; } -.site-shell { +.app-shell { position: relative; z-index: 1; min-height: 100vh; - display: flex; - flex-direction: column; - padding: 32px 28px 24px; + display: grid; + grid-template-columns: 260px 1fr; + gap: 24px; + padding: 24px; } -.site-header { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 24px; - align-items: center; - background: linear-gradient(140deg, rgba(18, 22, 34, 0.9), rgba(23, 28, 43, 0.9)); +.app-sidebar { + background: var(--panel); border: 1px solid var(--line); border-radius: 18px; - padding: 20px 24px; + padding: 20px; box-shadow: var(--shadow); - backdrop-filter: blur(6px); + display: flex; + flex-direction: column; + gap: 18px; + position: sticky; + top: 24px; + height: fit-content; +} + +.sidebar-toggle { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + color: var(--text); + font-weight: 700; +} + +.sidebar-collapsed .app-shell { + grid-template-columns: 72px 1fr; +} +.sidebar-collapsed .app-sidebar .brand-text, +.sidebar-collapsed .app-sidebar .nav-section, +.sidebar-collapsed .app-sidebar .nav-link { + display: none; +} +.sidebar-collapsed .app-sidebar .brand img { + height: 36px; } .brand { @@ -78,35 +138,41 @@ body { font-size: 0.9rem; } -.site-nav { +.sidebar-nav { display: flex; - gap: 12px; - flex-wrap: wrap; + flex-direction: column; + gap: 8px; +} +.nav-section { + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 6px; } .nav-link { - color: var(--muted); + color: var(--text); text-decoration: none; padding: 8px 14px; - border-radius: 999px; + border-radius: 10px; border: 1px solid transparent; transition: all 180ms ease; font-weight: 600; } .nav-link:hover { - color: var(--text); border-color: var(--line); - background: rgba(255, 255, 255, 0.04); + background: var(--panel-2); } .nav-link.is-active { - color: var(--bg); + color: #ffffff; background: var(--accent); border-color: var(--accent); } -.header-cta .cta-button { +.cta-button { background: linear-gradient(120deg, var(--accent), #ff9f45); border: none; - color: #0b0d12; + color: #ffffff; font-weight: 700; padding: 10px 18px; border-radius: 12px; @@ -114,9 +180,66 @@ body { box-shadow: 0 14px 30px rgba(255, 94, 91, 0.35); } +.app-content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.topbar { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: var(--shadow); +} +.page-title { + margin: 0; + font-size: 1.25rem; +} +.topbar-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.module-subnav { + display: flex; + gap: 10px; + flex-wrap: wrap; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel-2); +} + +.debug-fab { + position: fixed; + right: 24px; + bottom: 24px; + width: 52px; + height: 52px; + border-radius: 50%; + background: var(--accent); + color: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + font-size: 22px; + box-shadow: 0 16px 32px rgba(255, 107, 74, 0.28); + z-index: 50; +} +.debug-fab:hover { + filter: brightness(0.95); +} + .site-main { flex: 1; - margin-top: 28px; } .site-footer { @@ -136,7 +259,15 @@ body { } .card input, .card textarea { - background: #0f121b; + background: #ffffff; + border: 1px solid var(--line); + color: var(--text); + padding: 10px 12px; + border-radius: 10px; + font-family: inherit; +} +.card select { + background: #ffffff; border: 1px solid var(--line); color: var(--text); padding: 10px 12px; @@ -167,17 +298,13 @@ body { gap: 16px; } -@media (max-width: 900px) { - .site-header { - grid-template-columns: 1fr; - } - .header-cta { - justify-self: start; - } +@media (max-width: 1100px) { + .app-shell { grid-template-columns: 1fr; } + .app-sidebar { position: relative; top: 0; } } @media (max-width: 720px) { .grid { grid-template-columns: 1fr; } - .site-shell { padding: 24px 18px 20px; } + .app-shell { padding: 18px; } } /* Minimal Tailwind-like utility support for existing templates */ @@ -194,13 +321,13 @@ body { } .text-white { color: #ffffff; } -.text-gray-200 { color: #d9deee; } -.text-gray-300 { color: #c2c9de; } -.text-gray-400 { color: #a9b1c9; } -.text-gray-500 { color: #8b94ad; } -.text-red-100 { color: #ffe2e2; } -.text-indigo-400 { color: #9aa5ff; } -.hover\\:text-indigo-300:hover { color: #b7c0ff; } +.text-gray-200 { color: #3a4c6e; } +.text-gray-300 { color: #4b5775; } +.text-gray-400 { color: #5a6685; } +.text-gray-500 { color: #6b7696; } +.text-red-100 { color: #8b1d1d; } +.text-indigo-400 { color: #2d7bff; } +.hover\\:text-indigo-300:hover { color: #1b63da; } .font-medium { font-weight: 600; } .font-semibold { font-weight: 700; } .font-bold { font-weight: 700; } @@ -219,8 +346,8 @@ body { .border-red-500 { border-color: #ff5e5b; } .border-gray-700 { border-color: var(--line); } .bg-gray-800 { background: var(--panel); } -.bg-gray-900 { background: #0f121b; } -.bg-red-900 { background: #2c1214; } +.bg-gray-900 { background: var(--panel-2); } +.bg-red-900 { background: #ffe9e9; } .shadow { box-shadow: var(--shadow); } .overflow-hidden { overflow: hidden; } .overflow-x-auto { overflow-x: auto; } diff --git a/public/assets/js/app.js b/public/assets/js/app.js index bbe8027..4823835 100755 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -1 +1,13 @@ -console.log('mini example loaded'); +(() => { + const toggle = document.querySelector('[data-sidebar-toggle]'); + if (toggle) { + toggle.addEventListener('click', () => { + document.body.classList.toggle('sidebar-collapsed'); + localStorage.setItem('sidebar-collapsed', document.body.classList.contains('sidebar-collapsed') ? '1' : '0'); + }); + } + + if (localStorage.getItem('sidebar-collapsed') === '1') { + document.body.classList.add('sidebar-collapsed'); + } +})(); diff --git a/public/index.php b/public/index.php index 2de9118..7b60658 100755 --- a/public/index.php +++ b/public/index.php @@ -22,6 +22,20 @@ if (defined('APP_BASIC_AUTH') && APP_BASIC_AUTH && !$isRetoolPath) { } } +// OIDC Auth +$publicPaths = [ + 'auth/login', + 'auth/callback', + 'auth/logout', +]; +if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && !in_array($uriPath, $publicPaths, true)) { + $user = auth_user(); + if (!$user) { + header('Location: /auth/login', true, 302); + exit; + } +} + // Sicherheitscheck if (str_contains($uriPath, '..')) { http_response_code(400); @@ -29,9 +43,23 @@ if (str_contains($uriPath, '..')) { } // Spezialrouten für Module -if (str_starts_with($uriPath, 'modules/setup/')) { +if (str_starts_with($uriPath, 'modules/install')) { + $target = __DIR__ . '/page/modules_install.php'; +} elseif (str_starts_with($uriPath, 'modules/setup/')) { $_GET['module'] = trim(substr($uriPath, strlen('modules/setup/')), '/'); $target = __DIR__ . '/page/modules_setup.php'; +} elseif ($uriPath === 'auth/login') { + $target = __DIR__ . '/page/auth_login.php'; +} elseif ($uriPath === 'auth/callback') { + $target = __DIR__ . '/page/auth_callback.php'; +} elseif ($uriPath === 'auth/logout') { + $target = __DIR__ . '/page/auth_logout.php'; +} elseif ($uriPath === 'settings') { + $target = __DIR__ . '/page/settings.php'; +} elseif ($uriPath === 'users') { + $target = __DIR__ . '/page/users.php'; +} elseif ($uriPath === 'debug') { + $target = __DIR__ . '/page/debug.php'; } elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) { $module = $m[1]; $page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index'; diff --git a/public/page/404.php b/public/page/404.php new file mode 100644 index 0000000..b07a2f3 --- /dev/null +++ b/public/page/404.php @@ -0,0 +1,11 @@ + +
+
404
+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht oder wurde verschoben.

+ +
diff --git a/public/page/auth_callback.php b/public/page/auth_callback.php new file mode 100644 index 0000000..753e64a --- /dev/null +++ b/public/page/auth_callback.php @@ -0,0 +1,49 @@ +config(); +$session = app()->session(); +$session->start(); + +if (!$config->authEnabled) { + echo '
Auth ist deaktiviert.
'; + return; +} + +$code = (string)($_GET['code'] ?? ''); +$state = (string)($_GET['state'] ?? ''); +$expectedState = (string)($_SESSION['oidc_state'] ?? ''); +$nonce = (string)($_SESSION['oidc_nonce'] ?? ''); + +if ($code === '' || $state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) { + echo '
Ungültiger Login-Status.
'; + return; +} + +unset($_SESSION['oidc_state']); + +$client = new OidcClient($config); +$token = $client->exchangeCode($code); + +$idToken = (string)($token['id_token'] ?? ''); +if ($idToken === '') { + echo '
Kein ID Token erhalten.
'; + return; +} + +$claims = $client->decodeJwt($idToken); +$client->validateIdToken($claims, $nonce); +unset($_SESSION['oidc_nonce']); + +$groups = $client->groupsFromClaims($claims); +$user = [ + 'sub' => (string)($claims['sub'] ?? ''), + 'email' => (string)($claims['email'] ?? ''), + 'name' => (string)($claims['name'] ?? ($claims['preferred_username'] ?? '')), + 'groups' => $groups, + 'id_token' => $idToken, +]; + +$_SESSION['auth_user'] = $user; + +redirect('/'); diff --git a/public/page/auth_login.php b/public/page/auth_login.php new file mode 100644 index 0000000..0038b2f --- /dev/null +++ b/public/page/auth_login.php @@ -0,0 +1,19 @@ +config(); +if (!$config->authEnabled) { + echo '
Auth ist deaktiviert.
'; + return; +} + +$session = app()->session(); +$session->start(); + +$state = bin2hex(random_bytes(16)); +$nonce = bin2hex(random_bytes(16)); +$_SESSION['oidc_state'] = $state; +$_SESSION['oidc_nonce'] = $nonce; + +$client = new OidcClient($config); +redirect($client->authUrl($state, $nonce)); diff --git a/public/page/auth_logout.php b/public/page/auth_logout.php new file mode 100644 index 0000000..56d5948 --- /dev/null +++ b/public/page/auth_logout.php @@ -0,0 +1,23 @@ +config(); +$session = app()->session(); +$session->start(); + +$idToken = null; +if (!empty($_SESSION['auth_user']['id_token'])) { + $idToken = (string)$_SESSION['auth_user']['id_token']; +} + +unset($_SESSION['auth_user']); + +if ($config->authEnabled) { + $client = new OidcClient($config); + $url = $client->logoutUrl($idToken); + if ($url) { + redirect($url); + } +} + +redirect('/'); diff --git a/public/page/debug.php b/public/page/debug.php new file mode 100644 index 0000000..f7cef19 --- /dev/null +++ b/public/page/debug.php @@ -0,0 +1,90 @@ +Debug-Tool ist deaktiviert.
'; + return; +} + +$debugDir = __DIR__ . '/../../debug'; +if (!is_dir($debugDir)) { + echo '
Debug-Verzeichnis fehlt.
'; + return; +} + +$files = array_values(array_filter(scandir($debugDir) ?: [], function ($f) use ($debugDir) { + if ($f === '.' || $f === '..') return false; + $path = $debugDir . '/' . $f; + return is_file($path); +})); + +$selected = (string)($_GET['file'] ?? ''); +$content = null; + +if ($selected !== '' && preg_match('/^[a-zA-Z0-9._-]+$/', $selected)) { + $path = $debugDir . '/' . $selected; + if (is_file($path)) { + $content = file_get_contents($path); + } +} + +if (isset($_GET['raw']) && $_GET['raw'] === '1') { + header('Content-Type: text/plain; charset=utf-8'); + echo $content ?? ''; + return; +} +?> +
+
Debug
+

Debug Logs

+

Hier kannst du temporäre Log-Files aus dem debug/-Ordner ansehen.

+ +
+
+ Logs +
    + +
  • Keine Logs vorhanden.
  • + + +
  • + +
  • + +
+
+
+ Inhalt + +

Wähle eine Datei.

+ +
+ +
+
+
+ + + + diff --git a/public/page/modules.php b/public/page/modules.php index 7ab9d13..62570b2 100644 --- a/public/page/modules.php +++ b/public/page/modules.php @@ -4,6 +4,7 @@ $error = null; $notice = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_admin(); $name = (string)($_POST['module'] ?? ''); $action = (string)($_POST['action'] ?? ''); @@ -18,8 +19,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } ?>
+
Module

Module verwalten

+

Hier siehst du nur aktive Module. Installierte Module kannst du unten verwalten.

diff --git a/public/page/modules_install.php b/public/page/modules_install.php new file mode 100644 index 0000000..830c5d3 --- /dev/null +++ b/public/page/modules_install.php @@ -0,0 +1,78 @@ +all(); +$error = null; +$notice = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_admin(); + $name = (string)($_POST['module'] ?? ''); + $action = (string)($_POST['action'] ?? ''); + if ($name !== '' && ($action === 'enable' || $action === 'disable')) { + modules()->setEnabled($name, $action === 'enable'); + $notice = $action === 'enable' ? 'Modul aktiviert.' : 'Modul deaktiviert.'; + $modules = modules()->all(); + } else { + $error = 'Ungültige Aktion.'; + } +} + +$active = []; +$inactive = []; +foreach ($modules as $m) { + if (!empty($m['enabled'])) { + $active[] = $m; + } else { + $inactive[] = $m; + } +} +?> + +
+
Module
+

Module installieren/aktivieren

+

Erkannte Module basieren auf Ordnern in modules/.

+ + + + +
+ +
+ + +

Aktive Module

+
+ +
+ +
+
+ Öffnen +
+ + +
+
+
+ +
+ +

Deaktivierte Module

+
+ +
+ +
+
+ Setup +
+ + +
+
+
+ +
+
diff --git a/public/page/modules_setup.php b/public/page/modules_setup.php index af8d71c..301eeb9 100644 --- a/public/page/modules_setup.php +++ b/public/page/modules_setup.php @@ -4,6 +4,8 @@ $module = modules()->get($moduleName); $error = null; $notice = null; +require_admin(); + if (!$module) { http_response_code(404); echo '
Modul nicht gefunden.
'; diff --git a/public/page/settings.php b/public/page/settings.php new file mode 100644 index 0000000..d1d091a --- /dev/null +++ b/public/page/settings.php @@ -0,0 +1,47 @@ + 'Light', + 'ocean' => 'Ocean', + 'graphite' => 'Graphite', +]; + +require_auth(); + +$current = user_theme(); +$notice = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $theme = (string)($_POST['theme'] ?? 'light'); + if (!isset($themes[$theme])) { + $theme = 'light'; + } + set_user_theme($theme); + $current = $theme; + $notice = 'Theme gespeichert.'; +} +?> +
+
Einstellungen
+

User-Design

+

Wähle deine persönliche Farbpalette.

+ + +
+ +
+ + +
+ + +
+
diff --git a/public/page/users.php b/public/page/users.php new file mode 100644 index 0000000..3dd0e42 --- /dev/null +++ b/public/page/users.php @@ -0,0 +1,128 @@ +basePdo(); +$error = null; +$notice = null; + +require_admin(); + +if (!$pdo) { + echo '
Base-DB nicht aktiviert.
'; + return; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string)($_POST['action'] ?? ''); + + if ($action === 'add_role') { + $role = trim((string)($_POST['role'] ?? '')); + $desc = trim((string)($_POST['description'] ?? '')); + if ($role === '') { + $error = 'Rollenname fehlt.'; + } else { + $stmt = $pdo->prepare( + "INSERT INTO nexus_roles (name, description) + VALUES (:name, :description) + ON CONFLICT(name) DO UPDATE SET description = excluded.description" + ); + $stmt->execute(['name' => $role, 'description' => $desc]); + $notice = 'Rolle gespeichert.'; + } + } elseif ($action === 'add_user') { + $email = trim((string)($_POST['email'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + $role = trim((string)($_POST['role'] ?? 'user')); + + if ($email === '' || $password === '') { + $error = 'E-Mail und Passwort sind erforderlich.'; + } else { + $hash = password_hash($password, PASSWORD_DEFAULT); + $pdo->prepare( + "INSERT INTO nexus_users (email, password_hash, role, is_active) + VALUES (:email, :hash, :role, 1)" + )->execute([ + 'email' => $email, + 'hash' => $hash, + 'role' => $role !== '' ? $role : 'user', + ]); + + $pdo->prepare( + "INSERT INTO nexus_roles (name) VALUES (:name) + ON CONFLICT(name) DO NOTHING" + )->execute(['name' => $role !== '' ? $role : 'user']); + + $notice = 'User angelegt.'; + } + } +} + +$roles = $pdo->query("SELECT name, description FROM nexus_roles ORDER BY name")->fetchAll(PDO::FETCH_ASSOC) ?: []; +$users = $pdo->query("SELECT id, email, role, is_active, created_at FROM nexus_users ORDER BY id DESC")->fetchAll(PDO::FETCH_ASSOC) ?: []; +?> +
+
Userverwaltung
+

User & Rollen

+

Admin kann Module aktivieren/deaktivieren, Benutzer können Module nutzen.

+ + + + +
+ +
+ + +
+
+ Rollen +
    + +
  • + +
+ +
+ + + + +
+
+ +
+ User anlegen +
+ + + + + +
+
+
+ +

Userliste

+
+ + + + + + + + + + + + + + + + + + + +
E-MailRolleAktivErstellt
+
+
diff --git a/src/App/BaseSchema.php b/src/App/BaseSchema.php index 5c6eade..d5a041b 100644 --- a/src/App/BaseSchema.php +++ b/src/App/BaseSchema.php @@ -58,6 +58,21 @@ final class BaseSchema created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_roles ( + name TEXT PRIMARY KEY, + description TEXT + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_user_prefs ( + client_id TEXT PRIMARY KEY, + theme TEXT NOT NULL DEFAULT 'light', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )" + ); } private static function ensureSqlite(\PDO $pdo): void @@ -99,6 +114,21 @@ final class BaseSchema created_at TEXT NOT NULL DEFAULT (datetime('now')) )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_roles ( + name TEXT PRIMARY KEY, + description TEXT + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_user_prefs ( + client_id TEXT PRIMARY KEY, + theme TEXT NOT NULL DEFAULT 'light', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ); } private static function ensureGeneric(\PDO $pdo): void @@ -140,5 +170,20 @@ final class BaseSchema created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )" ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_roles ( + name VARCHAR(64) PRIMARY KEY, + description VARCHAR(255) + )" + ); + + $pdo->exec( + "CREATE TABLE IF NOT EXISTS nexus_user_prefs ( + client_id VARCHAR(190) PRIMARY KEY, + theme VARCHAR(64) NOT NULL DEFAULT 'light', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )" + ); } } diff --git a/src/App/Config.php b/src/App/Config.php index 4bc6fcf..0a27760 100755 --- a/src/App/Config.php +++ b/src/App/Config.php @@ -12,6 +12,18 @@ class Config public string $keaDbVersion; public array $baseDb; public bool $baseDbEnabled; + public bool $authEnabled; + public string $oidcIssuer; + public string $oidcClientId; + public string $oidcClientSecret; + public string $oidcRedirectUri; + public string $oidcAuthEndpoint; + public string $oidcTokenEndpoint; + public string $oidcUserinfoEndpoint; + public string $oidcLogoutEndpoint; + public string $oidcGroupClaim; + public string $oidcAdminGroup; + public string $oidcUserGroup; public function __construct( public array $db, @@ -26,6 +38,18 @@ class Config $this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : ''; $this->baseDb = $baseDb; $this->baseDbEnabled = $baseDbEnabled; + $this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : false; + $this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : ''; + $this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : ''; + $this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : ''; + $this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : ''; + $this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : ''; + $this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : ''; + $this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : ''; + $this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : ''; + $this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : 'groups'; + $this->oidcAdminGroup = defined('APP_OIDC_ADMIN_GROUP') ? (string)APP_OIDC_ADMIN_GROUP : 'admin'; + $this->oidcUserGroup = defined('APP_OIDC_USER_GROUP') ? (string)APP_OIDC_USER_GROUP : 'user'; } public function primaryUrl(): string diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php index 70507a9..517ee95 100644 --- a/src/App/ModuleManager.php +++ b/src/App/ModuleManager.php @@ -208,6 +208,7 @@ final class ModuleManager 'version' => $data['version'] ?? '', 'description' => $data['description'] ?? '', 'setup' => $data['setup'] ?? [], + 'menu' => $data['menu'] ?? [], 'db_defaults' => $data['db_defaults'] ?? [], 'path' => $dir, 'enabled' => false, diff --git a/src/App/OidcClient.php b/src/App/OidcClient.php new file mode 100644 index 0000000..31e7c4a --- /dev/null +++ b/src/App/OidcClient.php @@ -0,0 +1,153 @@ + $this->config->oidcClientId, + 'response_type' => 'code', + 'scope' => 'openid profile email', + 'redirect_uri' => $this->config->oidcRedirectUri, + 'state' => $state, + 'nonce' => $nonce, + ]; + + $endpoint = $this->endpoint('auth'); + return $endpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); + } + + public function exchangeCode(string $code): array + { + $endpoint = $this->endpoint('token'); + $post = [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->config->oidcRedirectUri, + 'client_id' => $this->config->oidcClientId, + ]; + + if ($this->config->oidcClientSecret !== '') { + $post['client_secret'] = $this->config->oidcClientSecret; + } + + $ch = curl_init($endpoint); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POSTFIELDS => http_build_query($post, '', '&', PHP_QUERY_RFC3986), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], + ]); + + $raw = curl_exec($ch); + if ($raw === false) { + throw new \RuntimeException('OIDC token request failed: ' . curl_error($ch)); + } + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($raw, true); + if (!is_array($data)) { + throw new \RuntimeException('OIDC token response invalid.'); + } + if ($status >= 400) { + $msg = $data['error_description'] ?? $data['error'] ?? 'OIDC token error'; + throw new \RuntimeException($msg); + } + + return $data; + } + + public function decodeJwt(string $jwt): array + { + $parts = explode('.', $jwt); + if (count($parts) < 2) { + throw new \RuntimeException('Invalid JWT'); + } + $payload = $this->b64url_decode($parts[1]); + $data = json_decode($payload, true); + if (!is_array($data)) { + throw new \RuntimeException('Invalid JWT payload'); + } + return $data; + } + + public function validateIdToken(array $claims, string $nonce): void + { + if (!empty($this->config->oidcIssuer) && ($claims['iss'] ?? '') !== $this->config->oidcIssuer) { + throw new \RuntimeException('Invalid token issuer'); + } + + $aud = $claims['aud'] ?? null; + if (is_array($aud)) { + if (!in_array($this->config->oidcClientId, $aud, true)) { + throw new \RuntimeException('Invalid token audience'); + } + } elseif (is_string($aud)) { + if ($aud !== $this->config->oidcClientId) { + throw new \RuntimeException('Invalid token audience'); + } + } + + if (!empty($claims['nonce']) && $claims['nonce'] !== $nonce) { + throw new \RuntimeException('Invalid token nonce'); + } + + if (!empty($claims['exp']) && time() >= (int)$claims['exp']) { + throw new \RuntimeException('Token expired'); + } + } + + public function groupsFromClaims(array $claims): array + { + $claim = $this->config->oidcGroupClaim ?: 'groups'; + $value = $claims[$claim] ?? []; + if (is_string($value)) { + $value = [$value]; + } + if (!is_array($value)) { + return []; + } + return array_values(array_filter(array_map('strval', $value))); + } + + public function logoutUrl(?string $idToken): ?string + { + $endpoint = $this->config->oidcLogoutEndpoint; + if ($endpoint === '') { + return null; + } + $params = [ + 'post_logout_redirect_uri' => $this->config->oidcRedirectUri ? dirname($this->config->oidcRedirectUri) : '/', + ]; + if ($idToken) { + $params['id_token_hint'] = $idToken; + } + return $endpoint . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); + } + + private function endpoint(string $type): string + { + return match ($type) { + 'auth' => $this->config->oidcAuthEndpoint !== '' ? $this->config->oidcAuthEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/auth', + 'token' => $this->config->oidcTokenEndpoint !== '' ? $this->config->oidcTokenEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/token', + 'userinfo' => $this->config->oidcUserinfoEndpoint !== '' ? $this->config->oidcUserinfoEndpoint : rtrim($this->config->oidcIssuer, '/') . '/protocol/openid-connect/userinfo', + default => throw new \RuntimeException('Unknown OIDC endpoint'), + }; + } + + private function b64url_decode(string $data): string + { + $data = strtr($data, '-_', '+/'); + $pad = strlen($data) % 4; + if ($pad) { + $data .= str_repeat('=', 4 - $pad); + } + return base64_decode($data) ?: ''; + } +} diff --git a/src/App/functions.php b/src/App/functions.php index 9062a76..822688e 100644 --- a/src/App/functions.php +++ b/src/App/functions.php @@ -13,6 +13,111 @@ function t(string $key, $default = '', array $vars = []): string return app()->i18n()->get($key, $default, $vars); } +function current_client_id(): string +{ + $session = app()->session(); + $session->start(); + return $session->ensureClientId(); +} + +function user_theme(): string +{ + $pdo = app()->basePdo(); + if (!$pdo) { + return 'light'; + } + + $clientId = current_client_id(); + $stmt = $pdo->prepare("SELECT theme FROM nexus_user_prefs WHERE client_id = :id LIMIT 1"); + $stmt->execute(['id' => $clientId]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $theme = is_array($row) ? (string)($row['theme'] ?? '') : ''; + return $theme !== '' ? $theme : 'light'; +} + +function set_user_theme(string $theme): void +{ + $pdo = app()->basePdo(); + if (!$pdo) { + return; + } + + $clientId = current_client_id(); + $stmt = $pdo->prepare( + "INSERT INTO nexus_user_prefs (client_id, theme, updated_at) + VALUES (:id, :theme, CURRENT_TIMESTAMP) + ON CONFLICT(client_id) DO UPDATE SET + theme = excluded.theme, + updated_at = CURRENT_TIMESTAMP" + ); + $stmt->execute(['id' => $clientId, 'theme' => $theme]); +} + +function current_module_name(): ?string +{ + $path = app()->request()->path(); + if (preg_match('~^/module/([a-zA-Z0-9_-]+)~', $path, $m)) { + return $m[1]; + } + return null; +} + +function auth_enabled(): bool +{ + return app()->config()->authEnabled; +} + +function auth_user(): ?array +{ + $session = app()->session(); + $session->start(); + return $_SESSION['auth_user'] ?? null; +} + +function auth_groups(): array +{ + $user = auth_user(); + return is_array($user['groups'] ?? null) ? $user['groups'] : []; +} + +function auth_is_admin(): bool +{ + $config = app()->config(); + $groups = auth_groups(); + return in_array($config->oidcAdminGroup, $groups, true); +} + +function auth_is_user(): bool +{ + $config = app()->config(); + $groups = auth_groups(); + if (in_array($config->oidcAdminGroup, $groups, true)) { + return true; + } + return in_array($config->oidcUserGroup, $groups, true); +} + +function require_auth(): void +{ + if (!auth_enabled()) { + return; + } + if (auth_user()) { + return; + } + redirect('/auth/login'); +} + +function require_admin(): void +{ + require_auth(); + if (!auth_is_admin()) { + http_response_code(403); + echo '
Keine Berechtigung.
'; + exit; + } +} + function modules(): \App\ModuleManager { return app()->modules();