Issues (207)

class.keycloak.php (1 issue)

1
<?php
2
/*
3
 * SPDX-License-Identifier: AGPL-3.0-only
4
 * SPDX-FileCopyrightText: Copyright 2023 grommunio GmbH
5
 *
6
 * Performs several actions against a KeyCloak server.
7
 */
8
9
// include the token grant class
10
require_once 'class.token.php';
11
12
class KeyCloak {
13
	public $access_token;
14
	public $refresh_token;
15
	public $id_token;
16
	public $redirect_url;
17
18
	public $last_refresh_time;
19
	private static $_instance;
20
	public $grant;
21
	public $error;
22
23
	protected $realm_id;
24
	protected $client_id;
25
	protected $secret;
26
27
	protected $realm_url;
28
	protected $realm_admin_url;
29
30
	protected $public_key;
31
	protected $is_public;
32
33
	/**
34
	 * The constructor reads all required values from the KeyCloak configuration file.
35
	 * This includes values for realm_id, client_id, client_secret, server_url etc.
36
	 *
37
	 * @param mixed $keycloak_config
38
	 */
39
	public function __construct($keycloak_config) {
40
		if (gettype($keycloak_config) === 'string') {
41
			$keycloak_config = json_decode($keycloak_config);
42
		}
43
44
		// redirect_url
45
		$url = "https://" . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
46
		$url_exp = explode('?', $url);
47
		$url = $url_exp[0];
48
		if ($url[-1] == '/') {
49
			$url = substr($url, 0, -1);
50
		}
51
		$this->redirect_url = $url;
52
53
		// keycloak Realm ID
54
		$this->realm_id = $keycloak_config['realm'] ?? 'grommunio';
55
56
		// keycloak client ID
57
		$this->client_id = $keycloak_config['resource'] ?? 'gramm';
58
59
		// @type {bool} checks if client is a public client and extracts the public key
60
		$this->is_public = $keycloak_config['public-client'] ?? false;
61
		$this->public_key = $this->is_public == false ? "" : "-----BEGIN PUBLIC KEY-----\n" . chunk_split($keycloak_config['realm-public-key'], 64, "\n") . "\n-----END PUBLIC KEY-----\n";
62
63
		// client secret => obtained if client is not a public client
64
		if (!$this->is_public) {
65
			$this->secret = $keycloak_config['credentials']['secret'] ?? $keycloak_config['secret'] ?? null;
66
		}
67
68
		// keycloak server url
69
		$auth_server_url = $keycloak_config['auth-server-url'] ?? 'null';
70
71
		// Root realm URL.
72
		$this->realm_url = $auth_server_url . 'realms/' . $this->realm_id;
73
74
		// Root realm admin URL.
75
		$this->realm_admin_url = $auth_server_url . 'admin/realms/' . $this->realm_id;
76
	}
77
78
	/**
79
	 * Static method to instantiate and return a KeyCloak instance from the
80
	 * default configuration file.
81
	 *
82
	 * @return KeyCloak
83
	 */
84
	public static function getInstance() {
85
		if (!defined('GROMOX_CONFIG_PATH')) {
86
			define('GROMOX_CONFIG_PATH', '/etc/gromox/');
87
		}
88
		if (is_null(KeyCloak::$_instance) && file_exists(GROMOX_CONFIG_PATH . 'keycloak.json')) {
89
			// Read the keycloak config adapter into an instance of the keyclaok class
90
			$keycloak_file = file_get_contents(GROMOX_CONFIG_PATH . 'keycloak.json');
91
			$keycloak_json = json_decode($keycloak_file, true);
92
			KeyCloak::$_instance = new KeyCloak($keycloak_json);
93
		}
94
95
		return KeyCloak::$_instance;
96
	}
97
98
	/**
99
	 * Returns the last known refresh time.
100
	 *
101
	 * @return long
102
	 */
103
	public function get_last_refresh_time() {
104
		return $this->last_refresh_time;
105
	}
106
107
	/**
108
	 * Sets  the last refresh time.
109
	 *
110
	 * @param long $time
111
	 */
112
	public function set_last_refresh_time($time) {
113
		$this->last_refresh_time = $time;
114
	}
115
116
	/**
117
	 * Oauth 2.0 Authorization flow is used to obtain Access token,
118
	 * refresh token and ID token from keycloak server by sending
119
	 * https post request (curl) to a /token web endpoint.
120
	 * The keycloak server will respond with grant back to the
121
	 * grommunio server.
122
	 *
123
	 * We implement three of this protocol:
124
	 *  1. OAuth 2.0 resource owner password credential grant.
125
	 *  2. OAuth 2.0 Client Code credential grant.
126
	 *  3. Refresh token grant.
127
	 *
128
	 * The password grant takes two argument:
129
	 *
130
	 * @param string $username The username
131
	 * @param string $password The cleartext password
132
	 *
133
	 * @return bool indicating if the request was successful nor not
134
	 */
135
	public function password_grant_req($username, $password) {
136
		$params = ['grant_type' => 'password', 'username' => $username, 'password' => $password];
137
138
		return $this->request($params);
139
	}
140
141
	/**
142
	 * The Oauth 2.0 client credential code grant is the next type request used to
143
	 * request access token from keycloak. The logon on the Authentication server url
144
	 * (keycloak), on successful authentication. the server replies with the credential
145
	 * grant code. This code will be used to request the tokens.
146
	 *
147
	 * @param string      $code         The code from a successful login redirected from Keycloak
148
	 * @param null|string $session_host
149
	 *
150
	 * @return bool indicating if the request was successful nor not
151
	 */
152
	public function client_credential_grant_req($code, $session_host = null) {
0 ignored issues
show
The parameter $session_host is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

152
	public function client_credential_grant_req($code, /** @scrutinizer ignore-unused */ $session_host = null) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
153
		// TODO: $session_host not used here
154
		$params = ['grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_url];
155
156
		return $this->request($params);
157
	}
158
159
	/**
160
	 * The Oauth 2.0 refresh token grant is the next type request used to
161
	 * request access token from keycloak. If the client has a valid refresh token
162
	 * which has not expired. It can send a request to the server to obtain new tokens.
163
	 *
164
	 * @return bool indicating if the request was successful nor not
165
	 */
166
	public function refresh_grant_req() {
167
		// Ensure grant exists, grant is not expired, and we have a refresh token
168
		if (!$this->grant || !$this->refresh_token) {
169
			$this->grant = null;
170
171
			return false;
172
		}
173
		$params = ['grant_type' => 'refresh_token', 'refresh_token' => $this->refresh_token->get_payload()];
174
175
		return $this->request($params);
176
	}
177
178
	/**
179
	 * Performs a token request to the KeyCloak server with predefined parameters.
180
	 *
181
	 * @param array $params predefined parameters used for the request
182
	 *
183
	 * @return bool indicating if the request was successful or not
184
	 */
185
	protected function request($params) {
186
		$headers = ['Content-Type: application/x-www-form-urlencoded'];
187
		if ($this->is_public) {
188
			$params['client_id'] = $this->client_id;
189
		}
190
		else {
191
			array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret));
192
		}
193
		$params['scope'] = 'openid';
194
		$response = $this->http_curl_request('POST', '/protocol/openid-connect/token', $headers, http_build_query($params));
195
		if ($response['code'] < 200 || $response['code'] > 299) {
196
			$this->error = $response['body'];
197
			$this->grant = null;
198
199
			return false;
200
		}
201
		$this->grant = $response['body'];
202
		if (gettype($this->grant) === 'string') {
203
			$this->grant = json_decode($this->grant, true);
204
		}
205
		else {
206
			$this->grant = json_encode($this->grant);
207
		}
208
		$this->access_token = isset($this->grant['access_token']) ? new Token($this->grant['access_token']) : null;
209
		$this->refresh_token = isset($this->grant['refresh_token']) ? new Token($this->grant['refresh_token']) : null;
210
		$this->id_token = isset($this->grant['id_token']) ? new Token($this->grant['id_token']) : null;
211
212
		return true;
213
	}
