Nexus upgrade design and refresh
This commit is contained in:
@@ -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
172
src/App/AuthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user