Subversion Repositories oidplus

Rev

Rev 1308 | Rev 1312 | Go to most recent revision | 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
        /**
79
         * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
80
         * @param string $sub
81
         * @return string
82
         */
83
        private static function jwtGetBlacklistConfigKey(int $gen, string $sub): string {
1265 daniel-mar 84
                // Note: Needs to be <= 50 characters! If $gen is 2 chars, then the config key is 49 chars long
585 daniel-mar 85
                return 'jwt_blacklist_gen('.$gen.')_sub('.trim(base64_encode(md5($sub,true)),'=').')';
86
        }
87
 
1116 daniel-mar 88
        /**
1265 daniel-mar 89
         * @param int $gen
90
         */
91
        private static function generatorName($gen) {
92
                // Note: The strings are not translated, because the name is used in config keys or logs
93
                if ($gen === self::JWT_GENERATOR_AJAX)   return 'Automated AJAX calls';
94
                if ($gen === self::JWT_GENERATOR_REST)   return 'REST API';
1305 daniel-mar 95
                if ($gen === self::JWT_GENERATOR_LOGIN)  return 'Browser login';
1265 daniel-mar 96
                if ($gen === self::JWT_GENERATOR_MANUAL) return 'Manually created';
97
                return 'Unknown generator';
98
        }
99
 
100
        /**
1116 daniel-mar 101
         * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
102
         * @param string $sub
103
         * @return void
104
         * @throws OIDplusException
105
         */
106
        public static function jwtBlacklist(int $gen, string $sub) {
585 daniel-mar 107
                $cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
108
                $bl_time = time()-1;
109
 
1265 daniel-mar 110
                $gen_desc = self::generatorName($gen);
585 daniel-mar 111
 
1305 daniel-mar 112
                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 113
                OIDplus::config()->setValue($cfg, $bl_time);
114
        }
115
 
1116 daniel-mar 116
        /**
117
         * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
118
         * @param string $sub
119
         * @return int
120
         * @throws OIDplusException
121
         */
122
        public static function jwtGetBlacklistTime(int $gen, string $sub): int {
585 daniel-mar 123
                $cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
1116 daniel-mar 124
                return (int)OIDplus::config()->getValue($cfg,0);
585 daniel-mar 125
        }
126
 
1116 daniel-mar 127
        /**
1298 daniel-mar 128
         * 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
129
         * @return string
130
         * @throws OIDplusException
131
         */
132
        private static function getSsh(): string {
1305 daniel-mar 133
                $hexadecimal_string = OIDplus::authUtils()->makeSecret(['bb1aebd6-fe6a-11ed-a553-3c4a92df8582']);
134
                return base64_encode(pack('H*',$hexadecimal_string));
1298 daniel-mar 135
        }
136
 
137
        /**
1265 daniel-mar 138
         * Do various checks if the token is allowed and not blacklisted
1305 daniel-mar 139
         * @param OIDplusAuthContentStoreJWT $contentProvider
1277 daniel-mar 140
         * @param int|null $validGenerators Bitmask which generators to allow (null = allow all)
1116 daniel-mar 141
         * @return void
142
         * @throws OIDplusException
143
         */
1305 daniel-mar 144
        private static function jwtSecurityCheck(OIDplusAuthContentStoreJWT $contentProvider, int $validGenerators=null) {
585 daniel-mar 145
                // Check if the token is intended for us
1308 daniel-mar 146
                // Note 'aud' is mandatory, so we do not check for exists()
699 daniel-mar 147
                if ($contentProvider->getValue('aud','') !== OIDplus::getEditionInfo()['jwtaud']) {
585 daniel-mar 148
                        throw new OIDplusException(_L('Token has wrong audience'));
149
                }
1298 daniel-mar 150
 
1308 daniel-mar 151
                // Note CLAIM_SSH is mandatory, so we do not check for exists()
1305 daniel-mar 152
                if ($contentProvider->getValue(self::CLAIM_SSH, '') !== self::getSsh()) {
1298 daniel-mar 153
                        throw new OIDplusException(_L('"Server Secret" was changed; therefore the JWT is not valid anymore'));
154
                }
155
 
1308 daniel-mar 156
                // Note CLAIM_GENERATOR is mandatory, so we do not check for exists()
1305 daniel-mar 157
                $gen = $contentProvider->getValue(self::CLAIM_GENERATOR, -1);
585 daniel-mar 158
 
159
                $has_admin = $contentProvider->isAdminLoggedIn();
160
                $has_ra = $contentProvider->raNumLoggedIn() > 0;
161
 
162
                // Check if the token generator is allowed
163
                if ($gen === self::JWT_GENERATOR_AJAX) {
164
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_ADMIN', true)) {
635 daniel-mar 165
                                // Generator: plugins/viathinksoft/adminPages/910_automated_ajax_calls/OIDplusPageAdminAutomatedAJAXCalls.class.php
585 daniel-mar 166
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_ADMIN'));
167
                        }
168
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_USER', true)) {
635 daniel-mar 169
                                // Generator: plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php
585 daniel-mar 170
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_USER'));
171
                        }
