Subversion Repositories oidplus

Rev

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