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 | } |