Subversion Repositories oidplus

Rev

Rev 1265 | Rev 1281 | 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
 
566 daniel-mar 26
class OIDplusAuthContentStoreJWT extends OIDplusAuthContentStoreDummy {
27
 
1130 daniel-mar 28
        /**
29
         * Cookie name for the JWT auth token
30
         */
585 daniel-mar 31
        const COOKIE_NAME = 'OIDPLUS_AUTH_JWT';
32
 
1130 daniel-mar 33
        /**
34
         * "Automated AJAX" plugin
35
         */
1265 daniel-mar 36
        const JWT_GENERATOR_AJAX   = 10;
1130 daniel-mar 37
        /**
1265 daniel-mar 38
         * "REST API" plugin
39
         */
40
        const JWT_GENERATOR_REST   = 20;
41
        /**
1130 daniel-mar 42
         * "Remember me" login method
43
         */
1265 daniel-mar 44
        const JWT_GENERATOR_LOGIN  = 40;
1130 daniel-mar 45
        /**
46
         * "Manually crafted" JWT tokens
47
         */
1265 daniel-mar 48
        const JWT_GENERATOR_MANUAL = 80;
585 daniel-mar 49
 
1116 daniel-mar 50
        /**
51
         * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
52
         * @param string $sub
53
         * @return string
54
         */
55
        private static function jwtGetBlacklistConfigKey(int $gen, string $sub): string {
1265 daniel-mar 56
                // Note: Needs to be <= 50 characters! If $gen is 2 chars, then the config key is 49 chars long
585 daniel-mar 57
                return 'jwt_blacklist_gen('.$gen.')_sub('.trim(base64_encode(md5($sub,true)),'=').')';
58
        }
59
 
1116 daniel-mar 60
        /**
1265 daniel-mar 61
         * @param int $gen
62
         */
63
        private static function generatorName($gen) {
64
                // Note: The strings are not translated, because the name is used in config keys or logs
65
                if ($gen === self::JWT_GENERATOR_AJAX)   return 'Automated AJAX calls';
66
                if ($gen === self::JWT_GENERATOR_REST)   return 'REST API';
67
                if ($gen === self::JWT_GENERATOR_LOGIN)  return 'Login ("Remember me")';
68
                if ($gen === self::JWT_GENERATOR_MANUAL) return 'Manually created';
69
                return 'Unknown generator';
70
        }
71
 
72
        /**
1116 daniel-mar 73
         * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
74
         * @param string $sub
75
         * @return void
76
         * @throws OIDplusException
77
         */
78
        public static function jwtBlacklist(int $gen, string $sub) {
585 daniel-mar 79
                $cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
80
                $bl_time = time()-1;
81
 
1265 daniel-mar 82
                $gen_desc = self::generatorName($gen);
585 daniel-mar 83
 
1116 daniel-mar 84
                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 85
                OIDplus::config()->setValue($cfg, $bl_time);
86
        }
87
 
1116 daniel-mar 88
        /**
89
         * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
90
         * @param string $sub
91
         * @return int
92
         * @throws OIDplusException
93
         */
94
        public static function jwtGetBlacklistTime(int $gen, string $sub): int {
585 daniel-mar 95
                $cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
1116 daniel-mar 96
                return (int)OIDplus::config()->getValue($cfg,0);
585 daniel-mar 97
        }
98
 
1116 daniel-mar 99
        /**
1265 daniel-mar 100
         * Do various checks if the token is allowed and not blacklisted
1116 daniel-mar 101
         * @param OIDplusAuthContentStore $contentProvider
1277 daniel-mar 102
         * @param int|null $validGenerators Bitmask which generators to allow (null = allow all)
1116 daniel-mar 103
         * @return void
104
         * @throws OIDplusException
105
         */
1277 daniel-mar 106
        private static function jwtSecurityCheck(OIDplusAuthContentStore $contentProvider, int $validGenerators=null) {
585 daniel-mar 107
                // Check if the token is intended for us
699 daniel-mar 108
                if ($contentProvider->getValue('aud','') !== OIDplus::getEditionInfo()['jwtaud']) {
585 daniel-mar 109
                        throw new OIDplusException(_L('Token has wrong audience'));
110
                }
111
                $gen = $contentProvider->getValue('oidplus_generator', -1);
112
 
113
                $has_admin = $contentProvider->isAdminLoggedIn();
114
                $has_ra = $contentProvider->raNumLoggedIn() > 0;
115
 
116
                // Check if the token generator is allowed
117
                if ($gen === self::JWT_GENERATOR_AJAX) {
118
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_ADMIN', true)) {
635 daniel-mar 119
                                // Generator: plugins/viathinksoft/adminPages/910_automated_ajax_calls/OIDplusPageAdminAutomatedAJAXCalls.class.php
585 daniel-mar 120
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_ADMIN'));
121
                        }
122
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_USER', true)) {
635 daniel-mar 123
                                // Generator: plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php
585 daniel-mar 124
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_USER'));
125
                        }
126
                }
