Rebuild
This commit is contained in:
@@ -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
|
||||
)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
153
src/App/OidcClient.php
Normal file
153
src/App/OidcClient.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class OidcClient
|
||||
{
|
||||
public function __construct(private Config $config) {}
|
||||
|
||||
public function authUrl(string $state, string $nonce): string
|
||||
{
|
||||
$params = [
|
||||
'client_id' => $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) ?: '';
|
||||
}
|
||||
}
|
||||
@@ -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 '<div class="card">Keine Berechtigung.</div>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function modules(): \App\ModuleManager
|
||||
{
|
||||
return app()->modules();
|
||||
|
||||
Reference in New Issue
Block a user