172
                }
1265 daniel-mar 173
                else if ($gen === self::JWT_GENERATOR_REST) {
174
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) {
175
                                // Generator: plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.class.php
176
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN'));
177
                        }
178
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) {
179
                                // Generator: plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.class.php
180
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER'));
181
                        }
182
                }
585 daniel-mar 183
                else if ($gen === self::JWT_GENERATOR_LOGIN) {
1305 daniel-mar 184
                        // Used for web browser login (use JWT token in a cookie as alternative to PHP session):
585 daniel-mar 185
                        // - No PHP session will be used
186
                        // - Session will not be bound to IP address (therefore, you can switch between mobile/WiFi for example)
187
                        // - No server-side session needed
188
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
189
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
190
                        }
191
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
192
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
193
                        }
194
                }
195
                else if ($gen === self::JWT_GENERATOR_MANUAL) {
1300 daniel-mar 196
                        // Generator: "hand-crafted" tokens
197
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL_ADMIN', false)) {
198
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL_ADMIN'));
585 daniel-mar 199
                        }
1300 daniel-mar 200
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL_USER', false)) {
201
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL_USER'));
202
                        }
585 daniel-mar 203
                } else {
204
                        throw new OIDplusException(_L('Token generator %1 not recognized',$gen));
205
                }
206
 
1307 daniel-mar 207
                // Every token must have and issued timestamp
208
                $iat = $contentProvider->getValue('iat',null);
209
                if (is_null($iat)) {
210
                        throw new OIDplusException(_L('The claim "%1" of the JWT token is missing or invalid','iat'));
211
                }
212
 
213
                // Verify that IAT has a valid value
214
                // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure.
215
                if (($iat-120/*leeway 2min*/) > time()) {
216
                        // Token was created in the future. Something is wrong!
217
                        throw new OIDplusException(_L('JWT Token cannot be verified because the server time is wrong'));
218
                }
219
 
220
                // Check if token is not yet valid
221
                // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure.
222
                $nbf = $contentProvider->getValue('nbf',null);
223
                if (!is_null($nbf)) {
224
                        if (time() < $nbf-120/*leeway 2min*/) {
225
                                throw new OIDplusException(_L('Token not valid before %1',date('d F Y, H:i:s',$nbf)));
226
                        }
227
                }
228
 
1306 daniel-mar 229
                // Check if token has expired
1307 daniel-mar 230
                // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure.
1306 daniel-mar 231
                $exp = $contentProvider->getValue('exp',null);
232
                if (!is_null($exp)) {
1307 daniel-mar 233
                        if (time() > $exp+120/*leeway 2min*/) {
1306 daniel-mar 234
                                throw new OIDplusException(_L('Token has expired on %1',date('d F Y, H:i:s',$exp)));
235
                        }
236
                }
237
 
585 daniel-mar 238
                // Make sure that the IAT (issued at time) isn't in a blacklisted timeframe
239
                // 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 240
                // When a user logs out of a web browser session, the JWT token will be blacklisted as well
241
                // Small side effect: All web browser login sessions of that user will be revoked then
585 daniel-mar 242
                $sublist = $contentProvider->loggedInRaList();
1281 daniel-mar 243
                $usernames = array();
244
                foreach ($sublist as $sub) {
245
                        $usernames[] = $sub->raEmail();
585 daniel-mar 246
                }
1281 daniel-mar 247
                if ($has_admin) $usernames[] = 'admin';
248
                foreach ($usernames as $username) {
249
                        $bl_time = self::jwtGetBlacklistTime($gen, $username);
585 daniel-mar 250
                        if ($iat <= $bl_time) {
1281 daniel-mar 251
                                // Token is blacklisted (it was created before the last blacklist time)
585 daniel-mar 252
                                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)));
253
                        }
254
                }
255
 
256
                // Optional feature: Limit the JWT to a specific IP address
257
                // Currently not used in OIDplus