1265 daniel-mar 127
                else if ($gen === self::JWT_GENERATOR_REST) {
128
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) {
129
                                // Generator: plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.class.php
130
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN'));
131
                        }
132
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) {
133
                                // Generator: plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.class.php
134
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER'));
135
                        }
136
                }
585 daniel-mar 137
                else if ($gen === self::JWT_GENERATOR_LOGIN) {
138
                        // Used for feature "Remember me" (use JWT token in a cookie as alternative to PHP session):
139
                        // - No PHP session will be used
140
                        // - Session will not be bound to IP address (therefore, you can switch between mobile/WiFi for example)
141
                        // - No server-side session needed
142
                        if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
143
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
144
                        }
145
                        if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
146
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
147
                        }
148
                }
149
                else if ($gen === self::JWT_GENERATOR_MANUAL) {
150
                        // Generator 2 are "hand-crafted" tokens
151
                        if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL', false)) {
152
                                throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL'));
153
                        }
154
                } else {
155
                        throw new OIDplusException(_L('Token generator %1 not recognized',$gen));
156
                }
157
 
158
                // Make sure that the IAT (issued at time) isn't in a blacklisted timeframe
159
                // When an user believes that a token was compromised, then they can blacklist the tokens identified by their "iat" ("Issued at") property
160
                // When a user logs out of a "remember me" session, the JWT token will be blacklisted as well
161
                // Small side effect: All "remember me" sessions of that user will be revoked then
162
                $sublist = $contentProvider->loggedInRaList();
163
                foreach ($sublist as &$sub) {
164
                        $sub = $sub->raEmail();
165
                }
166
                if ($has_admin) $sublist[] = 'admin';
167
                foreach ($sublist as $sub) {
168
                        $bl_time = self::jwtGetBlacklistTime($gen, $sub);
169
                        $iat = $contentProvider->getValue('iat',0);
170
                        if ($iat <= $bl_time) {
171
                                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)));
172
                        }
173
                }
174
 
175
                // Optional feature: Limit the JWT to a specific IP address
176
                // Currently not used in OIDplus
177
                $ip = $contentProvider->getValue('ip','');
178
                if ($ip !== '') {
179
                        if (isset($_SERVER['REMOTE_ADDR']) && ($ip !== $_SERVER['REMOTE_ADDR'])) {
180
                                throw new OIDplusException(_L('Your IP address is not allowed to use this token'));
181
                        }
182
                }
183
 
1265 daniel-mar 184
                // Checks if JWT are dependent on the generator
1277 daniel-mar 185
                if (!is_null($validGenerators)) {
1265 daniel-mar 186
                        if (($gen & $validGenerators) === 0) {
187
                                throw new OIDplusException(_L('This kind of JWT token (%1) cannot be used in this request type', self::generatorName($gen)));
585 daniel-mar 188
                        }
189
                }
190
        }
191
 
192
        // Override abstract functions
193
 
1116 daniel-mar 194
        /**
195
         * @return void
196
         */
585 daniel-mar 197
        public function activate() {
620 daniel-mar 198
                // Send cookie at the end of the HTTP request, in case there are multiple activate() calls
639 daniel-mar 199
                OIDplus::register_shutdown_function(array($this,'activateNow'));
620 daniel-mar 200
        }
201
 
1116 daniel-mar 202
        /**
203
         * @return void
204
         * @throws OIDplusException
205
         */
620 daniel-mar 206
        public function activateNow() {
585 daniel-mar 207
                $token = $this->getJWTToken();
208
                $exp = $this->getValue('exp',0);
209
                OIDplus::cookieUtils()->setcookie(self::COOKIE_NAME, $token, $exp, false);
210
        }
211
 
1116 daniel-mar 212
        /**
213
         * @return void
214
         * @throws OIDplusException
215
         */
585 daniel-mar 216
        public function destroySession() {
217
                OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
218
        }
219
 
1116 daniel-mar 220
        /**
221
         * @param string $email
222
         * @return void
223
         * @throws OIDplusException
224
         */
225
        public function raLogout(string $email) {
585 daniel-mar 226
                $gen = $this->getValue('oidplus_generator', -1);
227
                if ($gen >= 0) self::jwtBlacklist($gen, $email);
228
                parent::raLogout($email);
229
        }
