Issues (203)

class.keycloak.php (1 issue)

1
<?php
2
3
/*
4
 * SPDX-License-Identifier: AGPL-3.0-only
5
 * SPDX-FileCopyrightText: Copyright 2023 grommunio GmbH
6
 *
7
 * Performs several actions against a KeyCloak server.
8
 */
9
10
// include the token grant class
11
require_once 'class.token.php';
12
13
class KeyCloak {
14
	public $access_token;
15
	public $refresh_token;
16
	public $id_token;
17
	public $redirect_url;
18
19
	public $last_refresh_time;
20
	private static $_instance;
21
	public $grant;
22
	public $error;
23
24
	protected $realm_id;
25
	protected $client_id;
26
	protected $secret;
27
28
	protected $realm_url;
29
	protected $realm_admin_url;
30
31
	protected $public_key;
32
	protected $is_public;
33
34
	/**
35
	 * The constructor reads all required values from the KeyCloak configuration file.
36
	 * This includes values for realm_id, client_id, client_secret, server_url etc.
37
	 *
38
	 * @param mixed $keycloak_config
39
	 */
40
	public function __construct($keycloak_config) {
41
		if (gettype($keycloak_config) === 'string') {
42
			$keycloak_config = json_decode($keycloak_config);
43
		}
44
45
		// redirect_url
46
		$url = "https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
47
		$url_exp = explode('?', $url);
48
		$url = $url_exp[0];
49
		if ($url[-1] == '/') {
50
			$url = substr($url, 0, -1);
51
		}
52
		$this->redirect_url = $url;
53
54
		// keycloak Realm ID
55
		$this->realm_id = $keycloak_config['realm'] ?? 'grommunio';
56
57
		// keycloak client ID
58
		$this->client_id = $keycloak_config['resource'] ?? 'gramm';
59
60
		// @type {bool} checks if client is a public client and extracts the public key
61
		$this->is_public = $keycloak_config['public-client'] ?? false;
62
		$this->public_key = $this->is_public == false ? "" : "-----BEGIN PUBLIC KEY-----\n" . chunk_split((string) $keycloak_config['realm-public-key'], 64, "\n") . "\n-----END PUBLIC KEY-----\n";
63
64
		// client secret => obtained if client is not a public client
65
		if (!$this->is_public) {
66
			$this->secret = $keycloak_config['credentials']['secret'] ?? $keycloak_config['secret'] ?? null;
67
		}
68
69
		// keycloak server url
70
		$auth_server_url = $keycloak_config['auth-server-url'] ?? 'null';
71
72
		// Root realm URL.
73
		$this->realm_url = $auth_server_url . 'realms/' . $this->realm_id;
74
75
		// Root realm admin URL.
76
		$this->realm_admin_url = $auth_server_url . 'admin/realms/' . $this->realm_id;
77
	}
78
79
	/**
80
	 * Static method to instantiate and return a KeyCloak instance from the
81
	 * default configuration file.
82
	 *
83
	 * @return KeyCloak
84
	 */
85
	public static function getInstance() {
86
		if (!defined('GROMOX_CONFIG_PATH')) {
87
			define('GROMOX_CONFIG_PATH', '/etc/gromox/');
88
		}
89
		if (is_null(KeyCloak::$_instance) && file_exists(GROMOX_CONFIG_PATH . 'keycloak.json')) {
90
			// Read the keycloak config adapter into an instance of the keyclaok class
91
			$keycloak_file = file_get_contents(GROMOX_CONFIG_PATH . 'keycloak.json');
92
			$keycloak_json = json_decode($keycloak_file, true);
93
			KeyCloak::$_instance = new KeyCloak($keycloak_json);
94
		}
95
96
		return KeyCloak::$_instance;
97
	}
98
99
	/**
100
	 * Returns the last known refresh time.
101
	 *
102
	 * @return long
0 ignored issues
show
The type long was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
103
	 */
104
	public function get_last_refresh_time() {
105
		return $this->last_refresh_time;
106
	}
107
108
	/**
109
	 * Sets  the last refresh time.
110
	 *
111
	 * @param long $time
112
	 */
113
	public function set_last_refresh_time($time) {
114
		$this->last_refresh_time = $time;
115
	}
116
117
	/**
118
	 * Oauth 2.0 Authorization flow is used to obtain Access token,
119
	 * refresh token and ID token from keycloak server by sending
120
	 * https post request (curl) to a /token web endpoint.
121
	 * The keycloak server will respond with grant back to the
122
	 * grommunio server.
123
	 *
124
	 * We implement three of this protocol:
125
	 *  1. OAuth 2.0 resource owner password credential grant.
126
	 *  2. OAuth 2.0 Client Code credential grant.
127
	 *  3. Refresh token grant.
128
	 *
129
	 * The password grant takes two argument:
130
	 *
131
	 * @param string $username The username
132
	 * @param string $password The cleartext password
133
	 *
134
	 * @return bool indicating if the request was successful nor not
135
	 */
136
	public function password_grant_req($username, $password) {
137
		$params = ['grant_type' => 'password', 'username' => $username, 'password' => $password];
138
139
		return $this->request($params);
140
	}
141
142
	/**
143
	 * The Oauth 2.0 client credential code grant is the next type request used to
144
	 * request access token from keycloak. The logon on the Authentication server url
145
	 * (keycloak), on successful authentication. the server replies with the credential
146
	 * grant code. This code will be used to request the tokens.
147
	 *
148
	 * @param string      $code         The code from a successful login redirected from Keycloak
149
	 * @param null|string $session_host
150
	 *
151
	 * @return bool indicating if the request was successful nor not
152
	 */
153
	public function client_credential_grant_req($code, $session_host = null) {
154
		// TODO: $session_host not used here
155
		$params = ['grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_url];
156
157
		return $this->request($params);
158
	}
159
160
	/**
161
	 * The Oauth 2.0 refresh token grant is the next type request used to
162
	 * request access token from keycloak. If the client has a valid refresh token
163
	 * which has not expired. It can send a request to the server to obtain new tokens.
164
	 *
165
	 * @return bool indicating if the request was successful nor not
166
	 */
167
	public function refresh_grant_req() {
168
		// Ensure grant exists, grant is not expired, and we have a refresh token
169
		if (!$this->grant || !$this->refresh_token) {
170
			$this->grant = null;
171
172
			return false;
173
		}
174
		$params = ['grant_type' => 'refresh_token', 'refresh_token' => $this->refresh_token->get_payload()];
175
176
		return $this->request($params);
177
	}
178
179
	/**
180
	 * Performs a token request to the KeyCloak server with predefined parameters.
181
	 *
182
	 * @param array $params predefined parameters used for the request
183
	 *
184
	 * @return bool indicating if the request was successful or not
185
	 */
186
	protected function request($params) {
187
		$headers = ['Content-Type: application/x-www-form-urlencoded'];
188
		if ($this->is_public) {
189
			$params['client_id'] = $this->client_id;
190
		}
191
		else {
192
			array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret));
193
		}
194
		$params['scope'] = 'openid';
195
		$response = $this->http_curl_request('POST', '/protocol/openid-connect/token', $headers, http_build_query($params));
196
		if ($response['code'] < 200 || $response['code'] > 299) {
197
			$this->error = $response['body'];
198
			$this->grant = null;
199
200
			return false;
201
		}
202
		$this->grant = $response['body'];
203
		if (gettype($this->grant) === 'string') {
204
			$this->grant = json_decode($this->grant, true);
205
		}
206
		else {
207
			$this->grant = json_encode($this->grant);
208
		}
209
		$this->access_token = isset($this->grant['access_token']) ? new Token($this->grant['access_token']) : null;
210
		$this->refresh_token = isset($this->grant['refresh_token']) ? new Token($this->grant['refresh_token']) : null;
211
		$this->id_token = isset($this->grant['id_token']) ? new Token($this->grant['id_token']) : null;
212
213
		return true;
214
	}
