Subversion Repositories oidplus

Rev

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