214
215
	/**
216
	 * Validates the grant represented by the access and refresh tokens in the grant.
217
	 * If the refresh token has expired too, return false.
218
	 *
219
	 * @return bool
220
	 */
221
	public function validate_grant() {
222
		if ($this->validate_token($this->access_token) && $this->validate_token($this->refresh_token)) {
223
			return true;
224
		}
225
226
		return $this->refresh_grant_req();
227
	}
228
229
	/**
230
	 * Validates a token with the server.
231
	 *
232
	 * @param mixed $token
233
	 *
234
	 * @return bool
235
	 */
236
	protected function validate_token($token) {
237
		if (isset($token)) {
238
			$path = "/protocol/openid-connect/token/introspect";
239
			$headers = ['Content-Type: application/x-www-form-urlencoded'];
240
			$params = ['token' => $token->get_payload()];
241
			if ($this->is_public) {
242
				$params['client_id'] = $this->client_id;
243
			}
244
			else {
245
				array_push($headers, 'Authorization: Basic ' . base64_encode($this->client_id . ':' . $this->secret));
246
			}
247
			$response = $this->http_curl_request('POST', $path, $headers, http_build_query($params));
248
249
			if ($response['code'] < 200 || $response['code'] > 299) {
250
				return false;
251
			}
252
253
			try {
254
				$data = json_decode($response['body'], true);
255
			}
256
			catch (Exception $e) {
257
				return false;
258
			}
259
260
			return !array_key_exists('error', $data);
261
		}
262
263
		return false;
264
	}
