Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

196 строки
7.3KB

  1. <?php
  2. declare(strict_types=1);
  3. namespace Core\Auth;
  4. use Core\Http\Session;
  5. use Stevenmaguire\OAuth2\Client\Provider\Keycloak;
  6. class KeycloakAuth
  7. {
  8. private Session $session;
  9. private PermissionService $permissions;
  10. private ?Keycloak $provider = null;
  11. /** Keycloak service-account roles to exclude from the app role list. */
  12. private const SYSTEM_ROLES = ['uma_authorization', 'offline_access', 'account', 'view-profile', 'manage-account', 'manage-account-links'];
  13. public function __construct(Session $session, PermissionService $permissions)
  14. {
  15. $this->session = $session;
  16. $this->permissions = $permissions;
  17. }
  18. // ── Auth state ────────────────────────────────────────────────────────────
  19. public function check(): bool
  20. {
  21. return ($this->session->get('auth', [])['is_authenticated'] ?? false) === true;
  22. }
  23. public function user(): ?AuthUser
  24. {
  25. return $this->check() ? AuthUser::fromSession($this->session->get('auth', [])) : null;
  26. }
  27. public function id(): ?string
  28. {
  29. return $this->user()?->keycloakId;
  30. }
  31. public function roles(): array
  32. {
  33. return $this->user()?->roles ?? [];
  34. }
  35. public function permissions(): array
  36. {
  37. return $this->user()?->permissions ?? [];
  38. }
  39. public function hasRole(string $role): bool
  40. {
  41. return in_array($role, $this->roles(), true);
  42. }
  43. public function can(string $permission): bool
  44. {
  45. return in_array($permission, $this->permissions(), true);
  46. }
  47. // ── Login flow ────────────────────────────────────────────────────────────
  48. /**
  49. * Build the Keycloak authorization URL and store the state for CSRF validation.
  50. */
  51. public function beginLogin(): string
  52. {
  53. $provider = $this->getProvider();
  54. $authUrl = $provider->getAuthorizationUrl([
  55. 'scope' => 'openid email profile',
  56. ]);
  57. $this->session->set('oauth_state', $provider->getState());
  58. return $authUrl;
  59. }
  60. /**
  61. * Exchange the authorization code for tokens, populate the session.
  62. *
  63. * @throws \RuntimeException on state mismatch or token exchange failure
  64. */
  65. public function handleCallback(string $code, string $returnedState): void
  66. {
  67. $storedState = $this->session->get('oauth_state');
  68. $this->session->forget('oauth_state');
  69. if ($storedState === null || !hash_equals((string) $storedState, $returnedState)) {
  70. throw new \RuntimeException('OAuth state mismatch — possible CSRF attempt.');
  71. }
  72. $provider = $this->getProvider();
  73. $token = $provider->getAccessToken('authorization_code', ['code' => $code]);
  74. // Decode the access-token JWT to extract realm/client role claims.
  75. // Signature verification is not needed here: the token arrived directly
  76. // from Keycloak over an authenticated server-to-server HTTPS exchange.
  77. $accessClaims = $this->decodeJwtPayload($token->getToken());
  78. $idTokenRaw = (string) ($token->getValues()['id_token'] ?? '');
  79. $realmRoles = (array) ($accessClaims['realm_access']['roles'] ?? []);
  80. $clientId = (string) env('KEYCLOAK_CLIENT_ID', '');
  81. $clientRoles = (array) ($accessClaims['resource_access'][$clientId]['roles'] ?? []);
  82. $appRoles = array_values(array_filter(
  83. array_unique(array_merge($realmRoles, $clientRoles)),
  84. static fn(string $r): bool => !in_array($r, self::SYSTEM_ROLES, true)
  85. ));
  86. // Fetch the user-profile claims from the userinfo endpoint.
  87. $ownerData = $provider->getResourceOwner($token)->toArray();
  88. $displayName = trim(($ownerData['given_name'] ?? '') . ' ' . ($ownerData['family_name'] ?? ''));
  89. if ($displayName === '') {
  90. $displayName = (string) ($ownerData['preferred_username'] ?? $ownerData['sub'] ?? '');
  91. }
  92. // Prevent session fixation: regenerate ID before writing auth data.
  93. $this->session->regenerate();
  94. $this->session->set('auth', [
  95. 'is_authenticated' => true,
  96. 'user' => [
  97. 'keycloak_id' => (string) ($ownerData['sub'] ?? $accessClaims['sub'] ?? ''),
  98. 'username' => (string) ($ownerData['preferred_username'] ?? ''),
  99. 'email' => (string) ($ownerData['email'] ?? ''),
  100. 'display_name' => $displayName,
  101. 'roles' => $appRoles,
  102. 'permissions' => $this->permissions->permissionsForRoles($appRoles),
  103. ],
  104. // id_token is stored solely to support Keycloak RP-initiated logout
  105. // (id_token_hint parameter). It is never used to derive identity or
  106. // resolve permissions. Do not pass it to the browser or log it.
  107. 'id_token' => $idTokenRaw !== '' ? $idTokenRaw : null,
  108. 'login_time' => time(),
  109. 'last_permission_refresh' => time(),
  110. ]);
  111. }
  112. // ── Logout ────────────────────────────────────────────────────────────────
  113. /**
  114. * Destroy the local session and return the Keycloak RP-initiated logout URL.
  115. */
  116. public function logout(): string
  117. {
  118. $idToken = $this->session->get('auth')['id_token'] ?? null;
  119. $redirectUri = (string) env('KEYCLOAK_LOGOUT_REDIRECT_URI', '/');
  120. $base = rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/');
  121. $realm = (string) env('KEYCLOAK_REALM', '');
  122. $this->session->destroy();
  123. $params = ['post_logout_redirect_uri' => $redirectUri];
  124. if ($idToken !== null) {
  125. $params['id_token_hint'] = $idToken;
  126. }
  127. return "{$base}/realms/{$realm}/protocol/openid-connect/logout?" . http_build_query($params);
  128. }
  129. // ── Internal ──────────────────────────────────────────────────────────────
  130. private function getProvider(): Keycloak
  131. {
  132. if ($this->provider === null) {
  133. $this->provider = new Keycloak([
  134. 'authServerUrl' => rtrim((string) env('KEYCLOAK_BASE_URL', ''), '/'),
  135. 'realm' => (string) env('KEYCLOAK_REALM', ''),
  136. 'clientId' => (string) env('KEYCLOAK_CLIENT_ID', ''),
  137. 'clientSecret' => (string) env('KEYCLOAK_CLIENT_SECRET', ''),
  138. 'redirectUri' => (string) env('KEYCLOAK_REDIRECT_URI', ''),
  139. ]);
  140. }
  141. return $this->provider;
  142. }
  143. private function decodeJwtPayload(string $jwt): array
  144. {
  145. $parts = explode('.', $jwt);
  146. if (count($parts) !== 3) {
  147. return [];
  148. }
  149. $padded = str_pad(strtr($parts[1], '-_', '+/'), (int) ceil(strlen($parts[1]) / 4) * 4, '=');
  150. $decoded = base64_decode($padded, true);
  151. return $decoded !== false ? (json_decode($decoded, true) ?? []) : [];
  152. }
  153. }

Powered by TurnKey Linux.