keycloak
All checks were successful
Deploy / deploy-staging (push) Successful in 46s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-08 02:45:36 +02:00
parent 6d398323ae
commit 1ae7b98378
14 changed files with 1036 additions and 4 deletions

View File

@@ -30,6 +30,11 @@ final class App
$apps = AppIconResolver::decorate($registry->all(), $activeSkin);
$windows = (new WindowManager($registry))->defaultWindows();
$activeWidgetIds = DesktopState::defaultWidgetIds();
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
$authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null)
? $_SESSION['desktop_auth']['user']
: [];
$displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast');
return [
'meta' => [
@@ -45,9 +50,14 @@ final class App
'storage_scope' => 'guest',
'workspace' => DesktopState::defaultWorkspace(),
'login' => [
'authenticated' => false,
'mode' => 'public-home',
'authenticated' => $isAuthenticated,
'mode' => $isAuthenticated ? 'authenticated' : 'login-required',
'keycloak_theme_handoff' => '/docs/keycloak-theme-handoff.md',
'user' => [
'name' => (string) ($authUser['name'] ?? ''),
'username' => (string) ($authUser['username'] ?? ''),
'email' => (string) ($authUser['email'] ?? ''),
],
],
],
'apps' => $apps,
@@ -58,6 +68,9 @@ final class App
'active_ids' => $activeWidgetIds,
],
'tray' => DesktopState::trayItems(),
'session' => [
'display_name' => $displayName,
],
'menu' => [
'quick_actions' => DesktopState::quickActions(),
],

48
src/App/ConfigLoader.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App;
final class ConfigLoader
{
/**
* @return array<string, mixed>
*/
public static function load(string $projectRoot, string $name): array
{
$basePath = $projectRoot . '/config/' . $name . '.php';
$config = is_file($basePath) ? require $basePath : [];
$appEnv = getenv('APP_ENV') ?: 'local';
$envPath = $projectRoot . '/config/' . $appEnv . '/' . $name . '.php';
if (!is_file($envPath)) {
return $config;
}
/** @var array<string, mixed> $envConfig */
$envConfig = require $envPath;
return self::merge($config, $envConfig);
}
/**
* @param array<string, mixed> $base
* @param array<string, mixed> $override
* @return array<string, mixed>
*/
private static function merge(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
$base[$key] = self::merge($base[$key], $value);
continue;
}
$base[$key] = $value;
}
return $base;
}
}

407
src/App/KeycloakAuth.php Normal file
View File