1305 daniel-mar 258
                $ip = $contentProvider->getValue(self::CLAIM_LIMIT_IP, null);
259
                if (!is_null($ip)) {
585 daniel-mar 260
                        if (isset($_SERVER['REMOTE_ADDR']) && ($ip !== $_SERVER['REMOTE_ADDR'])) {
261
                                throw new OIDplusException(_L('Your IP address is not allowed to use this token'));
262
                        }
263
                }
264
 
1265 daniel-mar 265
                // Checks if JWT are dependent on the generator
1277 daniel-mar 266
                if (!is_null($validGenerators)) {
1265 daniel-mar 267
                        if (($gen & $validGenerators) === 0) {
268
                                throw new OIDplusException(_L('This kind of JWT token (%1) cannot be used in this request type', self::generatorName($gen)));
585 daniel-mar 269
                        }
270
                }
271
        }
272
 
273
        // Override abstract functions
274
 
1116 daniel-mar 275
        /**
1301 daniel-mar 276
         * @var array
277
         */
278
        protected $content = array();
279
 
280
        /**
281
         * @param string $name
282
         * @param mixed|null $default
283
         * @return mixed|null
284
         */
285
        public function getValue(string $name, $default = NULL) {
286
                return $this->content[$name] ?? $default;
287
        }
288
 
289
        /**
290
         * @param string $name
291
         * @param mixed $value
1116 daniel-mar 292
         * @return void
293
         */
1301 daniel-mar 294
        public function setValue(string $name, $value) {
295
                $this->content[$name] = $value;
296
        }
297
 
298
        /**
299
         * @param string $name
300
         * @return bool
301
         */
302
        public function exists(string $name): bool {
303
                return isset($this->content[$name]);
304
        }
305
 
306
        /**
307
         * @param string $name
308
         * @return void
309
         */
310
        public function delete(string $name) {
311
                unset($this->content[$name]);
312
        }
313
 
314
        /**
315
         * @return void
316
         */
585 daniel-mar 317
        public function activate() {
620 daniel-mar 318
                // Send cookie at the end of the HTTP request, in case there are multiple activate() calls
639 daniel-mar 319
                OIDplus::register_shutdown_function(array($this,'activateNow'));
620 daniel-mar 320
        }
321
 
1116 daniel-mar 322
        /**
323
         * @return void
324
         * @throws OIDplusException
325
         */
620 daniel-mar 326
        public function activateNow() {
585 daniel-mar 327
                $token = $this->getJWTToken();
328
                $exp = $this->getValue('exp',0);
329
                OIDplus::cookieUtils()->setcookie(self::COOKIE_NAME, $token, $exp, false);
330
        }
331
 
1116 daniel-mar 332
        /**
333
         * @return void
334
         * @throws OIDplusException
335
         */
585 daniel-mar 336
        public function destroySession() {
337
                OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
338
        }
339
 
1305 daniel-mar 340
        // RA authentication functions (low-level)
341
 
1116 daniel-mar 342
        /**
343
         * @param string $email
344
         * @return void
1305 daniel-mar 345
         */
346
        public function raLogin(string $email) {
347
                if ($email == 'admin') return;
348
 
349
                $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
350
                if (is_null($list)) $list = [];
351
                if (!in_array($email, $list)) $list[] = $email;
352
                $this->setValue(self::CLAIM_LOGIN_LIST, $list);
353
        }
354
 
355
        /**
356
         * @return int
357
         */
358
        public function raNumLoggedIn(): int {
359
                return count($this->loggedInRaList());
360
        }
361
 
362
        /**
363
         * @return OIDplusRA[]
364
         */
365
        public function loggedInRaList(): array {
366
                $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
367
                if (is_null($list)) $list = [];
368
 
369
                $res = array();
370
                foreach (array_unique($list) as $username) {
371
                        if ($username == '') continue; // should not happen
372
                        if ($username == 'admin') continue;
373
                        $res[] = new OIDplusRA($username);
374
                }
375
                return $res;
376
        }
377
 
378
        /**
379
         * @param string $email
380
         * @return bool
381
         */
382
        public function isRaLoggedIn(string $email): bool {
383
                foreach ($this->loggedInRaList() as $ra) {
384
                        if ($email == $ra->raEmail()) return true;
385
                }
386
                return false;
387
        }
388
 
389
        /**
390
         * @param string $email
391
         * @return void
1116 daniel-mar 392
         * @throws OIDplusException
393
         */