230
 
1116 daniel-mar 231
        /**
232
         * @param string $email
233
         * @param string $loginfo
234
         * @return void
235
         * @throws OIDplusException
236
         */
237
        public function raLogoutEx(string $email, string &$loginfo) {
585 daniel-mar 238
                $this->raLogout($email);
239
                $loginfo = 'from JWT session';
240
        }
241
 
1116 daniel-mar 242
        /**
243
         * @return void
244
         * @throws OIDplusException
245
         */
585 daniel-mar 246
        public function adminLogout() {
247
                $gen = $this->getValue('oidplus_generator', -1);
248
                if ($gen >= 0) self::jwtBlacklist($gen, 'admin');
249
                parent::adminLogout();
250
        }
251
 
1116 daniel-mar 252
        /**
253
         * @param string $loginfo
254
         * @return void
255
         * @throws OIDplusException
256
         */
257
        public function adminLogoutEx(string &$loginfo) {
585 daniel-mar 258
                $this->adminLogout();
259
                $loginfo = 'from JWT session';
260
        }
261
 
620 daniel-mar 262
        private static $contentProvider = null;
1116 daniel-mar 263
 
264
        /**
265
         * @return OIDplusAuthContentStore|null
266
         * @throws OIDplusException
267
         */
268
        public static function getActiveProvider()/*: ?OIDplusAuthContentStore*/ {
620 daniel-mar 269
                if (!self::$contentProvider) {
585 daniel-mar 270
 
1265 daniel-mar 271
                        $tmp = null;
272
                        $silent_error = false;
585 daniel-mar 273
 
1265 daniel-mar 274
                        try {
585 daniel-mar 275
 
1265 daniel-mar 276
                                $rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
277
                                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.
278
 
279
                                        // REST may only use Bearer Authentication
280
                                        $bearer = getBearerToken();
281
                                        if (!is_null($bearer)) {
282
                                                $silent_error = false;
283
                                                $tmp = new OIDplusAuthContentStoreJWT();
284
                                                $tmp->loadJWT($bearer);
285
                                                self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_REST | self::JWT_GENERATOR_MANUAL);
585 daniel-mar 286
                                        }
1265 daniel-mar 287
 
288
                                } else {
289
 
290
                                        // A web-visitor (HTML and AJAX, but not REST) can use a JWT "remember me" Cookie
291
                                        if (isset($_COOKIE[self::COOKIE_NAME])) {
292
                                                $silent_error = true;
293
                                                $tmp = new OIDplusAuthContentStoreJWT();
294
                                                $tmp->loadJWT($_COOKIE[self::COOKIE_NAME]);
295
                                                self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_LOGIN | self::JWT_GENERATOR_MANUAL);
296
                                        }
297
 
298
                                        // AJAX may additionally use GET/POST automated AJAX (in addition to the normal JWT "remember me" Cookie)
299
                                        if (isset($_SERVER['SCRIPT_FILENAME']) && (strtolower(basename($_SERVER['SCRIPT_FILENAME'])) !== 'ajax.php')) {
300
                                                if (isset($_POST[self::COOKIE_NAME])) {
301
                                                        $silent_error = false;
302
                                                        $tmp = new OIDplusAuthContentStoreJWT();
303
                                                        $tmp->loadJWT($_POST[self::COOKIE_NAME]);
304
                                                        self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
305
                                                }
306
                                                if (isset($_GET[self::COOKIE_NAME])) {
307
                                                        $silent_error = false;
308
                                                        $tmp = new OIDplusAuthContentStoreJWT();
309
                                                        $tmp->loadJWT($_GET[self::COOKIE_NAME]);
310
                                                        self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
311
                                                }
312
                                        }
313
 
585 daniel-mar 314
                                }
315
 
1265 daniel-mar 316
                        } catch (\Exception $e) {
317
                                if (!$silent_error) {
318
                                        // Most likely an AJAX request. We can throw an Exception
319
                                        throw new OIDplusException(_L('The JWT token was rejected: %1',$e->getMessage()));
320
                                } else {
321
                                        // Most likely an expired Cookie/Login session. We must not throw an Exception, otherwise we will break jsTree
322
                                        OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
323
                                        return null;
324
                                }
585 daniel-mar 325
                        }
1265 daniel-mar 326
 
327
                        self::$contentProvider = $tmp;
585 daniel-mar 328
                }
329
 
620 daniel-mar 330
                return self::$contentProvider;
585 daniel-mar 331
        }
332
 
