Subversion Repositories oidplus

Rev

Rev 1050 | Rev 1116 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

  1. <?php
  2.  
  3. /*
  4.  * OIDplus 2.0
  5.  * Copyright 2019 - 2023 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. namespace ViaThinkSoft\OIDplus;
  21.  
  22. // phpcs:disable PSR1.Files.SideEffects
  23. \defined('INSIDE_OIDPLUS') or die;
  24. // phpcs:enable PSR1.Files.SideEffects
  25.  
  26. class OIDplusAuthContentStoreJWT extends OIDplusAuthContentStoreDummy {
  27.  
  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
  59.                 if ($contentProvider->getValue('aud','') !== OIDplus::getEditionInfo()['jwtaud']) {
  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)) {
  70.                                 // Generator: plugins/viathinksoft/adminPages/910_automated_ajax_calls/OIDplusPageAdminAutomatedAJAXCalls.class.php
  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)) {
  74.                                 // Generator: plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php
  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() {
  144.                 // Send cookie at the end of the HTTP request, in case there are multiple activate() calls
  145.                 OIDplus::register_shutdown_function(array($this,'activateNow'));
  146.         }
  147.  
  148.         public function activateNow() {
  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.  
  180.         private static $contentProvider = null;
  181.         public static function getActiveProvider() {
  182.                 if (!self::$contentProvider) {
  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);
  197.                                 } catch (\Exception $e) {
  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.  
  208.                                 self::$contentProvider = $tmp;
  209.                         }
  210.                 }
  211.  
  212.                 return self::$contentProvider;
  213.         }
  214.  
  215.         public function raLoginEx($email, &$loginfo) {
  216.                 if (is_null(self::getActiveProvider())) {
  217.                         $this->raLogin($email);
  218.                         $loginfo = 'into new JWT session';
  219.                         self::$contentProvider = $this;
  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';
  244.                         self::$contentProvider = $this;
  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.  
  265.         // Individual functions
  266.  
  267.         public function loadJWT($jwt) {
  268.                 \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds
  269.                 if (OIDplus::getPkiStatus()) {
  270.                         $pubKey = OIDplus::getSystemPublicKey();
  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);
  273.                 } else {
  274.                         $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '').'/OIDplusAuthContentStoreJWT';
  275.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
  276.                         $k = new \Firebase\JWT\Key($key, 'HS512'); // HMAC+SHA512 is hardcoded here
  277.                         $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $k);
  278.                 }
  279.         }
  280.  
  281.         public function getJWTToken() {
  282.                 $payload = $this->content;
  283.                 $payload["iss"] = OIDplus::getEditionInfo()['jwtaud'];
  284.                 $payload["aud"] = OIDplus::getEditionInfo()['jwtaud'];
  285.                 $payload["jti"] = gen_uuid();
  286.                 $payload["iat"] = time();
  287.  
  288.                 if (OIDplus::getPkiStatus()) {
  289.                         $privKey = OIDplus::getSystemPrivateKey();
  290.                         return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS256'); // RSA+SHA256 ist hardcoded in getPkiStatus() generation
  291.                 } else {
  292.                         $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '').'/OIDplusAuthContentStoreJWT';
  293.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
  294.                         return \Firebase\JWT\JWT::encode($payload, $key, 'HS512'); // HMAC+SHA512 is hardcoded here
  295.                 }
  296.         }
  297.  
  298. }
  299.