394
        public function raLogout(string $email) {
1305 daniel-mar 395
                if ($email == 'admin') return;
396
 
397
                $gen = $this->getValue(self::CLAIM_GENERATOR, -1);
585 daniel-mar 398
                if ($gen >= 0) self::jwtBlacklist($gen, $email);
1305 daniel-mar 399
 
400
                $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
401
                if (is_null($list)) $list = [];
402
                $key = array_search($email, $list);
403
                if ($key !== false) unset($list[$key]);
404
                $this->setValue(self::CLAIM_LOGIN_LIST, $list);
585 daniel-mar 405
        }
406
 
1116 daniel-mar 407
        /**
408
         * @param string $email
409
         * @param string $loginfo
410
         * @return void
411
         * @throws OIDplusException
412
         */
413
        public function raLogoutEx(string $email, string &$loginfo) {
585 daniel-mar 414
                $this->raLogout($email);
415
                $loginfo = 'from JWT session';
416
        }
417
 
1305 daniel-mar 418
        // Admin authentication functions (low-level)
419
 
1116 daniel-mar 420
        /**
421
         * @return void
1305 daniel-mar 422
         */
423
        public function adminLogin() {
424
                $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
425
                if (is_null($list)) $list = [];
426
                if (!in_array('admin', $list)) $list[] = 'admin';
427
                $this->setValue(self::CLAIM_LOGIN_LIST, $list);
428
        }
429
 
430
        /**
431
         * @return bool
432
         */
433
        public function isAdminLoggedIn(): bool {
434
                $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
435
                if (is_null($list)) $list = [];
436
                return in_array('admin', $list);
437
        }
438
 
439
        /**
440
         * @return void
1116 daniel-mar 441
         * @throws OIDplusException
442
         */
585 daniel-mar 443
        public function adminLogout() {
1305 daniel-mar 444
                $gen = $this->getValue(self::CLAIM_GENERATOR, -1);
585 daniel-mar 445
                if ($gen >= 0) self::jwtBlacklist($gen, 'admin');
1305 daniel-mar 446
 
447
                $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
448
                if (is_null($list)) $list = [];
449
                $key = array_search('admin', $list);
450
                if ($key !== false) unset($list[$key]);
451
                $this->setValue(self::CLAIM_LOGIN_LIST, $list);
585 daniel-mar 452
        }
453
 
1116 daniel-mar 454
        /**
455
         * @param string $loginfo
456
         * @return void
457
         * @throws OIDplusException
458
         */
459
        public function adminLogoutEx(string &$loginfo) {
585 daniel-mar 460
                $this->adminLogout();
461
                $loginfo = 'from JWT session';
462
        }
463
 
620 daniel-mar 464
        private static $contentProvider = null;
1116 daniel-mar 465
 
466
        /**
1305 daniel-mar 467
         * @return OIDplusAuthContentStoreJWT|null
1116 daniel-mar 468
         * @throws OIDplusException
469
         */
1305 daniel-mar 470
        public static function getActiveProvider()/*: ?OIDplusAuthContentStoreJWT*/ {
620 daniel-mar 471
                if (!self::$contentProvider) {
585 daniel-mar 472
 
1265 daniel-mar 473
                        $tmp = null;
474
                        $silent_error = false;
585 daniel-mar 475
 
1265 daniel-mar 476
                        try {
585 daniel-mar 477
 
1265 daniel-mar 478
                                $rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
479
                                if (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.
480
 
481
                                        // REST may only use Bearer Authentication
482
                                        $bearer = getBearerToken();
483
                                        if (!is_null($bearer)) {
484
                                                $silent_error = false;
485
                                                $tmp = new OIDplusAuthContentStoreJWT();
486
                                                $tmp->loadJWT($bearer);
487
                                                self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_REST | self::JWT_GENERATOR_MANUAL);
585 daniel-mar 488
                                        }
1265 daniel-mar 489
 
490
                                } else {
491
 
1305 daniel-mar 492
                                        // A web-visitor (HTML and AJAX, but not REST) can use a JWT Cookie
1265 daniel-mar 493
                                        if (isset($_COOKIE[self::COOKIE_NAME])) {
494
                                                $silent_error = true;
495
                                                $tmp = new OIDplusAuthContentStoreJWT();
496
                                                $tmp->loadJWT($_COOKIE[self::COOKIE_NAME]);
497
                                                self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_LOGIN | self::JWT_GENERATOR_MANUAL);
498
                                        }
499
 
1305 daniel-mar 500
                                        // AJAX may additionally use GET/POST automated AJAX (in addition to the normal web browser login Cookie)
1265 daniel-mar 501
                                        if (isset($_SERVER['SCRIPT_FILENAME']) && (strtolower(basename($_SERVER['SCRIPT_FILENAME'])) !== 'ajax.php')) {
502
                                                if (isset($_POST[self::COOKIE_NAME])) {
503
                                                        $silent_error = false;
504
                                                        $tmp = new OIDplusAuthContentStoreJWT();
505
                                                        $tmp->loadJWT($_POST[self::COOKIE_NAME]);
506
                                                        self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
507
                                                }
508
                                                if (isset($_GET[self::COOKIE_NAME])) {
509
                                                        $silent_error = false;
510
                                                        $tmp = new OIDplusAuthContentStoreJWT();
511
                                                        $tmp->loadJWT($_GET[self::COOKIE_NAME]);
512
                                                        self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
513
                                                }
514
                                        }
515
 
585 daniel-mar 516
                                }
517
 
1265 daniel-mar 518
                        } catch (\Exception $e) {
1306 daniel-mar 519
                                if (!$silent_error || OIDplus::baseConfig()->getValue('DEBUG',false)) {
1265 daniel-mar 520
                                        // Most likely an AJAX request. We can throw an Exception
521
                                        throw new OIDplusException(_L('The JWT token was rejected: %1',$e->getMessage()));
522
                                } else {
523
                                        // Most likely an expired Cookie/Login session. We must not throw an Exception, otherwise we will break jsTree
524
                                        OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
525
                                        return null;
526
                                }
585 daniel-mar 527
                        }
