Rev 1367 | Details | Compare with Previous | Last modification | View Log | RSS feed
Rev | Author | Line No. | Line |
---|---|---|---|
566 | daniel-mar | 1 | <?php |
2 | |||
3 | /* |
||
4 | * OIDplus 2.0 |
||
1086 | daniel-mar | 5 | * Copyright 2019 - 2023 Daniel Marschall, ViaThinkSoft |
566 | daniel-mar | 6 | * |
7 | * Licensed under the Apache License, Version 2.0 (the "License"); |
||
8 | * you may not use this file except in compliance with the License. |
||
9 | * You may obtain a copy of the License at |
||
10 | * |
||
11 | * http://www.apache.org/licenses/LICENSE-2.0 |
||
12 | * |
||
13 | * Unless required by applicable law or agreed to in writing, software |
||
14 | * distributed under the License is distributed on an "AS IS" BASIS, |
||
15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||
16 | * See the License for the specific language governing permissions and |
||
17 | * limitations under the License. |
||
18 | */ |
||
19 | |||
1050 | daniel-mar | 20 | namespace ViaThinkSoft\OIDplus; |
566 | daniel-mar | 21 | |
1086 | daniel-mar | 22 | // phpcs:disable PSR1.Files.SideEffects |
23 | \defined('INSIDE_OIDPLUS') or die; |
||
24 | // phpcs:enable PSR1.Files.SideEffects |
||
25 | |||
1303 | daniel-mar | 26 | /** |
1305 | daniel-mar | 27 | * Auth content store for JWT tokens (web browser login cookies, Automated AJAX argument, or REST Bearer) |
1303 | daniel-mar | 28 | */ |
1306 | daniel-mar | 29 | class OIDplusAuthContentStoreJWT implements OIDplusGetterSetterInterface { |
566 | daniel-mar | 30 | |
1130 | daniel-mar | 31 | /** |
32 | * Cookie name for the JWT auth token |
||
33 | */ |
||
585 | daniel-mar | 34 | const COOKIE_NAME = 'OIDPLUS_AUTH_JWT'; |
35 | |||
1130 | daniel-mar | 36 | /** |
1305 | daniel-mar | 37 | * Token generator; must be one of OIDplusAuthContentStoreJWT::JWT_GENERATOR_* |
38 | */ |
||
39 | const CLAIM_GENERATOR = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.1'; |
||
40 | |||
41 | /** |
||
42 | * List of logged-in users |
||
43 | */ |
||
44 | const CLAIM_LOGIN_LIST = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.2'; |
||
45 | |||
46 | /** |
||
47 | * SSH = Server Secret Hash |
||
48 | */ |
||
49 | const CLAIM_SSH = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.3'; |
||
50 | |||
51 | /** |
||
52 | * IP-Adress limit |
||
53 | */ |
||
54 | const CLAIM_LIMIT_IP = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.4'; |
||
55 | |||
56 | /** |
||
1310 | daniel-mar | 57 | * Trace JTI, IP, and UserAgent |
58 | */ |
||
59 | const CLAIM_TRACE = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.5'; |
||
60 | |||
61 | /** |
||
1130 | daniel-mar | 62 | * "Automated AJAX" plugin |
63 | */ |
||
1265 | daniel-mar | 64 | const JWT_GENERATOR_AJAX = 10; |
1130 | daniel-mar | 65 | /** |
1265 | daniel-mar | 66 | * "REST API" plugin |
67 | */ |
||
68 | const JWT_GENERATOR_REST = 20; |
||
69 | /** |
||
1305 | daniel-mar | 70 | * Web browser login method |
1130 | daniel-mar | 71 | */ |
1265 | daniel-mar | 72 | const JWT_GENERATOR_LOGIN = 40; |
1130 | daniel-mar | 73 | /** |
74 | * "Manually crafted" JWT tokens |
||
75 | */ |
||
1265 | daniel-mar | 76 | const JWT_GENERATOR_MANUAL = 80; |
585 | daniel-mar | 77 | |
1116 | daniel-mar | 78 | /** |
1317 | daniel-mar | 79 | * @return string |
80 | */ |
||
81 | public static function getAudIss(): string { |
||
82 | $oid = OIDplus::getSystemId(true); |
||
83 | if ($oid !== false) return 'urn:oid:'.$oid; |
||
84 | $url = OIDplus::webpath(null, OIDplus::PATH_ABSOLUTE_CANONICAL); |
||
85 | if ($url) return $url; |
||
86 | return 'http://oidplus.com/'; |
||
87 | } |
||
88 | |||
89 | /** |
||
1116 | daniel-mar | 90 | * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_... |
91 | * @param string $sub |
||
92 | * @return string |
||
93 | */ |
||
94 | private static function jwtGetBlacklistConfigKey(int $gen, string $sub): string { |
||
1265 | daniel-mar | 95 | // Note: Needs to be <= 50 characters! If $gen is 2 chars, then the config key is 49 chars long |
585 | daniel-mar | 96 | return 'jwt_blacklist_gen('.$gen.')_sub('.trim(base64_encode(md5($sub,true)),'=').')'; |
97 | } |
||
98 | |||
1116 | daniel-mar | 99 | /** |
1265 | daniel-mar | 100 | * @param int $gen |
101 | */ |
||
102 | private static function generatorName($gen) { |
||
103 | // Note: The strings are not translated, because the name is used in config keys or logs |
||
104 | if ($gen === self::JWT_GENERATOR_AJAX) return 'Automated AJAX calls'; |
||
105 | if ($gen === self::JWT_GENERATOR_REST) return 'REST API'; |
||
1305 | daniel-mar | 106 | if ($gen === self::JWT_GENERATOR_LOGIN) return 'Browser login'; |
1265 | daniel-mar | 107 | if ($gen === self::JWT_GENERATOR_MANUAL) return 'Manually created'; |
108 | return 'Unknown generator'; |
||
109 | } |
||
110 | |||
111 | /** |
||
1116 | daniel-mar | 112 | * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_... |
113 | * @param string $sub |
||
114 | * @return void |
||
115 | * @throws OIDplusException |
||
116 | */ |
||
117 | public static function jwtBlacklist(int $gen, string $sub) { |
||
585 | daniel-mar | 118 | $cfg = self::jwtGetBlacklistConfigKey($gen, $sub); |
119 | $bl_time = time()-1; |
||
120 | |||
1265 | daniel-mar | 121 | $gen_desc = self::generatorName($gen); |
585 | daniel-mar | 122 | |
1305 | daniel-mar | 123 | OIDplus::config()->prepareConfigKey($cfg, "Revoke timestamp of all JWT tokens for $sub with generator $gen ($gen_desc)", "$bl_time", OIDplusConfig::PROTECTION_HIDDEN, function($value) {}); |
585 | daniel-mar | 124 | OIDplus::config()->setValue($cfg, $bl_time); |
125 | } |
||
126 | |||
1116 | daniel-mar | 127 | /** |
128 | * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_... |
||
1321 | daniel-mar | 129 | * @param string $sub E-Mail-Adress of RA or 'admin' |
1116 | daniel-mar | 130 | * @return int |
131 | * @throws OIDplusException |
||
132 | */ |
||
133 | public static function jwtGetBlacklistTime(int $gen, string $sub): int { |
||
585 | daniel-mar | 134 | $cfg = self::jwtGetBlacklistConfigKey($gen, $sub); |
1116 | daniel-mar | 135 | return (int)OIDplus::config()->getValue($cfg,0); |
585 | daniel-mar | 136 | } |
137 | |||
1116 | daniel-mar | 138 | /** |
1298 | daniel-mar | 139 | * We include a hash of the server-secret here (ssh = server-secret-hash), so that the JWT can be invalidated by changing the server-secret |
140 | * @return string |
||
141 | * @throws OIDplusException |
||
142 | */ |
||
143 | private static function getSsh(): string { |
||
1305 | daniel-mar | 144 | $hexadecimal_string = OIDplus::authUtils()->makeSecret(['bb1aebd6-fe6a-11ed-a553-3c4a92df8582']); |
145 | return base64_encode(pack('H*',$hexadecimal_string)); |
||
1298 | daniel-mar | 146 | } |
147 | |||
148 | /** |
||
1265 | daniel-mar | 149 | * Do various checks if the token is allowed and not blacklisted |
1305 | daniel-mar | 150 | * @param OIDplusAuthContentStoreJWT $contentProvider |
1277 | daniel-mar | 151 | * @param int|null $validGenerators Bitmask which generators to allow (null = allow all) |
1116 | daniel-mar | 152 | * @return void |
153 | * @throws OIDplusException |
||
154 | */ |
||
1305 | daniel-mar | 155 | private static function jwtSecurityCheck(OIDplusAuthContentStoreJWT $contentProvider, int $validGenerators=null) { |
585 | daniel-mar | 156 | // Check if the token is intended for us |
1321 | daniel-mar | 157 | // Note 'aud' is mandatory for OIDplus, so we do not check for exists() |
1317 | daniel-mar | 158 | if ($contentProvider->getValue('aud','') !== $contentProvider->getAudIss()) { |
1318 | daniel-mar | 159 | throw new OIDplusException(_L('Token has wrong audience: Given %1 but expected %2.', $contentProvider->getValue('aud',''), $contentProvider->getAudIss())); |
585 | daniel-mar | 160 | } |
1298 | daniel-mar | 161 | |
1321 | daniel-mar | 162 | // Note CLAIM_SSH is mandatory for OIDplus, so we do not check for exists() |
1305 | daniel-mar | 163 | if ($contentProvider->getValue(self::CLAIM_SSH, '') !== self::getSsh()) { |
1298 | daniel-mar | 164 | throw new OIDplusException(_L('"Server Secret" was changed; therefore the JWT is not valid anymore')); |
165 | } |
||
166 | |||
1321 | daniel-mar | 167 | // Note CLAIM_GENERATOR is mandatory for OIDplus, so we do not check for exists() |
1305 | daniel-mar | 168 | $gen = $contentProvider->getValue(self::CLAIM_GENERATOR, -1); |
585 | daniel-mar | 169 | |
170 | $has_admin = $contentProvider->isAdminLoggedIn(); |
||
171 | $has_ra = $contentProvider->raNumLoggedIn() > 0; |
||
172 | |||
173 | // Check if the token generator is allowed |
||
174 | if ($gen === self::JWT_GENERATOR_AJAX) { |
||
175 | if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_ADMIN', true)) { |
||
635 | daniel-mar | 176 | // Generator: plugins/viathinksoft/adminPages/910_automated_ajax_calls/OIDplusPageAdminAutomatedAJAXCalls.class.php |
585 | daniel-mar | 177 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_ADMIN')); |
178 | } |
||
179 | if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_USER', true)) { |
||
635 | daniel-mar | 180 | // Generator: plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php |
585 | daniel-mar | 181 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_USER')); |
182 | } |
||
183 | } |
||
1265 | daniel-mar | 184 | else if ($gen === self::JWT_GENERATOR_REST) { |
185 | if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) { |
||
186 | // Generator: plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.class.php |
||
187 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN')); |
||
188 | } |
||
189 | if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) { |
||
190 | // Generator: plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.class.php |
||
191 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER')); |
||
192 | } |
||
193 | } |
||
585 | daniel-mar | 194 | else if ($gen === self::JWT_GENERATOR_LOGIN) { |
1305 | daniel-mar | 195 | // Used for web browser login (use JWT token in a cookie as alternative to PHP session): |
585 | daniel-mar | 196 | // - No PHP session will be used |
197 | // - Session will not be bound to IP address (therefore, you can switch between mobile/WiFi for example) |
||
198 | // - No server-side session needed |
||
199 | if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) { |
||
200 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN')); |
||
201 | } |
||
202 | if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) { |
||
203 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER')); |
||
204 | } |
||
205 | } |
||
206 | else if ($gen === self::JWT_GENERATOR_MANUAL) { |
||
1300 | daniel-mar | 207 | // Generator: "hand-crafted" tokens |
208 | if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL_ADMIN', false)) { |
||
209 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL_ADMIN')); |
||
585 | daniel-mar | 210 | } |
1300 | daniel-mar | 211 | if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL_USER', false)) { |
212 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL_USER')); |
||
213 | } |
||
585 | daniel-mar | 214 | } else { |
215 | throw new OIDplusException(_L('Token generator %1 not recognized',$gen)); |
||
216 | } |
||
217 | |||
1307 | daniel-mar | 218 | // Every token must have and issued timestamp |
219 | $iat = $contentProvider->getValue('iat',null); |
||
220 | if (is_null($iat)) { |
||
221 | throw new OIDplusException(_L('The claim "%1" of the JWT token is missing or invalid','iat')); |
||
222 | } |
||
223 | |||
224 | // Verify that IAT has a valid value |
||
225 | // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure. |
||
226 | if (($iat-120/*leeway 2min*/) > time()) { |
||
227 | // Token was created in the future. Something is wrong! |
||
228 | throw new OIDplusException(_L('JWT Token cannot be verified because the server time is wrong')); |
||
229 | } |
||
230 | |||
231 | // Check if token is not yet valid |
||
232 | // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure. |
||
233 | $nbf = $contentProvider->getValue('nbf',null); |
||
234 | if (!is_null($nbf)) { |
||
235 | if (time() < $nbf-120/*leeway 2min*/) { |
||
236 | throw new OIDplusException(_L('Token not valid before %1',date('d F Y, H:i:s',$nbf))); |
||
237 | } |
||
238 | } |
||
239 | |||
1306 | daniel-mar | 240 | // Check if token has expired |
1307 | daniel-mar | 241 | // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure. |
1306 | daniel-mar | 242 | $exp = $contentProvider->getValue('exp',null); |
243 | if (!is_null($exp)) { |
||
1307 | daniel-mar | 244 | if (time() > $exp+120/*leeway 2min*/) { |
1306 | daniel-mar | 245 | throw new OIDplusException(_L('Token has expired on %1',date('d F Y, H:i:s',$exp))); |
246 | } |
||
247 | } |
||
248 | |||
585 | daniel-mar | 249 | // Make sure that the IAT (issued at time) isn't in a blacklisted timeframe |
250 | // When an user believes that a token was compromised, then they can blacklist the tokens identified by their "iat" ("Issued at") property |
||
1305 | daniel-mar | 251 | // When a user logs out of a web browser session, the JWT token will be blacklisted as well |
252 | // Small side effect: All web browser login sessions of that user will be revoked then |
||
585 | daniel-mar | 253 | $sublist = $contentProvider->loggedInRaList(); |
1281 | daniel-mar | 254 | $usernames = array(); |
255 | foreach ($sublist as $sub) { |
||
256 | $usernames[] = $sub->raEmail(); |
||
585 | daniel-mar | 257 | } |
1281 | daniel-mar | 258 | if ($has_admin) $usernames[] = 'admin'; |
259 | foreach ($usernames as $username) { |
||
260 | $bl_time = self::jwtGetBlacklistTime($gen, $username); |
||
585 | daniel-mar | 261 | if ($iat <= $bl_time) { |
1281 | daniel-mar | 262 | // Token is blacklisted (it was created before the last blacklist time) |
585 | daniel-mar | 263 | throw new OIDplusException(_L('The JWT token was blacklisted on %1. Please generate a new one',date('d F Y, H:i:s',$bl_time))); |
264 | } |
||
265 | } |
||
266 | |||
1312 | daniel-mar | 267 | // Optional feature: Limit the JWT to a specific IP address (used if JWT_FIXED_IP_USER or JWT_FIXED_IP_ADMIN is true) |
1305 | daniel-mar | 268 | $ip = $contentProvider->getValue(self::CLAIM_LIMIT_IP, null); |
269 | if (!is_null($ip)) { |
||
1345 | daniel-mar | 270 | if ($ip !== OIDplus::getClientIpAddress()) { |
585 | daniel-mar | 271 | throw new OIDplusException(_L('Your IP address is not allowed to use this token')); |
272 | } |
||
273 | } |
||
274 | |||
1265 | daniel-mar | 275 | // Checks if JWT are dependent on the generator |
1277 | daniel-mar | 276 | if (!is_null($validGenerators)) { |
1265 | daniel-mar | 277 | if (($gen & $validGenerators) === 0) { |
278 | throw new OIDplusException(_L('This kind of JWT token (%1) cannot be used in this request type', self::generatorName($gen))); |
||
585 | daniel-mar | 279 | } |
280 | } |
||
281 | } |
||
282 | |||
283 | // Override abstract functions |
||
284 | |||
1116 | daniel-mar | 285 | /** |
1301 | daniel-mar | 286 | * @var array |
287 | */ |
||
288 | protected $content = array(); |
||
289 | |||
290 | /** |
||
291 | * @param string $name |
||
292 | * @param mixed|null $default |
||
293 | * @return mixed|null |
||
294 | */ |
||
295 | public function getValue(string $name, $default = NULL) { |
||
296 | return $this->content[$name] ?? $default; |
||
297 | } |
||
298 | |||
299 | /** |
||
300 | * @param string $name |
||
301 | * @param mixed $value |
||
1116 | daniel-mar | 302 | * @return void |
303 | */ |
||
1301 | daniel-mar | 304 | public function setValue(string $name, $value) { |
305 | $this->content[$name] = $value; |
||
306 | } |
||
307 | |||
308 | /** |
||
309 | * @param string $name |
||
310 | * @return bool |
||
311 | */ |
||
312 | public function exists(string $name): bool { |
||
313 | return isset($this->content[$name]); |
||
314 | } |
||
315 | |||
316 | /** |
||
317 | * @param string $name |
||
318 | * @return void |
||
319 | */ |
||
320 | public function delete(string $name) { |
||
321 | unset($this->content[$name]); |
||
322 | } |
||
323 | |||
324 | /** |
||
325 | * @return void |
||
326 | */ |
||
585 | daniel-mar | 327 | public function activate() { |
620 | daniel-mar | 328 | // Send cookie at the end of the HTTP request, in case there are multiple activate() calls |
639 | daniel-mar | 329 | OIDplus::register_shutdown_function(array($this,'activateNow')); |
620 | daniel-mar | 330 | } |
331 | |||
1116 | daniel-mar | 332 | /** |
333 | * @return void |
||
334 | * @throws OIDplusException |
||
335 | */ |
||
620 | daniel-mar | 336 | public function activateNow() { |
585 | daniel-mar | 337 | $token = $this->getJWTToken(); |
338 | $exp = $this->getValue('exp',0); |
||
339 | OIDplus::cookieUtils()->setcookie(self::COOKIE_NAME, $token, $exp, false); |
||
340 | } |
||
341 | |||
1116 | daniel-mar | 342 | /** |
343 | * @return void |
||
344 | * @throws OIDplusException |
||
345 | */ |
||
585 | daniel-mar | 346 | public function destroySession() { |
347 | OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME); |
||
348 | } |
||
349 | |||
1314 | daniel-mar | 350 | /** |
351 | * @param string[] $ra RAs |
||
352 | * @param bool $admin Admin yes or no? |
||
353 | * @param int $gen Generator |
||
354 | * @param bool $limit_ip Limit IP to the current IP address? |
||
355 | * @param int $ttl How many seconds valid? |
||
356 | * @return string JWT token |
||
357 | */ |
||
358 | public static function craftJWT(array $ra, bool $admin, int $gen, bool $limit_ip=false, int $ttl=10*365*24*60*60): string { |
||
359 | $authSimulation = new OIDplusAuthContentStoreJWT(); |
||
360 | foreach ($ra as $username) { |
||
361 | if ($username == 'admin') continue; |
||
362 | $authSimulation->raLogin($username); |
||
363 | } |
||
364 | if ($admin) $authSimulation->adminLogin(); |
||
365 | $authSimulation->setValue(OIDplusAuthContentStoreJWT::CLAIM_GENERATOR, $gen); |
||
366 | $authSimulation->setValue('exp', time()+$ttl); |
||
1345 | daniel-mar | 367 | if ($limit_ip) { |
368 | $cur_ip = OIDplus::getClientIpAddress(); |
||
369 | if ($cur_ip !== false) { |
||
370 | $authSimulation->setValue(self::CLAIM_LIMIT_IP, $cur_ip); |
||
371 | } |
||
1314 | daniel-mar | 372 | } |
373 | return $authSimulation->getJWTToken(); |
||
374 | } |
||
375 | |||
1305 | daniel-mar | 376 | // RA authentication functions (low-level) |
377 | |||
1116 | daniel-mar | 378 | /** |
379 | * @param string $email |
||
380 | * @return void |
||
1305 | daniel-mar | 381 | */ |
382 | public function raLogin(string $email) { |
||
383 | if ($email == 'admin') return; |
||
384 | |||
385 | $list = $this->getValue(self::CLAIM_LOGIN_LIST, null); |
||
386 | if (is_null($list)) $list = []; |
||
387 | if (!in_array($email, $list)) $list[] = $email; |
||
388 | $this->setValue(self::CLAIM_LOGIN_LIST, $list); |
||
389 | } |
||
390 | |||
391 | /** |
||
392 | * @return int |
||
393 | */ |
||
394 | public function raNumLoggedIn(): int { |
||
395 | return count($this->loggedInRaList()); |
||
396 | } |
||
397 | |||
398 | /** |
||
399 | * @return OIDplusRA[] |
||
400 | */ |
||
401 | public function loggedInRaList(): array { |
||
402 | $list = $this->getValue(self::CLAIM_LOGIN_LIST, null); |
||
403 | if (is_null($list)) $list = []; |
||
404 | |||
405 | $res = array(); |
||
406 | foreach (array_unique($list) as $username) { |
||
407 | if ($username == '') continue; // should not happen |
||
408 | if ($username == 'admin') continue; |
||
409 | $res[] = new OIDplusRA($username); |
||
410 | } |
||
411 | return $res; |
||
412 | } |
||
413 | |||
414 | /** |
||
415 | * @param string $email |
||
416 | * @return bool |
||
417 | */ |
||
418 | public function isRaLoggedIn(string $email): bool { |
||
419 | foreach ($this->loggedInRaList() as $ra) { |
||
420 | if ($email == $ra->raEmail()) return true; |
||
421 | } |
||
422 | return false; |
||
423 | } |
||
424 | |||
425 | /** |
||
426 | * @param string $email |
||
427 | * @return void |
||
1116 | daniel-mar | 428 | * @throws OIDplusException |
429 | */ |
||
430 | public function raLogout(string $email) { |
||
1305 | daniel-mar | 431 | if ($email == 'admin') return; |
432 | |||
433 | $gen = $this->getValue(self::CLAIM_GENERATOR, -1); |
||
585 | daniel-mar | 434 | if ($gen >= 0) self::jwtBlacklist($gen, $email); |
1305 | daniel-mar | 435 | |
436 | $list = $this->getValue(self::CLAIM_LOGIN_LIST, null); |
||
437 | if (is_null($list)) $list = []; |
||
438 | $key = array_search($email, $list); |
||
439 | if ($key !== false) unset($list[$key]); |
||
440 | $this->setValue(self::CLAIM_LOGIN_LIST, $list); |
||
585 | daniel-mar | 441 | } |
442 | |||
1116 | daniel-mar | 443 | /** |
444 | * @param string $email |
||
445 | * @param string $loginfo |
||
446 | * @return void |
||
447 | * @throws OIDplusException |
||
448 | */ |
||
449 | public function raLogoutEx(string $email, string &$loginfo) { |
||
585 | daniel-mar | 450 | $this->raLogout($email); |
451 | $loginfo = 'from JWT session'; |
||
452 | } |
||
453 | |||
1305 | daniel-mar | 454 | // Admin authentication functions (low-level) |
455 | |||
1116 | daniel-mar | 456 | /** |
457 | * @return void |
||
1305 | daniel-mar | 458 | */ |
459 | public function adminLogin() { |
||
460 | $list = $this->getValue(self::CLAIM_LOGIN_LIST, null); |
||
461 | if (is_null($list)) $list = []; |
||
462 | if (!in_array('admin', $list)) $list[] = 'admin'; |
||
463 | $this->setValue(self::CLAIM_LOGIN_LIST, $list); |
||
464 | } |
||
465 | |||
466 | /** |
||
467 | * @return bool |
||
468 | */ |
||
469 | public function isAdminLoggedIn(): bool { |
||
470 | $list = $this->getValue(self::CLAIM_LOGIN_LIST, null); |
||
471 | if (is_null($list)) $list = []; |
||
472 | return in_array('admin', $list); |
||
473 | } |
||
474 | |||
475 | /** |
||
476 | * @return void |
||
1116 | daniel-mar | 477 | * @throws OIDplusException |
478 | */ |
||
585 | daniel-mar | 479 | public function adminLogout() { |
1305 | daniel-mar | 480 | $gen = $this->getValue(self::CLAIM_GENERATOR, -1); |
585 | daniel-mar | 481 | if ($gen >= 0) self::jwtBlacklist($gen, 'admin'); |
1305 | daniel-mar | 482 | |
483 | $list = $this->getValue(self::CLAIM_LOGIN_LIST, null); |
||
484 | if (is_null($list)) $list = []; |
||
485 | $key = array_search('admin', $list); |
||
486 | if ($key !== false) unset($list[$key]); |
||
487 | $this->setValue(self::CLAIM_LOGIN_LIST, $list); |
||
585 | daniel-mar | 488 | } |
489 | |||
1116 | daniel-mar | 490 | /** |
491 | * @param string $loginfo |
||
492 | * @return void |
||
493 | * @throws OIDplusException |
||
494 | */ |
||
495 | public function adminLogoutEx(string &$loginfo) { |
||
585 | daniel-mar | 496 | $this->adminLogout(); |
497 | $loginfo = 'from JWT session'; |
||
498 | } |
||
499 | |||
620 | daniel-mar | 500 | private static $contentProvider = null; |
1116 | daniel-mar | 501 | |
502 | /** |
||
1305 | daniel-mar | 503 | * @return OIDplusAuthContentStoreJWT|null |
1116 | daniel-mar | 504 | * @throws OIDplusException |
505 | */ |
||
1305 | daniel-mar | 506 | public static function getActiveProvider()/*: ?OIDplusAuthContentStoreJWT*/ { |
620 | daniel-mar | 507 | if (!self::$contentProvider) { |
585 | daniel-mar | 508 | |
1265 | daniel-mar | 509 | $tmp = null; |
510 | $silent_error = false; |
||
585 | daniel-mar | 511 | |
1265 | daniel-mar | 512 | try { |
585 | daniel-mar | 513 | |
1367 | daniel-mar | 514 | if (isset($_SERVER['REQUEST_URI'])) { |
515 | $rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT))); |
||
516 | $only_use_bearer = str_starts_with($rel_url, 'rest/'); // <== TODO: Find a way how to move this into the plugin, since REST does not belong to the core. |
||
517 | } else { |
||
518 | $only_use_bearer = false; |
||
519 | } |
||
1265 | daniel-mar | 520 | |
1367 | daniel-mar | 521 | if ($only_use_bearer) { |
522 | |||
1265 | daniel-mar | 523 | // REST may only use Bearer Authentication |
524 | $bearer = getBearerToken(); |
||
525 | if (!is_null($bearer)) { |
||
526 | $silent_error = false; |
||
527 | $tmp = new OIDplusAuthContentStoreJWT(); |
||
528 | $tmp->loadJWT($bearer); |
||
529 | self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_REST | self::JWT_GENERATOR_MANUAL); |
||
585 | daniel-mar | 530 | } |
1265 | daniel-mar | 531 | |
532 | } else { |
||
533 | |||
1305 | daniel-mar | 534 | // A web-visitor (HTML and AJAX, but not REST) can use a JWT Cookie |
1265 | daniel-mar | 535 | if (isset($_COOKIE[self::COOKIE_NAME])) { |
536 | $silent_error = true; |
||
537 | $tmp = new OIDplusAuthContentStoreJWT(); |
||
538 | $tmp->loadJWT($_COOKIE[self::COOKIE_NAME]); |
||
539 | self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_LOGIN | self::JWT_GENERATOR_MANUAL); |
||
540 | } |
||
541 | |||
1305 | daniel-mar | 542 | // AJAX may additionally use GET/POST automated AJAX (in addition to the normal web browser login Cookie) |
1265 | daniel-mar | 543 | if (isset($_SERVER['SCRIPT_FILENAME']) && (strtolower(basename($_SERVER['SCRIPT_FILENAME'])) !== 'ajax.php')) { |
544 | if (isset($_POST[self::COOKIE_NAME])) { |
||
545 | $silent_error = false; |
||
546 | $tmp = new OIDplusAuthContentStoreJWT(); |
||
547 | $tmp->loadJWT($_POST[self::COOKIE_NAME]); |
||
548 | self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL); |
||
549 | } |
||
550 | if (isset($_GET[self::COOKIE_NAME])) { |
||
551 | $silent_error = false; |
||
552 | $tmp = new OIDplusAuthContentStoreJWT(); |
||
553 | $tmp->loadJWT($_GET[self::COOKIE_NAME]); |
||
554 | self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL); |
||
555 | } |
||
556 | } |
||
557 | |||
585 | daniel-mar | 558 | } |
559 | |||
1265 | daniel-mar | 560 | } catch (\Exception $e) { |
1306 | daniel-mar | 561 | if (!$silent_error || OIDplus::baseConfig()->getValue('DEBUG',false)) { |
1265 | daniel-mar | 562 | // Most likely an AJAX request. We can throw an Exception |
1317 | daniel-mar | 563 | if (OIDplus::baseConfig()->getValue('DEBUG',false)) { |
564 | OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME); |
||
565 | } |
||
1265 | daniel-mar | 566 | throw new OIDplusException(_L('The JWT token was rejected: %1',$e->getMessage())); |
567 | } else { |
||
568 | // Most likely an expired Cookie/Login session. We must not throw an Exception, otherwise we will break jsTree |
||
569 | OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME); |
||
570 | return null; |
||
571 | } |
||
585 | daniel-mar | 572 | } |
1265 | daniel-mar | 573 | |
574 | self::$contentProvider = $tmp; |
||
585 | daniel-mar | 575 | } |
576 | |||
620 | daniel-mar | 577 | return self::$contentProvider; |
585 | daniel-mar | 578 | } |
579 | |||
1116 | daniel-mar | 580 | /** |
581 | * @param string $email |
||
582 | * @param string $loginfo |
||
583 | * @return void |
||
584 | * @throws OIDplusException |
||
585 | */ |
||
586 | public function raLoginEx(string $email, string &$loginfo) { |
||
585 | daniel-mar | 587 | if (is_null(self::getActiveProvider())) { |
588 | $loginfo = 'into new JWT session'; |
||
620 | daniel-mar | 589 | self::$contentProvider = $this; |
585 | daniel-mar | 590 | } else { |
1305 | daniel-mar | 591 | $gen = $this->getValue(self::CLAIM_GENERATOR,-1); |
585 | daniel-mar | 592 | switch ($gen) { |
593 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX : |
||
1265 | daniel-mar | 594 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST : |
585 | daniel-mar | 595 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL : |
596 | throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.')); |
||
597 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN : |
||
598 | if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) { |
||
1305 | daniel-mar | 599 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER')); |
585 | daniel-mar | 600 | } |
601 | break; |
||
602 | default: |
||
603 | assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators |
||
604 | break; |
||
605 | } |
||
606 | $loginfo = 'into existing JWT session'; |
||
607 | } |
||
1315 | daniel-mar | 608 | $this->raLogin($email); |
1312 | daniel-mar | 609 | $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 30*24*60*60); |
1306 | daniel-mar | 610 | $this->setValue('exp', time()+$ttl); // JWT "exp" attribute |
1345 | daniel-mar | 611 | if (OIDplus::baseConfig()->getValue('JWT_FIXED_IP_USER', false)) { |
612 | $cur_ip = OIDplus::getClientIpAddress(); |
||
613 | if ($cur_ip !== false) { |
||
614 | $this->setValue(self::CLAIM_LIMIT_IP, $cur_ip); |
||
615 | } |
||
1312 | daniel-mar | 616 | } |
585 | daniel-mar | 617 | } |
618 | |||
1116 | daniel-mar | 619 | /** |
620 | * @param string $loginfo |
||
621 | * @return void |
||
622 | * @throws OIDplusException |
||
623 | */ |
||
624 | public function adminLoginEx(string &$loginfo) { |
||
585 | daniel-mar | 625 | if (is_null(self::getActiveProvider())) { |
626 | $loginfo = 'into new JWT session'; |
||
620 | daniel-mar | 627 | self::$contentProvider = $this; |
585 | daniel-mar | 628 | } else { |
1305 | daniel-mar | 629 | $gen = $this->getValue(self::CLAIM_GENERATOR,-1); |
585 | daniel-mar | 630 | switch ($gen) { |
631 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX : |
||
1265 | daniel-mar | 632 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST : |
585 | daniel-mar | 633 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL : |
634 | throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.')); |
||
635 | case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN : |
||
636 | if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) { |
||
1305 | daniel-mar | 637 | throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN')); |
585 | daniel-mar | 638 | } |
639 | break; |
||
640 | default: |
||
641 | assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators |
||
642 | break; |
||
643 | } |
||
644 | $loginfo = 'into existing JWT session'; |
||
645 | } |
||
1315 | daniel-mar | 646 | $this->adminLogin(); |
1312 | daniel-mar | 647 | $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 30*24*60*60); |
1306 | daniel-mar | 648 | $this->setValue('exp', time()+$ttl); // JWT "exp" attribute |
1345 | daniel-mar | 649 | if (OIDplus::baseConfig()->getValue('JWT_FIXED_IP_ADMIN', false)) { |
650 | $cur_ip = OIDplus::getClientIpAddress(); |
||
651 | if ($cur_ip !== false) { |
||
652 | $this->setValue(self::CLAIM_LIMIT_IP, $cur_ip); |
||
653 | } |
||
1312 | daniel-mar | 654 | } |
585 | daniel-mar | 655 | } |
656 | |||
566 | daniel-mar | 657 | // Individual functions |
658 | |||
1116 | daniel-mar | 659 | /** |
1265 | daniel-mar | 660 | * Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked |
1116 | daniel-mar | 661 | * @param string $jwt |
662 | * @return void |
||
663 | * @throws OIDplusException |
||
664 | */ |
||
665 | public function loadJWT(string $jwt) { |
||
571 | daniel-mar | 666 | \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds |
1444 | daniel-mar | 667 | $cls_content = null; |
570 | daniel-mar | 668 | if (OIDplus::getPkiStatus()) { |
830 | daniel-mar | 669 | $pubKey = OIDplus::getSystemPublicKey(); |
1298 | daniel-mar | 670 | $k = new \Firebase\JWT\Key($pubKey, 'RS256'); // RSA+SHA256 is hardcoded in getPkiStatus() generation |
1444 | daniel-mar | 671 | $cls_content = \Firebase\JWT\JWT::decode($jwt, $k); |
570 | daniel-mar | 672 | } else { |
1283 | daniel-mar | 673 | $key = OIDplus::authUtils()->makeSecret(['0be35e52-f4ef-11ed-b67e-3c4a92df8582']); |
826 | daniel-mar | 674 | $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false); |
679 | daniel-mar | 675 | $k = new \Firebase\JWT\Key($key, 'HS512'); // HMAC+SHA512 is hardcoded here |
1444 | daniel-mar | 676 | $cls_content = \Firebase\JWT\JWT::decode($jwt, $k); |
570 | daniel-mar | 677 | } |
1444 | daniel-mar | 678 | $this->content = json_decode(json_encode($cls_content), true); // convert stdClass to array |
566 | daniel-mar | 679 | } |
680 | |||
1116 | daniel-mar | 681 | /** |
682 | * @return string |
||
683 | * @throws OIDplusException |
||
684 | */ |
||
685 | public function getJWTToken(): string { |
||
566 | daniel-mar | 686 | $payload = $this->content; |
1305 | daniel-mar | 687 | $payload[self::CLAIM_SSH] = self::getSsh(); // SSH = Server Secret Hash |
1307 | daniel-mar | 688 | // see also https://www.iana.org/assignments/jwt/jwt.xhtml#claims for some generic claims |
1317 | daniel-mar | 689 | if (!isset($payload["iss"])) $payload["iss"] = $this->getAudIss(); |
690 | if (!isset($payload["aud"])) $payload["aud"] = $this->getAudIss(); |
||
1321 | daniel-mar | 691 | $payload["jti"] = gen_uuid(); // always set/renew it; therefore not checking isset() |
692 | $payload["iat"] = time(); // always set/renew it; therefore not checking isset() |
||
1307 | daniel-mar | 693 | if (!isset($payload["nbf"])) $payload["nbf"] = time(); |
1306 | daniel-mar | 694 | if (!isset($payload["exp"])) $payload["exp"] = time()+3600/*1h*/; |
570 | daniel-mar | 695 | |
1345 | daniel-mar | 696 | $cur_ip = OIDplus::getClientIpAddress(); |
1310 | daniel-mar | 697 | if (!isset($payload[self::CLAIM_TRACE])) { |
698 | // "Trace" can be used for later updates |
||
699 | // For example, if the IP changes "too much" (different country, different AS, etc.) |
||
700 | // Or revoke all tokens from a single login flow (sequence 1, 2, 3, ...) |
||
701 | $payload[self::CLAIM_TRACE] = array(); |
||
1312 | daniel-mar | 702 | $payload[self::CLAIM_TRACE]['iat_1st'] = $payload["iat"]; |
1310 | daniel-mar | 703 | $payload[self::CLAIM_TRACE]['jti_1st'] = $payload["jti"]; |
704 | $payload[self::CLAIM_TRACE]['seq'] = 1; |
||
1345 | daniel-mar | 705 | if ($cur_ip !== false) $payload[self::CLAIM_TRACE]['ip'] = $cur_ip; |
1310 | daniel-mar | 706 | $payload[self::CLAIM_TRACE]['ip_1st'] = $payload[self::CLAIM_TRACE]['ip']; |
707 | $payload[self::CLAIM_TRACE]['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? ''; |
||
708 | $payload[self::CLAIM_TRACE]['ua_1st'] = $payload[self::CLAIM_TRACE]['ua']; |
||
709 | } else { |
||
710 | assert(is_numeric($payload[self::CLAIM_TRACE]['seq'])); |
||
711 | $payload[self::CLAIM_TRACE]['seq']++; |
||
1345 | daniel-mar | 712 | if ($cur_ip !== false) $payload[self::CLAIM_TRACE]['ip'] = $cur_ip; |
1310 | daniel-mar | 713 | $payload[self::CLAIM_TRACE]['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? ''; |
714 | } |
||
715 | |||
1306 | daniel-mar | 716 | uksort($payload, "strnatcmp"); // this is natsort on the key. Just to make the JWT look nicer. |
717 | |||
570 | daniel-mar | 718 | if (OIDplus::getPkiStatus()) { |
830 | daniel-mar | 719 | $privKey = OIDplus::getSystemPrivateKey(); |
1298 | daniel-mar | 720 | return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS256'); // RSA+SHA256 is hardcoded in getPkiStatus() generation |
570 | daniel-mar | 721 | } else { |
1283 | daniel-mar | 722 | $key = OIDplus::authUtils()->makeSecret(['0be35e52-f4ef-11ed-b67e-3c4a92df8582']); |
826 | daniel-mar | 723 | $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false); |
679 | daniel-mar | 724 | return \Firebase\JWT\JWT::encode($payload, $key, 'HS512'); // HMAC+SHA512 is hardcoded here |
570 | daniel-mar | 725 | } |
566 | daniel-mar | 726 | } |
727 | |||
570 | daniel-mar | 728 | } |