keycloak
This commit is contained in:
22
config/keycloak.php
Normal file
22
config/keycloak.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'enforce_login' => true,
|
||||
'allow_desktop_preview' => false,
|
||||
'desktop_preview_query_key' => 'preview-desktop',
|
||||
'base_url' => 'https://auth.kusche.berlin',
|
||||
'realm' => 'KuscheBerlin',
|
||||
'client_id' => 'desktop-kusche-berlin',
|
||||
'client_secret' => 'aeBX6f65E0RlNfksHldekcWgP37JDLRw',
|
||||
'redirect_path' => '/auth/callback',
|
||||
'scopes' => ['openid', 'profile', 'email'],
|
||||
'extra_authorize_params' => [],
|
||||
'branding' => [
|
||||
'product_name' => 'Kusche.Berlin',
|
||||
'headline' => 'Desktop Login',
|
||||
'subheadline' => 'Geschuetzter Zugang zur Desktop-Shell und kuenftigen Modulen.',
|
||||
],
|
||||
];
|
||||
@@ -19,12 +19,25 @@ Diese Datei beschreibt die Uebergabepunkte zwischen der Desktop-Shell und dem sp
|
||||
## Technische Schnittstellen
|
||||
|
||||
- Desktop-Shell verweist auf die Login-App `desktop-login`.
|
||||
- Root `/` kann jetzt vor der Desktop-Shell auf einen eigenen Login-Screen umleiten.
|
||||
- Login-Handoff zu Keycloak laeuft ueber `/auth/keycloak`.
|
||||
- Callback-Endpunkt ist unter `/auth/callback` vorbereitet.
|
||||
- Keycloak-Theme liegt spaeter getrennt im Keycloak-Theme-System.
|
||||
- Gemeinsame Branding-Werte sollten aus einer zentralen Theme-Definition abgeleitet werden.
|
||||
|
||||
## Aktueller Stand im Projekt
|
||||
|
||||
- `config/keycloak.php` enthaelt die Grundkonfiguration.
|
||||
- `config/<env>/keycloak.php` kann die Werte je Umgebung ueberschreiben.
|
||||
- `public/auth/login/` rendert den Desktop-Login-Screen.
|
||||
- `public/auth/keycloak/` leitet auf die Keycloak-Authorize-URL weiter.
|
||||
- `public/auth/callback/` prueft den Ruecksprungzustand, tauscht den Authorization Code gegen Token und legt die Session an.
|
||||
- `public/auth/logout/` beendet die lokale Session und kann Keycloak-Logout anstossen.
|
||||
|
||||
## Zu erledigen ausserhalb dieser V1
|
||||
|
||||
- Keycloak-Theme erzeugen
|
||||
- Theme dem passenden Client oder Realm zuweisen
|
||||
- Login-Templates, CSS und Assets im Theme pflegen
|
||||
- Session-Validierung, Refresh und spaetere Token-Erneuerung sauber nachziehen
|
||||
- Ruecksprung aus Keycloak sauber in die Desktop-Shell fuehren
|
||||
|
||||
54
partials/auth/login_screen.php
Normal file
54
partials/auth/login_screen.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$productName = (string) ($loginScreen['branding']['product_name'] ?? 'Kusche.Berlin');
|
||||
$headline = (string) ($loginScreen['branding']['headline'] ?? 'Desktop Login');
|
||||
$subheadline = (string) ($loginScreen['branding']['subheadline'] ?? '');
|
||||
$loginUrl = $loginScreen['login_url'] ?? null;
|
||||
$previewUrl = $loginScreen['preview_url'] ?? null;
|
||||
$keycloakReady = (bool) ($loginScreen['keycloak_ready'] ?? false);
|
||||
$callbackUrl = (string) ($loginScreen['callback_url'] ?? '');
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($headline, ENT_QUOTES) ?></title>
|
||||
<link rel="stylesheet" href="/assets/auth/login.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div class="login-panel-top">
|
||||
<p class="login-kicker"><?= htmlspecialchars($productName, ENT_QUOTES) ?></p>
|
||||
<h1><?= htmlspecialchars($headline, ENT_QUOTES) ?></h1>
|
||||
<p class="login-copy"><?= htmlspecialchars($subheadline, ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="login-actions">
|
||||
<?php if ($loginUrl !== null): ?>
|
||||
<a class="login-button login-button-primary" href="<?= htmlspecialchars($loginUrl, ENT_QUOTES) ?>">Mit Keycloak anmelden</a>
|
||||
<?php else: ?>
|
||||
<div class="login-button login-button-disabled">Keycloak noch nicht konfiguriert</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($previewUrl !== null): ?>
|
||||
<a class="login-button login-button-secondary" href="<?= htmlspecialchars($previewUrl, ENT_QUOTES) ?>">Desktop-Vorschau</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<dl class="login-meta">
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd><?= $keycloakReady ? 'Keycloak bereit' : 'Konfiguration fehlt' ?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Callback</dt>
|
||||
<dd><?= htmlspecialchars($callbackUrl, ENT_QUOTES) ?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
116
public/assets/auth/login.css
Normal file
116
public/assets/auth/login.css
Normal file
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: #f8fafc;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(255, 222, 173, 0.28), transparent 28%),
|
||||
linear-gradient(145deg, #101827 0%, #18253d 52%, #0f172a 100%);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(480px, 100%);
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.42);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.login-panel-top {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-kicker {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 36px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.login-copy {
|
||||
margin: 0;
|
||||
color: #dbe5f1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-button-primary {
|
||||
color: #082032;
|
||||
background: linear-gradient(180deg, #a7f3d0, #6ee7b7);
|
||||
}
|
||||
|
||||
.login-button-secondary {
|
||||
color: #f8fafc;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.login-button-disabled {
|
||||
color: #cbd5e1;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.login-meta {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-meta div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.login-meta dt {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.login-meta dd {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -45,6 +45,20 @@ if (payloadNode) {
|
||||
return `<span class="app-icon-fallback${safeClassName}">${escapeHtml(app.icon || 'AP')}</span>`;
|
||||
};
|
||||
|
||||
const updateSessionDisplay = () => {
|
||||
const accountNode = document.querySelector('.tray-pill:last-of-type');
|
||||
|
||||
if (!accountNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayName = payload.session?.display_name;
|
||||
|
||||
if (typeof displayName === 'string' && displayName.trim() !== '') {
|
||||
accountNode.textContent = displayName;
|
||||
}
|
||||
};
|
||||
|
||||
const buildTaskbarButtonContent = (record) => `
|
||||
<span class="taskbar-app-button-badge">${buildAppIconMarkup(record.definition, 'taskbar-app-icon')}</span>
|
||||
<span class="taskbar-app-button-label">${escapeHtml(record.title)}</span>
|
||||
@@ -702,6 +716,8 @@ if (payloadNode) {
|
||||
iconsRoot?.appendChild(icon);
|
||||
});
|
||||
|
||||
updateSessionDisplay();
|
||||
|
||||
startButton?.setAttribute('aria-expanded', 'false');
|
||||
startButton?.addEventListener('click', () => {
|
||||
if (!startMenu || !startButton) {
|
||||
|
||||
82
public/auth/callback/index.php
Normal file
82
public/auth/callback/index.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$state = (string) ($_GET['state'] ?? '');
|
||||
$expectedState = (string) ($_SESSION['keycloak_oauth_state'] ?? '');
|
||||
$code = (string) ($_GET['code'] ?? '');
|
||||
|
||||
$title = 'Keycloak Callback';
|
||||
$message = 'Anmeldung wird verarbeitet.';
|
||||
$redirectTarget = '/';
|
||||
|
||||
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
|
||||
http_response_code(400);
|
||||
$message = 'State-Pruefung fehlgeschlagen. Bitte den Login erneut starten.';
|
||||
} elseif ($code === '') {
|
||||
http_response_code(400);
|
||||
$message = 'Kein Authorization Code von Keycloak erhalten.';
|
||||
} else {
|
||||
unset($_SESSION['keycloak_oauth_state']);
|
||||
|
||||
$tokenExchange = $auth->exchangeAuthorizationCode($code);
|
||||
|
||||
if (($tokenExchange['success'] ?? false) !== true) {
|
||||
http_response_code(502);
|
||||
$message = (string) ($tokenExchange['error'] ?? 'Token-Austausch mit Keycloak fehlgeschlagen.');
|
||||
} else {
|
||||
/** @var array<string, mixed> $tokenPayload */
|
||||
$tokenPayload = $tokenExchange['data'];
|
||||
$accessToken = (string) ($tokenPayload['access_token'] ?? '');
|
||||
|
||||
if ($accessToken === '') {
|
||||
http_response_code(502);
|
||||
$message = 'Keycloak hat kein Access Token geliefert.';
|
||||
} else {
|
||||
$userInfo = $auth->fetchUserInfo($accessToken);
|
||||
|
||||
if (($userInfo['success'] ?? false) !== true) {
|
||||
http_response_code(502);
|
||||
$message = (string) ($userInfo['error'] ?? 'Userinfo-Abruf fehlgeschlagen.');
|
||||
} else {
|
||||
/** @var array<string, mixed> $userPayload */
|
||||
$userPayload = $userInfo['data'];
|
||||
$auth->establishSession($tokenPayload, $userPayload);
|
||||
header('Location: ' . $redirectTarget, true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= htmlspecialchars($title, ENT_QUOTES) ?></title>
|
||||
<link rel="stylesheet" href="/assets/auth/login.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div class="login-panel-top">
|
||||
<p class="login-kicker">Kusche.Berlin</p>
|
||||
<h1><?= htmlspecialchars($title, ENT_QUOTES) ?></h1>
|
||||
<p class="login-copy"><?= htmlspecialchars($message, ENT_QUOTES) ?></p>
|
||||
</div>
|
||||
<div class="login-actions">
|
||||
<a class="login-button login-button-secondary" href="/auth/login">Zurueck zum Login</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
22
public/auth/keycloak/index.php
Normal file
22
public/auth/keycloak/index.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$loginUrl = $auth->loginUrl();
|
||||
|
||||
if ($loginUrl === null) {
|
||||
header('Location: /auth/login');
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Location: ' . $loginUrl, true, 302);
|
||||
exit;
|
||||
26
public/auth/login/index.php
Normal file
26
public/auth/login/index.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$previewUrl = $auth->allowDesktopPreview()
|
||||
? '/?' . urlencode($auth->previewQueryKey()) . '=1'
|
||||
: null;
|
||||
|
||||
$loginScreen = [
|
||||
'branding' => $auth->branding(),
|
||||
'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null,
|
||||
'preview_url' => $previewUrl,
|
||||
'keycloak_ready' => $auth->isConfigured(),
|
||||
'callback_url' => $auth->redirectUri(),
|
||||
];
|
||||
|
||||
require $projectRoot . '/partials/auth/login_screen.php';
|
||||
28
public/auth/logout/index.php
Normal file
28
public/auth/logout/index.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
$logoutUrl = null;
|
||||
|
||||
if ($auth->isConfigured() && $auth->isAuthenticated()) {
|
||||
$logoutUrl = $auth->logoutUrl();
|
||||
}
|
||||
|
||||
$auth->logout();
|
||||
|
||||
if ($logoutUrl !== null) {
|
||||
header('Location: ' . $logoutUrl, true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Location: /auth/login', true, 302);
|
||||
exit;
|
||||
@@ -5,8 +5,35 @@ declare(strict_types=1);
|
||||
require_once dirname(__DIR__) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\App;
|
||||
use App\ConfigLoader;
|
||||
use App\KeycloakAuth;
|
||||
|
||||
$app = new App(dirname(__DIR__));
|
||||
session_start();
|
||||
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak');
|
||||
$auth = new KeycloakAuth($keycloakConfig);
|
||||
|
||||
if (!$auth->shouldShowDesktop()) {
|
||||
$previewUrl = null;
|
||||
|
||||
if ($auth->allowDesktopPreview()) {
|
||||
$previewUrl = '/?' . urlencode($auth->previewQueryKey()) . '=1';
|
||||
}
|
||||
|
||||
$loginScreen = [
|
||||
'branding' => $auth->branding(),
|
||||
'login_url' => $auth->isConfigured() ? '/auth/keycloak' : null,
|
||||
'preview_url' => $previewUrl,
|
||||
'keycloak_ready' => $auth->isConfigured(),
|
||||
'callback_url' => $auth->redirectUri(),
|
||||
];
|
||||
|
||||
require $projectRoot . '/partials/auth/login_screen.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$app = new App($projectRoot);
|
||||
$desktopPayload = $app->desktopPayload();
|
||||
|
||||
require dirname(__DIR__) . '/partials/desktop/shell.php';
|
||||
require $projectRoot . '/partials/desktop/shell.php';
|
||||
|
||||
@@ -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
48
src/App/ConfigLoader.php
Normal 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
407
src/App/KeycloakAuth.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
158
temp/KEYCLOAK_CLIENT_ANLEGEN.md
Normal file
158
temp/KEYCLOAK_CLIENT_ANLEGEN.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Keycloak Client Anlegen
|
||||
|
||||
Diese Schritte legen den Client fuer `desktop.kusche.berlin` in Keycloak an.
|
||||
|
||||
## Ziel
|
||||
|
||||
Wir brauchen einen eigenen OpenID-Client fuer die Desktop-Shell, damit:
|
||||
|
||||
- der Login ueber Keycloak laeuft
|
||||
- der Ruecksprung sauber auf `desktop.kusche.berlin` erfolgt
|
||||
- spaeter ein eigenes Login-Theme fuer diesen Client genutzt werden kann
|
||||
|
||||
## Vorab benoetigte Daten
|
||||
|
||||
Vor dem Anlegen solltest du diese Werte kennen:
|
||||
|
||||
- Keycloak Basis-URL
|
||||
- Beispiel: `https://auth.kusche.berlin`
|
||||
- Realm-Name
|
||||
- Beispiel: `kusche-berlin`
|
||||
- Staging-Domain
|
||||
- Beispiel: `https://desktop-staging.kusche.berlin`
|
||||
- Live-Domain
|
||||
- Beispiel: `https://desktop.kusche.berlin`
|
||||
|
||||
## Client anlegen
|
||||
|
||||
1. In Keycloak einloggen.
|
||||
2. Den passenden Realm auswaehlen.
|
||||
3. `Clients` aufrufen.
|
||||
4. `Create client` klicken.
|
||||
|
||||
## Grunddaten
|
||||
|
||||
Diese Werte setzen:
|
||||
|
||||
- `Client type`: `OpenID Connect`
|
||||
- `Client ID`: `desktop-kusche-berlin`
|
||||
- `Name`: `Desktop Kusche Berlin`
|
||||
- `Description`: `Desktop Shell Login fuer Kusche.Berlin`
|
||||
|
||||
Dann `Next`.
|
||||
|
||||
## Capability config
|
||||
|
||||
Diese Werte setzen:
|
||||
|
||||
- `Client authentication`: `On`
|
||||
- `Authorization`: `Off`
|
||||
- `Standard flow`: `On`
|
||||
- `Direct access grants`: `Off`
|
||||
- `Implicit flow`: `Off`
|
||||
- `Service accounts roles`: `Off`
|
||||
|
||||
Dann `Next`.
|
||||
|
||||
## Login config
|
||||
|
||||
Diese Werte setzen:
|
||||
|
||||
- `Root URL`:
|
||||
- Staging zuerst: `https://desktop-staging.kusche.berlin`
|
||||
- `Home URL`:
|
||||
- `https://desktop-staging.kusche.berlin/`
|
||||
- `Valid redirect URIs`:
|
||||
- `https://desktop-staging.kusche.berlin/auth/callback`
|
||||
- spaeter zusaetzlich live:
|
||||
- `https://desktop.kusche.berlin/auth/callback`
|
||||
- `Valid post logout redirect URIs`:
|
||||
- `https://desktop-staging.kusche.berlin/`
|
||||
- spaeter zusaetzlich live:
|
||||
- `https://desktop.kusche.berlin/`
|
||||
- `Web origins`:
|
||||
- `https://desktop-staging.kusche.berlin`
|
||||
- spaeter zusaetzlich live:
|
||||
- `https://desktop.kusche.berlin`
|
||||
|
||||
Dann `Save`.
|
||||
|
||||
## Client Secret holen
|
||||
|
||||
Nach dem Speichern:
|
||||
|
||||
1. Den neuen Client oeffnen.
|
||||
2. Auf `Credentials` gehen.
|
||||
3. Das generierte Secret kopieren.
|
||||
|
||||
Dieses Secret bitte noch nicht ins Repo schreiben.
|
||||
|
||||
## Optional aber sinnvoll
|
||||
|
||||
Diese Einstellungen danach noch pruefen:
|
||||
|
||||
- `Login theme`
|
||||
- spaeter auf das eigene Desktop-Theme setzen
|
||||
- `Consent required`
|
||||
- `Off`
|
||||
- `Display on consent screen`
|
||||
- `Off`
|
||||
|
||||
## Werte fuer unser Projekt
|
||||
|
||||
Nach dem Anlegen brauchen wir diese finalen Werte:
|
||||
|
||||
- `base_url`
|
||||
- Beispiel: `https://auth.kusche.berlin`
|
||||
- `realm`
|
||||
- Beispiel: `kusche-berlin`
|
||||
- `client_id`
|
||||
- `desktop-kusche-berlin`
|
||||
- `client_secret`
|
||||
- aus `Credentials`
|
||||
|
||||
## Danach im Projekt eintragen
|
||||
|
||||
Die Werte kommen danach in eine Umgebungsdatei, nicht in die allgemeine Basisdatei.
|
||||
|
||||
Geplant ist:
|
||||
|
||||
- `config/staging/keycloak.php`
|
||||
- `config/prod/keycloak.php`
|
||||
|
||||
Beispielstruktur:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'enabled' => true,
|
||||
'enforce_login' => true,
|
||||
'allow_desktop_preview' => false,
|
||||
'base_url' => 'https://auth.kusche.berlin',
|
||||
'realm' => 'kusche-berlin',
|
||||
'client_id' => 'desktop-kusche-berlin',
|
||||
'client_secret' => 'HIER_DAS_SECRET',
|
||||
'redirect_path' => '/auth/callback',
|
||||
'scopes' => ['openid', 'profile', 'email'],
|
||||
];
|
||||
```
|
||||
|
||||
## Wichtig
|
||||
|
||||
- `client_secret` gehoert nicht in `config/keycloak.php`
|
||||
- nur in `config/staging/keycloak.php` bzw. `config/prod/keycloak.php`
|
||||
- wenn Staging und Live unterschiedliche Keycloak-Clients bekommen sollen, dann je Umgebung eigene `client_id` und eigenes Secret nutzen
|
||||
|
||||
## Rueckmeldung an Codex
|
||||
|
||||
Wenn der Client angelegt ist, brauche ich von dir nur:
|
||||
|
||||
- `base_url`
|
||||
- `realm`
|
||||
- `client_id`
|
||||
- ob Staging und Live denselben Client nutzen sollen oder getrennte Clients
|
||||
|
||||
Das Secret musst du mir nicht im Chat schicken, wenn du es lieber direkt lokal in die Env-Datei eintragen willst.
|
||||
Reference in New Issue
Block a user