1265 daniel-mar 528
 
529
                        self::$contentProvider = $tmp;
585 daniel-mar 530
                }
531
 
620 daniel-mar 532
                return self::$contentProvider;
585 daniel-mar 533
        }
534
 
1116 daniel-mar 535
        /**
536
         * @param string $email
537
         * @param string $loginfo
538
         * @return void
539
         * @throws OIDplusException
540
         */
541
        public function raLoginEx(string $email, string &$loginfo) {
585 daniel-mar 542
                if (is_null(self::getActiveProvider())) {
543
                        $this->raLogin($email);
544
                        $loginfo = 'into new JWT session';
620 daniel-mar 545
                        self::$contentProvider = $this;
585 daniel-mar 546
                } else {
1305 daniel-mar 547
                        $gen = $this->getValue(self::CLAIM_GENERATOR,-1);
585 daniel-mar 548
                        switch ($gen) {
549
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
1265 daniel-mar 550
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
585 daniel-mar 551
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
552
                                        throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
553
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
554
                                        if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
1305 daniel-mar 555
                                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
585 daniel-mar 556
                                        }
557
                                        break;
558
                                default:
559
                                        assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
560
                                        break;
561
                        }
562
                        $this->raLogin($email);
563
                        $loginfo = 'into existing JWT session';
564
                }
1306 daniel-mar 565
                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 10*365*24*60*60);
566
                $this->setValue('exp', time()+$ttl); // JWT "exp" attribute
585 daniel-mar 567
        }
568
 
1116 daniel-mar 569
        /**
570
         * @param string $loginfo
571
         * @return void
572
         * @throws OIDplusException
573
         */
574
        public function adminLoginEx(string &$loginfo) {
585 daniel-mar 575
                if (is_null(self::getActiveProvider())) {
576
                        $this->adminLogin();
577
                        $loginfo = 'into new JWT session';
620 daniel-mar 578
                        self::$contentProvider = $this;
585 daniel-mar 579
                } else {
1305 daniel-mar 580
                        $gen = $this->getValue(self::CLAIM_GENERATOR,-1);
585 daniel-mar 581
                        switch ($gen) {
582
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
1265 daniel-mar 583
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
585 daniel-mar 584
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
585
                                        throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
586
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
587
                                        if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
1305 daniel-mar 588
                                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
585 daniel-mar 589
                                        }
590
                                        break;
591
                                default:
592
                                        assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
593
                                        break;
594
                        }
595
                        $this->adminLogin();
596
                        $loginfo = 'into existing JWT session';
597
                }
1306 daniel-mar 598
                $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 10*365*24*60*60);
599
                $this->setValue('exp', time()+$ttl); // JWT "exp" attribute
585 daniel-mar 600
        }
601
 
566 daniel-mar 602
        // Individual functions
603
 
1116 daniel-mar 604
        /**
1265 daniel-mar 605
         * Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked
1116 daniel-mar 606
         * @param string $jwt
607
         * @return void
608
         * @throws OIDplusException
609
         */
