Subversion Repositories oidplus

Rev

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