215
216
	/**
217
	 * Validates the grant represented by the access and refresh tokens in the grant.
218
	 * If the refresh token has expired too, return false.
219
	 *
220
	 * @return bool
221
	 */
222
	public function validate_grant() {
223
		if ($this->validate_token($this->access_token) && $this->validate_token($this->refresh_token)) {
224
			return true;
225
		}
226
227
		return $this->refresh_grant_req();
228
	}
229
230
	/**
231
	 * Validates a token with the server.
232
	 *
233
	 * @param mixed $token
234
	 *
235
	 * @return bool
236
	 */
237
	protected function validate_token($token) {
238
		if (isset($token)) {
239
			$path = "/protocol/openid-connect/token/introspect";
240
			$headers = ['Content-Type: application/x-www-form-urlencoded'];
241
			$params = ['token' => $token->get_payload()];
242
			if ($this->is_public) {
243
				$params['client_id'] = $this->client_id;
244
			}
245
			else {
246
				array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret));
247
			}
248
			$response = $this->http_curl_request('POST', $path, $headers, http_build_query($params));
249
250
			if ($response['code'] < 200 || $response['code'] > 299) {
251
				return false;
252
			}
253
254
			try {
255
				$data = json_decode((string) $response['body'], true);
256
			}
257
			catch (Exception) {
258
				return false;
259
			}