610
        public function loadJWT(string $jwt) {
571 daniel-mar 611
                \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds
570 daniel-mar 612
                if (OIDplus::getPkiStatus()) {
830 daniel-mar 613
                        $pubKey = OIDplus::getSystemPublicKey();
1298 daniel-mar 614
                        $k = new \Firebase\JWT\Key($pubKey, 'RS256'); // RSA+SHA256 is hardcoded in getPkiStatus() generation
679 daniel-mar 615
                        $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $k);
570 daniel-mar 616
                } else {
1283 daniel-mar 617
                        $key = OIDplus::authUtils()->makeSecret(['0be35e52-f4ef-11ed-b67e-3c4a92df8582']);
826 daniel-mar 618
                        $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
679 daniel-mar 619
                        $k = new \Firebase\JWT\Key($key, 'HS512'); // HMAC+SHA512 is hardcoded here
620
                        $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $k);
570 daniel-mar 621
                }
566 daniel-mar 622
        }
623
 
1116 daniel-mar 624
        /**
625
         * @return string
626
         * @throws OIDplusException
627
         */
628
        public function getJWTToken(): string {
566 daniel-mar 629
                $payload = $this->content;
1305 daniel-mar 630
                $payload[self::CLAIM_SSH] = self::getSsh(); // SSH = Server Secret Hash
1307 daniel-mar 631
                // see also https://www.iana.org/assignments/jwt/jwt.xhtml#claims for some generic claims
632
                $payload["iss"] = OIDplus::getEditionInfo()['jwtaud']; // sic: jwtaud
699 daniel-mar 633
                $payload["aud"] = OIDplus::getEditionInfo()['jwtaud'];
570 daniel-mar 634
                $payload["jti"] = gen_uuid();
566 daniel-mar 635
                $payload["iat"] = time();
1307 daniel-mar 636
                if (!isset($payload["nbf"])) $payload["nbf"] = time();
1306 daniel-mar 637
                if (!isset($payload["exp"])) $payload["exp"] = time()+3600/*1h*/;
570 daniel-mar 638
 
1310 daniel-mar 639
                if (!isset($payload[self::CLAIM_TRACE])) {
640
                        // "Trace" can be used for later updates
641
                        // For example, if the IP changes "too much" (different country, different AS, etc.)
642
                        // Or revoke all tokens from a single login flow (sequence 1, 2, 3, ...)
643
                        $payload[self::CLAIM_TRACE] = array();
644
                        $payload[self::CLAIM_TRACE]['jti_1st'] = $payload["jti"];
645
                        $payload[self::CLAIM_TRACE]['seq'] = 1;
646
                        $payload[self::CLAIM_TRACE]['ip'] = $_SERVER['REMOTE_ADDR'] ?? '';
647
                        $payload[self::CLAIM_TRACE]['ip_1st'] = $payload[self::CLAIM_TRACE]['ip'];
648
                        $payload[self::CLAIM_TRACE]['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
649
                        $payload[self::CLAIM_TRACE]['ua_1st'] = $payload[self::CLAIM_TRACE]['ua'];
650
                } else {
651
                        assert(is_numeric($payload[self::CLAIM_TRACE]['seq']));
652
                        $payload[self::CLAIM_TRACE]['seq']++;
653
                        $payload[self::CLAIM_TRACE]['ip'] = $_SERVER['REMOTE_ADDR'] ?? '';
654
                        $payload[self::CLAIM_TRACE]['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
655
                }
656
 
1306 daniel-mar 657
                uksort($payload, "strnatcmp"); // this is natsort on the key. Just to make the JWT look nicer.
658
 
570 daniel-mar 659
                if (OIDplus::getPkiStatus()) {
830 daniel-mar 660
                        $privKey = OIDplus::getSystemPrivateKey();
1298 daniel-mar 661
                        return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS256'); // RSA+SHA256 is hardcoded in getPkiStatus() generation
570 daniel-mar 662
                } else {
1283 daniel-mar 663
                        $key = OIDplus::authUtils()->makeSecret(['0be35e52-f4ef-11ed-b67e-3c4a92df8582']);
826 daniel-mar 664
                        $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
679 daniel-mar 665
                        return \Firebase\JWT\JWT::encode($payload, $key, 'HS512'); // HMAC+SHA512 is hardcoded here
570 daniel-mar 666
                }
566 daniel-mar 667
        }
668
 
570 daniel-mar 669
}