1116 daniel-mar 333
        /**
334
         * @param string $email
335
         * @param string $loginfo
336
         * @return void
337
         * @throws OIDplusException
338
         */
339
        public function raLoginEx(string $email, string &$loginfo) {
585 daniel-mar 340
                if (is_null(self::getActiveProvider())) {
341
                        $this->raLogin($email);
342
                        $loginfo = 'into new JWT session';
620 daniel-mar 343
                        self::$contentProvider = $this;
585 daniel-mar 344
                } else {
345
                        $gen = $this->getValue('oidplus_generator',-1);
346
                        switch ($gen) {
347
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
1265 daniel-mar 348
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
585 daniel-mar 349
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
350
                                        throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
351
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
352
                                        if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
353
                                                throw new OIDplusException(_L('You cannot add this login credential to your existing "remember me" session. You need to log-out first.'));
354
                                        }
355
                                        break;
356
                                default:
357
                                        assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
358
                                        break;
359
                        }
360
                        $this->raLogin($email);
361
                        $loginfo = 'into existing JWT session';
362
                }
363
        }
364
 
1116 daniel-mar 365
        /**
366
         * @param string $loginfo
367
         * @return void
368
         * @throws OIDplusException
369
         */
370
        public function adminLoginEx(string &$loginfo) {
585 daniel-mar 371
                if (is_null(self::getActiveProvider())) {
372
                        $this->adminLogin();
373
                        $loginfo = 'into new JWT session';
620 daniel-mar 374
                        self::$contentProvider = $this;
585 daniel-mar 375
                } else {
376
                        $gen = $this->getValue('oidplus_generator',-1);
377
                        switch ($gen) {
378
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
1265 daniel-mar 379
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
585 daniel-mar 380
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
381
                                        throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
382
                                case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
383
                                        if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
384
                                                throw new OIDplusException(_L('You cannot add this login credential to your existing "remember me" session. You need to log-out first.'));
385
                                        }
386
                                        break;
387
                                default:
388
                                        assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
389
                                        break;
390
                        }
391
                        $this->adminLogin();
392
                        $loginfo = 'into existing JWT session';
393
                }
394
        }
395
 
566 daniel-mar 396
        // Individual functions
397
 
1116 daniel-mar 398
        /**
1265 daniel-mar 399
         * Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked
1116 daniel-mar 400
         * @param string $jwt
401
         * @return void
402
         * @throws OIDplusException
403
         */
404
        public function loadJWT(string $jwt) {
571 daniel-mar 405
                \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds
570 daniel-mar 406
                if (OIDplus::getPkiStatus()) {
830 daniel-mar 407
                        $pubKey = OIDplus::getSystemPublicKey();
679 daniel-mar 408
                        $k = new \Firebase\JWT\Key($pubKey, 'RS256'); // RSA+SHA256 ist hardcoded in getPkiStatus() generation
409
                        $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $k);
570 daniel-mar 410
                } else {
622 daniel-mar 411
                        $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '').'/OIDplusAuthContentStoreJWT';
826 daniel-mar 412
                        $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
679 daniel-mar 413
                        $k = new \Firebase\JWT\Key($key, 'HS512'); // HMAC+SHA512 is hardcoded here
414
                        $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $k);
570 daniel-mar 415
                }
566 daniel-mar 416
        }
417
 
1116 daniel-mar 418
        /**
419
         * @return string
420
         * @throws OIDplusException
421
         */
422
        public function getJWTToken(): string {
566 daniel-mar 423
                $payload = $this->content;
699 daniel-mar 424
                $payload["iss"] = OIDplus::getEditionInfo()['jwtaud'];
425
                $payload["aud"] = OIDplus::getEditionInfo()['jwtaud'];
570 daniel-mar 426
                $payload["jti"] = gen_uuid();
566 daniel-mar 427
                $payload["iat"] = time();
570 daniel-mar 428
 
429
                if (OIDplus::getPkiStatus()) {
830 daniel-mar 430
                        $privKey = OIDplus::getSystemPrivateKey();
679 daniel-mar 431
                        return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS256'); // RSA+SHA256 ist hardcoded in getPkiStatus() generation
570 daniel-mar 432
                } else {
622 daniel-mar 433
                        $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '').'/OIDplusAuthContentStoreJWT';
826 daniel-mar 434
                        $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
679 daniel-mar 435
                        return \Firebase\JWT\JWT::encode($payload, $key, 'HS512'); // HMAC+SHA512 is hardcoded here
570 daniel-mar 436
                }
566 daniel-mar 437
        }
438
 
570 daniel-mar 439
}