Passed
Push — master ( de481c...31eb82 )
by
unknown
12:45 queued 15s
created

class.keycloak.php (1 issue)

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