260
261
			return !array_key_exists('error', $data);
262
		}
263
264
		return false;
265
	}
266
267
	/**
268
	 * Indicates if the access token is expired.
269
	 *
270
	 * @return bool
271
	 */
272
	public function is_expired() {
273
		if (!$this->access_token) {
274
			return true;
275
		}
276
277
		return $this->access_token->is_expired();
278
	}
279
280
	/**
281
	 * Builds a KeyCloak login url used with the client credential code.
282
	 *
283
	 * @param string $redirect_url Redirect URL to be parameterized in the URL
284
	 *
285
	 * @return string
286
	 */
287
	public function login_url($redirect_url) {
288
		$uuid = bin2hex(openssl_random_pseudo_bytes(32));
289
290
		return $this->realm_url . '/protocol/openid-connect/auth?scope=openid&client_id=' . urlencode((string) $this->client_id) . '&state=' . urlencode($uuid) . '&redirect_uri=' . urlencode($redirect_url) . '&response_type=code';
291
	}
292
293
	/**
294
	 * Builds a KeyCloak logout url.
295
	 *
296
	 * @return string
297
	 */
298
	public function logout() {
299
		$params = '?id_token_hint=' . $this->id_token->get_payload() . '&refresh_token=' . $this->refresh_token->get_payload();
300
301
		return $this->realm_url . '/protocol/openid-connect/logout' . $params;
302
	}
303
304
	/*
305
	 * Send HTTP request via CURL.
306
	 *
307
	 * @param string $method The HTTP request to use. (Default to GET)
308
	 * @param array $headers The HTTP headers to be passed into the request
309
	 * @param string $data The data to be passed into the body of the request
310
	 * @param string $domain
311
	 *
312
	 * @return array associative array with 'code' for response code and 'body' for request body
313
	 */
314
	protected function http_curl_request($method, $domain, $headers = [], $data = '') {
315
		$request = curl_init();
316
		curl_setopt($request, CURLOPT_URL, $this->realm_url . $domain);
317
		if (strcmp(strtoupper((string) $method), 'POST') == 0) {
318
			curl_setopt($request, CURLOPT_POST, true);
319
			curl_setopt($request, CURLOPT_POSTFIELDS, $data);
320
			array_push($headers, 'Content-Length: ' . strlen((string) $data));
321
		}
322
323
		curl_setopt($request, CURLOPT_HTTPHEADER, $headers);
324
		curl_setopt($request, CURLOPT_RETURNTRANSFER, true);
325
326
		$response = curl_exec($request);
327
		$response_code = curl_getinfo($request, CURLINFO_HTTP_CODE);
328
		curl_close($request);
329
330
		return [
331
			'code' => $response_code,
332
			'body' => $response,
333
		];
334
	}
335
}
336