Subversion Repositories oidplus

Rev

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