@@ -0,0 +1,407 @@
<?php
declare(strict_types=1);
namespace App;
final class KeycloakAuth
{
/**
* @param array<string, mixed> $config
*/
public function __construct(
private readonly array $config,
) {
}
public function isEnabled(): bool
{
return (bool) ($this->config['enabled'] ?? false);
}
public function enforceLogin(): bool
{
return $this->isEnabled() && (bool) ($this->config['enforce_login'] ?? false);
}
public function allowDesktopPreview(): bool
{
return (bool) ($this->config['allow_desktop_preview'] ?? false);
}
public function previewQueryKey(): string
{
return (string) ($this->config['desktop_preview_query_key'] ?? 'preview-desktop');
}
public function isConfigured(): bool
{
return $this->baseUrl() !== ''
&& $this->realm() !== ''
&& $this->clientId() !== ''
&& $this->clientSecret() !== '';
}
public function isAuthenticated(): bool
{
return isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);
}
public function shouldShowDesktop(): bool
{
if (!$this->enforceLogin()) {
return true;
}
if ($this->isAuthenticated()) {
return true;
}
$previewKey = $this->previewQueryKey();
return $this->allowDesktopPreview() && isset($_GET[$previewKey]);
}
public function loginUrl(): ?string
{
if (!$this->isConfigured()) {
return null;
}
$state = bin2hex(random_bytes(16));
$_SESSION['keycloak_oauth_state'] = $state;
$params = array_merge(
[
'client_id' => $this->clientId(),
'redirect_uri' => $this->redirectUri(),
'response_type' => 'code',
'scope' => implode(' ', $this->scopes()),
'state' => $state,
],
is_array($this->config['extra_authorize_params'] ?? null)
? $this->config['extra_authorize_params']
: []
);
return rtrim($this->baseUrl(), '/')
. '/realms/' . rawurlencode($this->realm())
. '/protocol/openid-connect/auth?' . http_build_query($params);
}
public function logout(): void
{
unset($_SESSION['desktop_auth'], $_SESSION['keycloak_oauth_state']);
}
/**
* @return array{success: bool, data?: array<string, mixed>, error?: string}
*/
public function exchangeAuthorizationCode(string $code): array
{
if (!$this->isConfigured()) {
return [
'success' => false,
'error' => 'Keycloak ist noch nicht vollstaendig konfiguriert.',
];
}
$response = $this->postForm(
$this->tokenEndpoint(),
[
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => $this->clientId(),
'client_secret' => $this->clientSecret(),
'redirect_uri' => $this->redirectUri(),
]
);
if ($response['success'] !== true) {
return [
'success' => false,
'error' => $response['error'] ?? 'Token-Austausch mit Keycloak fehlgeschlagen.',
];
}
/** @var array<string, mixed> $payload */
$payload = $response['data'];
return [
'success' => true,
'data' => $payload,
];
}
/**
* @return array{success: bool, data?: array<string, mixed>, error?: string}
*/
public function fetchUserInfo(string $accessToken): array
{
$response = $this->getJson(
$this->userInfoEndpoint(),
[
'Authorization: Bearer ' . $accessToken,
]
);
if ($response['success'] !== true) {
return [
'success' => false,
'error' => $response['error'] ?? 'Userinfo-Abruf bei Keycloak fehlgeschlagen.',
];
}
/** @var array<string, mixed> $payload */
$payload = $response['data'];
return [
'success' => true,
'data' => $payload,
];
}
/**
* @param array<string, mixed> $tokenPayload
* @param array<string, mixed> $userInfo
*/
public function establishSession(array $tokenPayload, array $userInfo): void
{
$_SESSION['desktop_auth'] = [
'authenticated_at' => time(),
'access_token' => (string) ($tokenPayload['access_token'] ?? ''),
'refresh_token' => (string) ($tokenPayload['refresh_token'] ?? ''),
'id_token' => (string) ($tokenPayload['id_token'] ?? ''),
'expires_in' => (int) ($tokenPayload['expires_in'] ?? 0),
'refresh_expires_in' => (int) ($tokenPayload['refresh_expires_in'] ?? 0),
'token_type' => (string) ($tokenPayload['token_type'] ?? 'Bearer'),
'scope' => (string) ($tokenPayload['scope'] ?? ''),
'user' => [
'sub' => (string) ($userInfo['sub'] ?? ''),
'username' => (string) ($userInfo['preferred_username'] ?? ''),
'name' => (string) ($userInfo['name'] ?? ''),
'email' => (string) ($userInfo['email'] ?? ''),
],
];
}
public function logoutUrl(): string
{
$params = [
'client_id' => $this->clientId(),
'post_logout_redirect_uri' => $this->postLogoutRedirectUri(),
];
$idToken = (string) ($_SESSION['desktop_auth']['id_token'] ?? '');
if ($idToken !== '') {
$params['id_token_hint'] = $idToken;
}
return rtrim($this->baseUrl(), '/')
. '/realms/' . rawurlencode($this->realm())
. '/protocol/openid-connect/logout?' . http_build_query($params);
}
/**
* @return array<string, mixed>
*/
public function branding(): array
{
return is_array($this->config['branding'] ?? null) ? $this->config['branding'] : [];
}
public function redirectUri(): string
{
$path = (string) ($this->config['redirect_path'] ?? '/auth/callback');
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
return $scheme . '://' . $host . $path;
}
public function baseUrl(): string
{
return trim((string) ($this->config['base_url'] ?? ''));
}
public function realm(): string
{
return trim((string) ($this->config['realm'] ?? ''));
}
public function clientId(): string
{
return trim((string) ($this->config['client_id'] ?? ''));
}
public function clientSecret(): string
{
return trim((string) ($this->config['client_secret'] ?? ''));
}
/**
* @return array<int, string>
*/
public function scopes(): array
{
$scopes = $this->config['scopes'] ?? ['openid'];
return is_array($scopes) ? array_values(array_map('strval', $scopes)) : ['openid'];
}
private function tokenEndpoint(): string
{
return rtrim($this->baseUrl(), '/')
. '/realms/' . rawurlencode($this->realm())
. '/protocol/openid-connect/token';
}
private function userInfoEndpoint(): string
{
return rtrim($this->baseUrl(), '/')
. '/realms/' . rawurlencode($this->realm())
. '/protocol/openid-connect/userinfo';
}
private function postLogoutRedirectUri(): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = (string) ($_SERVER['HTTP_HOST'] ?? 'localhost');
return $scheme . '://' . $host . '/auth/login';
}
/**
* @param array<string, string> $fields
* @return array{success: bool, data?: array<string, mixed>, error?: string}
*/
private function postForm(string $url, array $fields): array
{
return $this->requestJson(
$url,
[
'method' => 'POST',
'headers' => [
'Content-Type: application/x-www-form-urlencoded',
],
'body' => http_build_query($fields),
]
);
}
/**
* @param array<int, string> $headers
* @return array{success: bool, data?: array<string, mixed>, error?: string}
*/
private function getJson(string $url, array $headers = []): array
{
return $this->requestJson(
$url,
[
'method' => 'GET',
'headers' => $headers,
'body' => null,
]
);
}
/**
* @param array{method: string, headers: array<int, string>, body: ?string} $request
* @return array{success: bool, data?: array<string, mixed>, error?: string}
*/
private function requestJson(string $url, array $request): array
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch === false) {
return [
'success' => false,
'error' => 'cURL konnte nicht initialisiert werden.',
];
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $request['method'],
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $request['headers']),
CURLOPT_TIMEOUT => 20,
]);
if ($request['body'] !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $request['body']);
}
$raw = curl_exec($ch);
$curlError = curl_error($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false) {
return [
'success' => false,
'error' => 'Keycloak-Request fehlgeschlagen: ' . $curlError,
];
}
return $this->decodeJsonResponse($raw, $statusCode);
}
$context = stream_context_create([
'http' => [
'method' => $request['method'],
'header' => implode("\r\n", array_merge(['Accept: application/json'], $request['headers'])),
'content' => $request['body'] ?? '',
'timeout' => 20,
'ignore_errors' => true,
],
]);
$raw = @file_get_contents($url, false, $context);
if ($raw === false) {
return [
'success' => false,
'error' => 'Keycloak-Request fehlgeschlagen.',
];
}
$statusCode = 200;
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $matches) === 1) {
$statusCode = (int) $matches[1];
}
return $this->decodeJsonResponse($raw, $statusCode);
}
/**
* @return array{success: bool, data?: array<string, mixed>, error?: string}
*/
private function decodeJsonResponse(string $raw, int $statusCode): array
{
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [
'success' => false,
'error' => 'Antwort von Keycloak ist kein gueltiges JSON.',
];
}
if ($statusCode >= 400) {
$message = (string) ($decoded['error_description'] ?? $decoded['error'] ?? ('HTTP ' . $statusCode));
return [
'success' => false,
'error' => $message,
];
}
return [
'success' => true,
'data' => $decoded,
];
}
}