508 lines
14 KiB
PHP
508 lines
14 KiB
PHP
<?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'] ?? ''),
|
|
'groups' => $this->extractGroups($tokenPayload, $userInfo),
|
|
],
|
|
];
|
|
}
|
|
|
|
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');
|
|
|
|
return $this->requestOrigin() . $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
|
|
{
|
|
return $this->requestOrigin() . '/';
|
|
}
|
|
|
|
private function requestOrigin(): string
|
|
{
|
|
$forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''));
|
|
$forwardedHost = trim((string) ($_SERVER['HTTP_X_FORWARDED_HOST'] ?? ''));
|
|
$forwardedPort = trim((string) ($_SERVER['HTTP_X_FORWARDED_PORT'] ?? ''));
|
|
|
|
$scheme = $this->normalizeForwardedValue($forwardedProto);
|
|
|
|
if ($scheme === '') {
|
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
}
|
|
|
|
$host = $this->normalizeForwardedValue($forwardedHost);
|
|
|
|
if ($host === '') {
|
|
$host = (string) ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost');
|
|
}
|
|
|
|
if ($forwardedPort !== '') {
|
|
$port = $this->normalizeForwardedValue($forwardedPort);
|
|
$hostWithoutPort = preg_replace('/:\d+$/', '', $host) ?? $host;
|
|
|
|
if (
|
|
$port !== ''
|
|
&& !str_contains($host, ':')
|
|
&& !(($scheme === 'https' && $port === '443') || ($scheme === 'http' && $port === '80'))
|
|
) {
|
|
$host = $hostWithoutPort . ':' . $port;
|
|
}
|
|
}
|
|
|
|
return $scheme . '://' . $host;
|
|
}
|
|
|
|
private function normalizeForwardedValue(string $value): string
|
|
{
|
|
if ($value === '') {
|
|
return '';
|
|
}
|
|
|
|
$first = trim(explode(',', $value)[0] ?? '');
|
|
|
|
return strtolower($first);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $tokenPayload
|
|
* @param array<string, mixed> $userInfo
|
|
* @return array<int, string>
|
|
*/
|
|
private function extractGroups(array $tokenPayload, array $userInfo): array
|
|
{
|
|
$groups = [];
|
|
|
|
foreach ([$userInfo, $this->decodeJwtClaims((string) ($tokenPayload['access_token'] ?? '')), $this->decodeJwtClaims((string) ($tokenPayload['id_token'] ?? ''))] as $source) {
|
|
$candidateGroups = $source['groups'] ?? [];
|
|
|
|
if (!is_array($candidateGroups)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($candidateGroups as $group) {
|
|
$group = trim((string) $group);
|
|
|
|
if ($group === '') {
|
|
continue;
|
|
}
|
|
|
|
$group = trim($group, '/');
|
|
$groups[$group] = $group;
|
|
}
|
|
}
|
|
|
|
return array_values($groups);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function decodeJwtClaims(string $jwt): array
|
|
{
|
|
if ($jwt === '') {
|
|
return [];
|
|
}
|
|
|
|
$parts = explode('.', $jwt);
|
|
|
|
if (count($parts) < 2) {
|
|
return [];
|
|
}
|
|
|
|
$payload = $parts[1];
|
|
$payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4);
|
|
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
|
|
|
if ($decoded === false) {
|
|
return [];
|
|
}
|
|
|
|
$claims = json_decode($decoded, true);
|
|
|
|
return is_array($claims) ? $claims : [];
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
];
|
|
}
|
|
}
|