Subversion Repositories oidplus

Rev

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
}