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

This commit is contained in:
2026-06-27 23:59:21 +02:00
parent dad3e0a629
commit 2d46b05b1e
7 changed files with 209 additions and 41 deletions

View File

@@ -16,7 +16,9 @@ $auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
$state = (string) ($_GET['state'] ?? '');
$expectedState = (string) ($_SESSION['keycloak_oauth_state'] ?? '');
$oauthMode = (string) ($_SESSION['keycloak_oauth_mode'] ?? 'interactive');
$code = (string) ($_GET['code'] ?? '');
$error = trim((string) ($_GET['error'] ?? ''));
$title = 'Keycloak Callback';
$message = 'Anmeldung wird verarbeitet.';
@@ -25,11 +27,21 @@ $redirectTarget = '/';
if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $state)) {
http_response_code(400);
$message = 'State-Pruefung fehlgeschlagen. Bitte den Login erneut starten.';
} elseif ($error !== '') {
unset($_SESSION['keycloak_oauth_state'], $_SESSION['keycloak_oauth_mode']);
if ($oauthMode === 'silent' && in_array($error, ['login_required', 'interaction_required', 'consent_required'], true)) {
header('Location: ' . $redirectTarget, true, 302);
exit;
}
http_response_code(400);
$message = 'Keycloak hat die Anmeldung abgelehnt: ' . $error . '.';
} elseif ($code === '') {
http_response_code(400);
$message = 'Kein Authorization Code von Keycloak erhalten.';
} else {
unset($_SESSION['keycloak_oauth_state']);
unset($_SESSION['keycloak_oauth_state'], $_SESSION['keycloak_oauth_mode']);
$tokenExchange = $auth->exchangeAuthorizationCode($code);
@@ -67,6 +79,7 @@ if ($state === '' || $expectedState === '' || !hash_equals($expectedState, $stat
exit;
} else {
$auth->establishSession($tokenPayload, $userPayload);
unset($_SESSION['desktop_silent_sso_attempted']);
header('Location: ' . $redirectTarget, true, 302);
exit;
}

View File

@@ -11,7 +11,8 @@ session_start();
$projectRoot = dirname(__DIR__, 3);
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
$loginUrl = $auth->loginUrl();
$mode = trim((string) ($_GET['mode'] ?? ''));
$loginUrl = $auth->loginUrl($mode === 'silent' ? ['prompt' => 'none'] : []);
if ($loginUrl === null) {
http_response_code(503);

View File

@@ -41,6 +41,16 @@ if ($auth->isAuthenticated()) {
}
}
if (
!$auth->isAuthenticated()
&& $auth->isConfigured()
&& empty($_SESSION['desktop_silent_sso_attempted'])
) {
$_SESSION['desktop_silent_sso_attempted'] = true;
header('Location: /auth/keycloak/?mode=silent', true, 302);
exit;
}
$app = new App($projectRoot);
$desktopPayload = $app->desktopPayload();

View File

@@ -62,7 +62,10 @@ final class KeycloakAuth
return $this->allowDesktopPreview() && isset($_GET[$previewKey]);
}
public function loginUrl(): ?string
/**
* @param array<string, string> $authorizeOverrides
*/
public function loginUrl(array $authorizeOverrides = []): ?string
{
if (!$this->isConfigured()) {
return null;
@@ -70,6 +73,7 @@ final class KeycloakAuth
$state = bin2hex(random_bytes(16));
$_SESSION['keycloak_oauth_state'] = $state;
$_SESSION['keycloak_oauth_mode'] = (string) ($authorizeOverrides['prompt'] ?? '') === 'none' ? 'silent' : 'interactive';
$params = array_merge(
[
@@ -83,6 +87,7 @@ final class KeycloakAuth
? $this->config['extra_authorize_params']
: []
);
$params = array_merge($params, $authorizeOverrides);
return rtrim($this->baseUrl(), '/')
. '/realms/' . rawurlencode($this->realm())

View File

@@ -257,6 +257,10 @@
min-width: 220px;
}
#admin-apps-app .aa-inline-access-trigger {
justify-self: start;
}
#admin-apps-app .aa-inline-access-current {
margin: 0;
font-size: 14px;
@@ -326,6 +330,65 @@
justify-self: start;
}
#admin-apps-app .aa-secondary {
background: rgba(226, 232, 240, 0.9);
color: #0f172a;
}
#admin-apps-app .aa-modal {
width: min(680px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
padding: 0;
border: 0;
border-radius: 22px;
background: transparent;
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.35);
}
#admin-apps-app .aa-modal::backdrop {
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(4px);
}
#admin-apps-app .aa-modal-card {
display: grid;
gap: 18px;
padding: 22px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96));
}
#admin-apps-app .aa-modal-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
#admin-apps-app .aa-modal-title {
margin: 0 0 6px;
font-size: 22px;
line-height: 1.15;
color: #0f172a;
}
#admin-apps-app .aa-modal-body {
display: grid;
gap: 14px;
}
#admin-apps-app .aa-modal-close {
width: 38px;
height: 38px;
border: 0;
border-radius: 999px;
background: rgba(226, 232, 240, 0.9);
color: #0f172a;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
#admin-apps-app .aa-message.is-success {
background: rgba(34, 197, 94, 0.12);
color: #166534;

