Subversion Repositories oidplus

Rev

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