Nexus upgrade design and refresh

This commit is contained in:
2026-04-11 01:23:28 +02:00
parent 9d5bb2d3cf
commit e83925ba64
53 changed files with 13388 additions and 60 deletions

View File

@@ -15,6 +15,7 @@ final class App
private ?\PDO $pdo;
private ?\PDO $basePdo;
private ModuleManager $modules;
private AuthService $auth;
private function __construct(private Config $config)
{
@@ -41,6 +42,7 @@ final class App
}
$this->modules = new ModuleManager($this->basePdo, $modulesPath);
$this->modules->bootEnabled();
$this->auth = new AuthService($this);
}
public static function init(Config $config): self
@@ -68,4 +70,5 @@ final class App
public function pdo(): ?\PDO { return $this->pdo; }
public function basePdo(): ?\PDO { return $this->basePdo; }
public function modules(): ModuleManager { return $this->modules; }
public function auth(): AuthService { return $this->auth; }
}

172
src/App/AuthService.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App;
final class AuthService
{
private const SESSION_TTL = 604800;
public function __construct(private App $app) {}
public function isEnabled(): bool
{
return $this->app->config()->authEnabled;
}
public function start(): void
{
$this->app->session()->start();
}
public function user(): ?array
{
$this->start();
$user = $_SESSION['auth_user'] ?? null;
if (!is_array($user)) {
return null;
}
$expiresAt = (int) ($_SESSION['auth_expires_at'] ?? 0);
if ($expiresAt > 0 && $expiresAt < time()) {
$this->clearLocalSession();
return null;
}
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
return $user;
}
public function isAuthenticated(): bool
{
return $this->user() !== null;
}
public function login(string $returnTo = '/'): void
{
if (!$this->isEnabled()) {
redirect($returnTo !== '' ? $returnTo : '/');
}
$this->start();
$_SESSION['oidc_return_to'] = $this->safeReturnTo($returnTo);
redirect('/auth/login');
}
public function callback(): void
{
$query = (string) ($_SERVER['QUERY_STRING'] ?? '');
redirect('/auth/callback' . ($query !== '' ? '?' . $query : ''));
}
public function logout(): void
{
redirect('/auth/logout');
}
public function storeUser(array $claims, array $groups, string $idToken): void
{
$username = (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? '');
$_SESSION['auth_user'] = [
'sub' => (string) ($claims['sub'] ?? ''),
'username' => $username,
'email' => (string) ($claims['email'] ?? ''),
'name' => (string) ($claims['name'] ?? $username),
'groups' => $groups,
'id_token' => $idToken,
];
$_SESSION['auth_id_token'] = $idToken;
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
}
public function canAccessModule(array $module): bool
{
$auth = is_array($module['auth'] ?? null) ? $module['auth'] : [];
$required = (bool) ($auth['required'] ?? false);
if (!$required || !$this->isEnabled()) {
return true;
}
$user = $this->user();
if ($user === null) {
return false;
}
$allowedUsers = $this->normalizeList($auth['users'] ?? []);
$allowedGroups = $this->normalizeList($auth['groups'] ?? []);
if ($allowedUsers === [] && $allowedGroups === []) {
return true;
}
$username = strtolower((string) ($user['username'] ?? ''));
$email = strtolower((string) ($user['email'] ?? ''));
$sub = strtolower((string) ($user['sub'] ?? ''));
foreach ($allowedUsers as $allowedUser) {
if ($allowedUser === $username || ($email !== '' && $allowedUser === $email) || ($sub !== '' && $allowedUser === $sub)) {
return true;
}
}
$userGroups = $this->normalizeList($user['groups'] ?? []);
return array_intersect($allowedGroups, $userGroups) !== [];
}
public function requireModuleAccess(array $module): void
{
if ($this->canAccessModule($module)) {
return;
}
if (!$this->isAuthenticated()) {
$this->login($this->currentPath());
}
http_response_code(403);
exit('Forbidden');
}
public function filterModules(array $modules): array
{
return array_values(array_filter($modules, fn (array $module): bool => $this->canAccessModule($module)));
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = strtolower(trim((string) $value));
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
private function clearLocalSession(): void
{
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
}
private function currentPath(): string
{
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
return $this->safeReturnTo($uri);
}
private function safeReturnTo(string $returnTo): string
{
$returnTo = trim($returnTo);
if ($returnTo === '' || !str_starts_with($returnTo, '/') || str_starts_with($returnTo, '//')) {
return '/';
}
return $returnTo;
}
}

View File

@@ -26,6 +26,7 @@ class Config
public string $oidcAdminGroup;
public string $oidcUserGroup;
public string $modulesPath;
public array $dbConfig;
public function __construct(
public array $db,
@@ -40,17 +41,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->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : '';
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : 'groups';
$this->dbConfig = $baseDbEnabled && !empty($baseDb) ? $baseDb : $db;
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : (defined('KEYCLOAK_ENABLED') ? (bool)KEYCLOAK_ENABLED : false);
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : (defined('KEYCLOAK_ISSUER') ? (string)KEYCLOAK_ISSUER : '');
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : (defined('KEYCLOAK_CLIENT_ID') ? (string)KEYCLOAK_CLIENT_ID : '');
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : (defined('KEYCLOAK_CLIENT_SECRET') ? (string)KEYCLOAK_CLIENT_SECRET : '');
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : (defined('KEYCLOAK_REDIRECT_URI') ? (string)KEYCLOAK_REDIRECT_URI : '');
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : (defined('KEYCLOAK_AUTH_ENDPOINT') ? (string)KEYCLOAK_AUTH_ENDPOINT : '');
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : (defined('KEYCLOAK_TOKEN_ENDPOINT') ? (string)KEYCLOAK_TOKEN_ENDPOINT : '');
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : (defined('KEYCLOAK_USERINFO_ENDPOINT') ? (string)KEYCLOAK_USERINFO_ENDPOINT : '');
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : (defined('KEYCLOAK_LOGOUT_ENDPOINT') ? (string)KEYCLOAK_LOGOUT_ENDPOINT : '');
$this->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : (defined('KEYCLOAK_POST_LOGOUT_REDIRECT_URI') ? (string)KEYCLOAK_POST_LOGOUT_REDIRECT_URI : '');
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : (defined('KEYCLOAK_GROUP_CLAIM') ? (string)KEYCLOAK_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';
$this->modulesPath = defined('APP_MODULES_PATH') ? (string)APP_MODULES_PATH : '';

View File

@@ -245,7 +245,8 @@ final class ModuleManager
$module = [
'name' => $name,
'title' => $data['title'] ?? $name,
'slug' => $name,
'title' => $data['title'] ?? $data['name'] ?? $name,
'version' => $data['version'] ?? '',
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
@@ -253,6 +254,9 @@ final class ModuleManager
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
'path' => $dir,
'entry' => '/module/' . rawurlencode($name),
'auth' => is_array($data['auth'] ?? null) ? $data['auth'] : ['required' => false, 'users' => [], 'groups' => []],
'enabled_by_default' => (bool)($data['enabled_by_default'] ?? false),
'enabled' => false,
];
@@ -268,7 +272,7 @@ final class ModuleManager
private function loadEnabledState(string $name, array $module): bool
{
if (!$this->basePdo) {
return false;
return (bool)($module['enabled_by_default'] ?? false);
}
$stmt = $this->basePdo->prepare(
@@ -287,8 +291,64 @@ final class ModuleManager
$stmt->bindValue(':name', $name, \PDO::PARAM_STR);
$stmt->bindValue(':title', (string)$module['title'], \PDO::PARAM_STR);
$stmt->bindValue(':version', (string)$module['version'], \PDO::PARAM_STR);
$stmt->bindValue(':enabled', false, \PDO::PARAM_BOOL);
$enabledByDefault = (bool)($module['enabled_by_default'] ?? false);
$stmt->bindValue(':enabled', $enabledByDefault, \PDO::PARAM_BOOL);
$stmt->execute();
return false;
return $enabledByDefault;
}
public function saveAuth(string $name, array $auth): array
{
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
throw new \RuntimeException('Invalid module name.');
}
$module = $this->get($name);
if (!$module) {
throw new \RuntimeException('Module not found.');
}
$manifest = $module['path'] . '/module.json';
$raw = is_file($manifest) ? file_get_contents($manifest) : '';
$data = $raw ? json_decode($raw, true) : [];
if (!is_array($data)) {
$data = [];
}
$data['auth'] = [
'required' => (bool) ($auth['required'] ?? false),
'users' => $this->normalizeList($auth['users'] ?? []),
'groups' => $this->normalizeList($auth['groups'] ?? []),
];
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($json)) {
throw new \RuntimeException('Could not encode module metadata.');
}
file_put_contents($manifest, $json . PHP_EOL, LOCK_EX);
$this->scanModules();
return $data['auth'];
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = trim((string) $value);
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
}

View File

@@ -12,7 +12,7 @@ final class OidcClient
$params = [
'client_id' => $this->config->oidcClientId,
'response_type' => 'code',
'scope' => 'openid profile email',
'scope' => 'openid profile email groups',
'redirect_uri' => $this->config->oidcRedirectUri,
'state' => $state,
'nonce' => $nonce,

View File

@@ -5,6 +5,8 @@ namespace App;
final class SessionManager
{
private const SESSION_TTL = 604800;
private string $sessionCookieName;
private string $clientCookieName;
@@ -30,15 +32,16 @@ final class SessionManager
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
session_set_cookie_params([
'lifetime' => 0,
'lifetime' => self::SESSION_TTL,
'path' => '/',
'domain' => $this->config->cookieDomain(),
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$this->extendSessionCookie($secure);
}
public function ensureClientId(int $lifetimeSeconds = 31536000): string
@@ -57,7 +60,7 @@ final class SessionManager
setcookie($this->clientCookieName, $id, [
'expires' => time() + $lifetimeSeconds,
'path' => '/',
'domain' => $this->config->cookieDomain(),
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => false, // accessible to JS if needed
'samesite' => 'Lax',
@@ -68,4 +71,22 @@ final class SessionManager
return $id;
}
private function extendSessionCookie(bool $secure): void
{
$name = session_name();
$value = session_id();
if ($name === '' || $value === '') {
return;
}
setcookie($name, $value, [
'expires' => time() + self::SESSION_TTL,
'path' => '/',
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
}

View File

@@ -32,7 +32,11 @@ function user_theme(): string
$stmt->execute(['id' => $clientId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$theme = is_array($row) ? (string)($row['theme'] ?? '') : '';
return $theme !== '' ? $theme : 'light';
return match ($theme) {
'dark', 'night' => 'night',
'light', 'day', '' => 'day',
default => $theme,
};
}
function set_user_theme(string $theme): void
@@ -64,14 +68,12 @@ function current_module_name(): ?string
function auth_enabled(): bool
{
return app()->config()->authEnabled;
return app()->auth()->isEnabled();
}
function auth_user(): ?array
{
$session = app()->session();
$session->start();
return $_SESSION['auth_user'] ?? null;
return app()->auth()->user();
}
function auth_display_name(): string