View File

@@ -22,6 +22,54 @@
};
const bindInteractions = (host, loadRoute) => {
host.querySelectorAll('[data-aa-open-modal]').forEach((button) => {
if (button.dataset.aaBound === '1') {
return;
}
button.dataset.aaBound = '1';
button.addEventListener('click', () => {
const modalId = button.getAttribute('data-aa-open-modal') || '';
const modal = host.querySelector(`[data-aa-modal="${CSS.escape(modalId)}"]`);
if (modal instanceof HTMLDialogElement) {
modal.showModal();
}
});
});
host.querySelectorAll('[data-aa-close-modal]').forEach((button) => {
if (button.dataset.aaBound === '1') {
return;
}
button.dataset.aaBound = '1';
button.addEventListener('click', () => {
const modal = button.closest('dialog');
if (modal instanceof HTMLDialogElement) {
modal.close();
}
});
});
host.querySelectorAll('dialog[data-aa-modal]').forEach((dialog) => {
if (dialog.dataset.aaBound === '1') {
return;
}
dialog.dataset.aaBound = '1';
dialog.addEventListener('click', (event) => {
const rect = dialog.getBoundingClientRect();
const inside = event.clientX >= rect.left
&& event.clientX <= rect.right
&& event.clientY >= rect.top
&& event.clientY <= rect.bottom;
if (!inside) {
dialog.close();
}
});
});
host.querySelectorAll('a[href]').forEach((link) => {
if (link.dataset.aaBound === '1') {
return;
@@ -66,6 +114,11 @@
throw new Error(`HTTP ${response.status}`);
}
const modal = form.closest('dialog');
if (modal instanceof HTMLDialogElement) {
modal.close();
}
host.innerHTML = html;
bindInteractions(host, loadRoute);
} catch (error) {

View File

@@ -289,48 +289,71 @@ ob_start();
<td><?= $h((string) ($app['app_scope'] ?? '')) ?></td>
<td><?= $h(!empty($app['installable']) ? 'Custom App' : 'System') ?></td>
<td>
<form class="aa-inline-access-form" method="post" action="/admin/apps/">
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
<input type="hidden" name="action" value="save-app-access">
<input type="hidden" name="active_section" value="overview">
<input type="hidden" name="app_id" value="<?= $h((string) ($app['app_id'] ?? '')) ?>">
<div class="aa-inline-access-form">
<p class="aa-inline-access-current"><?= $h((string) ($app['required_groups_display'] ?? '')) ?></p>
<label class="aa-inline-access-toggle">
<input
type="checkbox"
name="available_without_login"
value="1"
<?= !empty($app['available_without_login']) ? 'checked' : '' ?>
>
<span>Auch ohne Login auf dem Logoff-Desktop anzeigen</span>
</label>
<button
class="window-app-button aa-inline-access-trigger"
type="button"
data-aa-open-modal="access-<?= $h((string) ($app['app_id'] ?? '')) ?>"
>Berechtigungen</button>
</div>
<?php if ($availableGroups !== []): ?>
<details class="aa-inline-access-details">
<summary>Bearbeiten</summary>
<div class="aa-inline-access-groups">
<?php foreach ($availableGroups as $group): ?>
<label class="aa-inline-access-option">
<input
type="checkbox"
name="required_groups[]"
value="<?= $h((string) ($group['id'] ?? '')) ?>"
<?= in_array((string) ($group['id'] ?? ''), array_values(array_map('strval', (array) ($app['required_groups'] ?? []))), true) ? 'checked' : '' ?>
>
<span>
<strong><?= $h((string) ($group['label'] ?? '')) ?></strong>
<small><?= $h((string) ($group['id'] ?? '')) ?></small>
</span>
</label>
<?php endforeach; ?>
<dialog class="aa-modal" data-aa-modal="access-<?= $h((string) ($app['app_id'] ?? '')) ?>">
<form class="aa-modal-card" method="post" action="/admin/apps/">
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
<input type="hidden" name="action" value="save-app-access">
<input type="hidden" name="active_section" value="overview">
<input type="hidden" name="app_id" value="<?= $h((string) ($app['app_id'] ?? '')) ?>">
<div class="aa-modal-head">
<div>
<p class="window-app-kicker aa-kicker">Berechtigungen</p>
<h4 class="aa-modal-title"><?= $h((string) ($app['title'] ?? '')) ?></h4>
<p class="aa-copy">LDAP-Gruppen und Freigabe fuer den Logoff-Desktop zentral festlegen.</p>
</div>
<button class="aa-modal-close" type="button" data-aa-close-modal>×</button>
</div>
<div class="aa-modal-body">
<p class="aa-inline-access-current">Aktuell: <?= $h((string) ($app['required_groups_display'] ?? '')) ?></p>
<label class="aa-inline-access-toggle">
<input
type="checkbox"
name="available_without_login"
value="1"
<?= !empty($app['available_without_login']) ? 'checked' : '' ?>
>
<span>Auch ohne Login auf dem Logoff-Desktop anzeigen</span>
</label>
<?php if ($availableGroups !== []): ?>
<div class="aa-inline-access-groups">
<?php foreach ($availableGroups as $group): ?>
<label class="aa-inline-access-option">
<input
type="checkbox"
name="required_groups[]"
value="<?= $h((string) ($group['id'] ?? '')) ?>"
<?= in_array((string) ($group['id'] ?? ''), array_values(array_map('strval', (array) ($app['required_groups'] ?? []))), true) ? 'checked' : '' ?>
>
<span>
<strong><?= $h((string) ($group['label'] ?? '')) ?></strong>
<small><?= $h((string) ($group['id'] ?? '')) ?></small>
</span>
</label>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="aa-table-copy">Keine LDAP-Gruppen gefunden.</p>
<?php endif; ?>
</div>
<div class="aa-actions">
<button class="window-app-button aa-secondary" type="button" data-aa-close-modal>Abbrechen</button>
<button class="window-app-button aa-primary aa-inline-access-submit" type="submit">Speichern</button>
</details>
<?php else: ?>
<p class="aa-table-copy">Keine LDAP-Gruppen gefunden.</p>
<?php endif; ?>
</form>
</div>
</form>
</dialog>
</td>
<td><code><?= $h((string) ($app['entry_route'] ?? '')) ?></code></td>
<td>