Subversion Repositories oidplus

Rev

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