Subversion Repositories oidplus

Rev

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