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

View File

@@ -11,7 +11,8 @@ session_start();
$projectRoot = dirname(__DIR__, 3); $projectRoot = dirname(__DIR__, 3);
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); $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) { if ($loginUrl === null) {
http_response_code(503); 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); $app = new App($projectRoot);
$desktopPayload = $app->desktopPayload(); $desktopPayload = $app->desktopPayload();

View File

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

View File

@@ -257,6 +257,10 @@
min-width: 220px; min-width: 220px;
} }
#admin-apps-app .aa-inline-access-trigger {
justify-self: start;
}
#admin-apps-app .aa-inline-access-current { #admin-apps-app .aa-inline-access-current {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
@@ -326,6 +330,65 @@
justify-self: start; 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 { #admin-apps-app .aa-message.is-success {
background: rgba(34, 197, 94, 0.12); background: rgba(34, 197, 94, 0.12);
color: #166534; color: #166534;

View File

@@ -22,6 +22,54 @@
}; };
const bindInteractions = (host, loadRoute) => { 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) => { host.querySelectorAll('a[href]').forEach((link) => {
if (link.dataset.aaBound === '1') { if (link.dataset.aaBound === '1') {
return; return;
@@ -66,6 +114,11 @@
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
const modal = form.closest('dialog');
if (modal instanceof HTMLDialogElement) {
modal.close();
}
host.innerHTML = html; host.innerHTML = html;
bindInteractions(host, loadRoute); bindInteractions(host, loadRoute);
} catch (error) { } catch (error) {

View File

@@ -289,13 +289,33 @@ ob_start();
<td><?= $h((string) ($app['app_scope'] ?? '')) ?></td> <td><?= $h((string) ($app['app_scope'] ?? '')) ?></td>
<td><?= $h(!empty($app['installable']) ? 'Custom App' : 'System') ?></td> <td><?= $h(!empty($app['installable']) ? 'Custom App' : 'System') ?></td>
<td> <td>
<form class="aa-inline-access-form" method="post" action="/admin/apps/"> <div class="aa-inline-access-form">
<p class="aa-inline-access-current"><?= $h((string) ($app['required_groups_display'] ?? '')) ?></p>
<button
class="window-app-button aa-inline-access-trigger"
type="button"
data-aa-open-modal="access-<?= $h((string) ($app['app_id'] ?? '')) ?>"
>Berechtigungen</button>
</div>
<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="csrf_token" value="<?= $h($csrfToken) ?>">
<input type="hidden" name="action" value="save-app-access"> <input type="hidden" name="action" value="save-app-access">
<input type="hidden" name="active_section" value="overview"> <input type="hidden" name="active_section" value="overview">
<input type="hidden" name="app_id" value="<?= $h((string) ($app['app_id'] ?? '')) ?>"> <input type="hidden" name="app_id" value="<?= $h((string) ($app['app_id'] ?? '')) ?>">
<p class="aa-inline-access-current"><?= $h((string) ($app['required_groups_display'] ?? '')) ?></p> <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"> <label class="aa-inline-access-toggle">
<input <input
type="checkbox" type="checkbox"
@@ -307,8 +327,6 @@ ob_start();
</label> </label>
<?php if ($availableGroups !== []): ?> <?php if ($availableGroups !== []): ?>
<details class="aa-inline-access-details">
<summary>Bearbeiten</summary>
<div class="aa-inline-access-groups"> <div class="aa-inline-access-groups">
<?php foreach ($availableGroups as $group): ?> <?php foreach ($availableGroups as $group): ?>
<label class="aa-inline-access-option"> <label class="aa-inline-access-option">
@@ -325,12 +343,17 @@ ob_start();
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button class="window-app-button aa-primary aa-inline-access-submit" type="submit">Speichern</button>
</details>
<?php else: ?> <?php else: ?>
<p class="aa-table-copy">Keine LDAP-Gruppen gefunden.</p> <p class="aa-table-copy">Keine LDAP-Gruppen gefunden.</p>
<?php endif; ?> <?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>
</div>
</form> </form>
</dialog>
</td> </td>
<td><code><?= $h((string) ($app['entry_route'] ?? '')) ?></code></td> <td><code><?= $h((string) ($app['entry_route'] ?? '')) ?></code></td>
<td> <td>