Subversion Repositories oidplus

Rev

Rev 679 | Rev 826 | 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 - 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.  
  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
  55.                 if ($contentProvider->getValue('aud','') !== OIDplus::getEditionInfo()['jwtaud']) {
  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)) {
  66.                                 // Generator: plugins/viathinksoft/adminPages/910_automated_ajax_calls/OIDplusPageAdminAutomatedAJAXCalls.class.php
  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)) {
  70.                                 // Generator: plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php
  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() {
  140.                 // Send cookie at the end of the HTTP request, in case there are multiple activate() calls
  141.                 OIDplus::register_shutdown_function(array($this,'activateNow'));
  142.         }
  143.  
  144.         public function activateNow() {
  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.  
  176.         private static $contentProvider = null;
  177.         public static function getActiveProvider() {
  178.                 if (!self::$contentProvider) {
  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.  
  204.                                 self::$contentProvider = $tmp;
  205.                         }
  206.                 }
  207.  
  208.                 return self::$contentProvider;
  209.         }
  210.  
  211.         public function raLoginEx($email, &$loginfo) {
  212.                 if (is_null(self::getActiveProvider())) {
  213.                         $this->raLogin($email);
  214.                         $loginfo = 'into new JWT session';
  215.                         self::$contentProvider = $this;
  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';
  240.                         self::$contentProvider = $this;
  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.  
  261.         // Individual functions
  262.  
  263.         public function loadJWT($jwt) {
  264.                 \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds
  265.                 if (OIDplus::getPkiStatus()) {
  266.                         $pubKey = OIDplus::config()->getValue('oidplus_public_key');
  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);
  269.                 } else {
  270.                         $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '').'/OIDplusAuthContentStoreJWT';
  271.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 64/*256bit*/, false);
  272.                         $k = new \Firebase\JWT\Key($key, 'HS512'); // HMAC+SHA512 is hardcoded here
  273.                         $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $k);
  274.                 }
  275.         }
  276.  
  277.         public function getJWTToken() {
  278.                 $payload = $this->content;
  279.                 $payload["iss"] = OIDplus::getEditionInfo()['jwtaud'];
  280.                 $payload["aud"] = OIDplus::getEditionInfo()['jwtaud'];
  281.                 $payload["jti"] = gen_uuid();
  282.                 $payload["iat"] = time();
  283.  
  284.                 if (OIDplus::getPkiStatus()) {
  285.                         $privKey = OIDplus::config()->getValue('oidplus_private_key');
  286.                         return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS256'); // RSA+SHA256 ist hardcoded in getPkiStatus() generation
  287.                 } else {
  288.                         $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '').'/OIDplusAuthContentStoreJWT';
  289.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 64/*256bit*/, false);
  290.                         return \Firebase\JWT\JWT::encode($payload, $key, 'HS512'); // HMAC+SHA512 is hardcoded here
  291.                 }
  292.         }
  293.  
  294. }
  295.