265
266
	/**
267
	 * Indicates if the access token is expired.
268
	 *
269
	 * @return bool
270
	 */
271
	public function is_expired() {
272
		if (!$this->access_token) {
273
			return true;
274
		}
275
276
		return $this->access_token->is_expired();
277
	}
278
279
	/**
280
	 * Builds a KeyCloak login url used with the client credential code.
281
	 *
282
	 * @param string $redirect_url Redirect URL to be parameterized in the URL
283
	 *
284
	 * @return string
285
	 */
286
	public function login_url($redirect_url) {
287
		$uuid = bin2hex(openssl_random_pseudo_bytes(32));
288
289
		return $this->realm_url . '/protocol/openid-connect/auth?scope=openid&client_id=' . urlencode($this->client_id) . '&state=' . urlencode($uuid) . '&redirect_uri=' . urlencode($redirect_url) . '&response_type=code';
290
	}
291
292
	/**
293
	 * Builds a KeyCloak logout url.
294
	 *
295
	 * @return string
296
	 */
297
	public function logout() {
298
		$params = '?id_token_hint=' . $this->id_token->get_payload() . '&refresh_token=' . $this->refresh_token->get_payload();
299
300
		return $this->realm_url . '/protocol/openid-connect/logout' . $params;
301
	}
302
303
	/*
304
	 * Send HTTP request via CURL.
305
	 *
306
	 * @param string $method The HTTP request to use. (Default to GET)
307
	 * @param array $headers The HTTP headers to be passed into the request
308
	 * @param string $data The data to be passed into the body of the request
309
	 * @param string $domain
310
	 *
311
	 * @return array associative array with 'code' for response code and 'body' for request body
312
	 */
313
	protected function http_curl_request($method, $domain, $headers = [], $data = '') {
314
		$request = curl_init();
315
		curl_setopt($request, CURLOPT_URL, $this->realm_url . $domain);
316
		if (strcmp(strtoupper($method), 'POST') == 0) {
317
			curl_setopt($request, CURLOPT_POST, true);
318
			curl_setopt($request, CURLOPT_POSTFIELDS, $data);
319
			array_push($headers, 'Content-Length: ' . strlen($data));
320
		}
321
322
		curl_setopt($request, CURLOPT_HTTPHEADER, $headers);
323
		curl_setopt($request, CURLOPT_RETURNTRANSFER, true);
324
325
		$response = curl_exec($request);
326
		$response_code = curl_getinfo($request, CURLINFO_HTTP_CODE);
327
		curl_close($request);
328
329
		return [
330
			'code' => $response_code,
331
			'body' => $response,
332
		];
333
	}
334
}
335