Subversion Repositories oidplus

Rev

Rev 576 | Rev 591 | 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','') !== "http://oidplus.com") {
  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/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/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.                 $token = $this->getJWTToken();
  141.                 $exp = $this->getValue('exp',0);
  142.                 OIDplus::cookieUtils()->setcookie(self::COOKIE_NAME, $token, $exp, false);
  143.         }
  144.  
  145.         public function destroySession() {
  146.                 OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
  147.         }
  148.  
  149.         public function raLogout($email) {
  150.                 $gen = $this->getValue('oidplus_generator', -1);
  151.                 if ($gen >= 0) self::jwtBlacklist($gen, $email);
  152.                 parent::raLogout($email);
  153.         }
  154.  
  155.         public function raLogoutEx($email, &$loginfo) {
  156.                 $this->raLogout($email);
  157.                 $loginfo = 'from JWT session';
  158.         }
  159.  
  160.         public function adminLogout() {
  161.                 $gen = $this->getValue('oidplus_generator', -1);
  162.                 if ($gen >= 0) self::jwtBlacklist($gen, 'admin');
  163.                 parent::adminLogout();
  164.         }
  165.  
  166.         public function adminLogoutEx(&$loginfo) {
  167.                 $this->adminLogout();
  168.                 $loginfo = 'from JWT session';
  169.         }
  170.  
  171.         public static function getActiveProvider() {
  172.                 static $contentProvider = null;
  173.  
  174.                 if (!$contentProvider) {
  175.                         $jwt = '';
  176.                         if (isset($_COOKIE[self::COOKIE_NAME])) $jwt = $_COOKIE[self::COOKIE_NAME];
  177.                         if (isset($_POST[self::COOKIE_NAME]))   $jwt = $_POST[self::COOKIE_NAME];
  178.                         if (isset($_GET[self::COOKIE_NAME]))    $jwt = $_GET[self::COOKIE_NAME];
  179.  
  180.                         if (!empty($jwt)) {
  181.                                 $tmp = new OIDplusAuthContentStoreJWT();
  182.  
  183.                                 try {
  184.                                         // Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked
  185.                                         $tmp->loadJWT($jwt);
  186.  
  187.                                         // Do various checks if the token is allowed and not blacklisted
  188.                                         self::jwtSecurityCheck($tmp);
  189.                                 } catch (Exception $e) {
  190.                                         if (isset($_GET[self::COOKIE_NAME]) || isset($_POST[self::COOKIE_NAME])) {
  191.                                                 // Most likely an AJAX request. We can throw an Exception
  192.                                                 throw new OIDplusException(_L('The JWT token was rejected: %1',$e->getMessage()));
  193.                                         } else {
  194.                                                 // Most likely an expired Cookie/Login session. We must not throw an Exception, otherwise we will break jsTree
  195.                                                 OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
  196.                                                 return null;
  197.                                         }
  198.                                 }
  199.  
  200.                                 $contentProvider = $tmp;
  201.                         }
  202.                 }
  203.  
  204.                 return $contentProvider;
  205.         }
  206.  
  207.         public function raLoginEx($email, &$loginfo) {
  208.                 if (is_null(self::getActiveProvider())) {
  209.                         $this->raLogin($email);
  210.                         $loginfo = 'into new JWT session';
  211.                 } else {
  212.                         $gen = $this->getValue('oidplus_generator',-1);
  213.                         switch ($gen) {
  214.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
  215.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
  216.                                         throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
  217.                                         break;
  218.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
  219.                                         if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
  220.                                                 throw new OIDplusException(_L('You cannot add this login credential to your existing "remember me" session. You need to log-out first.'));
  221.                                         }
  222.                                         break;
  223.                                 default:
  224.                                         assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
  225.                                         break;
  226.                         }
  227.                         $this->raLogin($email);
  228.                         $loginfo = 'into existing JWT session';
  229.                 }
  230.         }
  231.  
  232.         public function adminLoginEx(&$loginfo) {
  233.                 if (is_null(self::getActiveProvider())) {
  234.                         $this->adminLogin();
  235.                         $loginfo = 'into new JWT session';
  236.                 } else {
  237.                         $gen = $this->getValue('oidplus_generator',-1);
  238.                         switch ($gen) {
  239.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
  240.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
  241.                                         throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
  242.                                         break;
  243.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
  244.                                         if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
  245.                                                 throw new OIDplusException(_L('You cannot add this login credential to your existing "remember me" session. You need to log-out first.'));
  246.                                         }
  247.                                         break;
  248.                                 default:
  249.                                         assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
  250.                                         break;
  251.                         }
  252.                         $this->adminLogin();
  253.                         $loginfo = 'into existing JWT session';
  254.                 }
  255.         }
  256.  
  257.         // Individual functions
  258.  
  259.         public function loadJWT($jwt) {
  260.                 \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds
  261.                 if (OIDplus::getPkiStatus()) {
  262.                         $pubKey = OIDplus::config()->getValue('oidplus_public_key');
  263.                         $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $pubKey, array('RS256', 'RS384', 'RS512'));
  264.                 } else {
  265.                         $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '');
  266.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 64/*256bit*/, false);
  267.                         $this->content = (array) \Firebase\JWT\JWT::decode($jwt, $key, array('HS256', 'HS384', 'HS512'));
  268.                 }
  269.         }
  270.  
  271.         public function getJWTToken() {
  272.                 $payload = $this->content;
  273.                 $payload["iss"] = "http://oidplus.com";
  274.                 $payload["aud"] = "http://oidplus.com";
  275.                 $payload["jti"] = gen_uuid();
  276.                 $payload["iat"] = time();
  277.  
  278.                 if (OIDplus::getPkiStatus()) {
  279.                         $privKey = OIDplus::config()->getValue('oidplus_private_key');
  280.                         return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS512');
  281.                 } else {
  282.                         $key = OIDplus::baseConfig()->getValue('SERVER_SECRET', '');
  283.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 64/*256bit*/, false);
  284.                         return \Firebase\JWT\JWT::encode($payload, $key, 'HS512');
  285.                 }
  286.         }
  287.  
  288. }
  289.