Subversion Repositories oidplus

Rev

Rev 1367 | 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. /**
  27.  * Auth content store for JWT tokens (web browser login cookies, Automated AJAX argument, or REST Bearer)
  28.  */
  29. class OIDplusAuthContentStoreJWT implements OIDplusGetterSetterInterface {
  30.  
  31.         /**
  32.          * Cookie name for the JWT auth token
  33.          */
  34.         const COOKIE_NAME = 'OIDPLUS_AUTH_JWT';
  35.  
  36.         /**
  37.          * Token generator; must be one of OIDplusAuthContentStoreJWT::JWT_GENERATOR_*
  38.          */
  39.         const CLAIM_GENERATOR = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.1';
  40.  
  41.         /**
  42.          * List of logged-in users
  43.          */
  44.         const CLAIM_LOGIN_LIST = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.2';
  45.  
  46.         /**
  47.          * SSH = Server Secret Hash
  48.          */
  49.         const CLAIM_SSH = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.3';
  50.  
  51.         /**
  52.          * IP-Adress limit
  53.          */
  54.         const CLAIM_LIMIT_IP = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.4';
  55.  
  56.         /**
  57.          * Trace JTI, IP, and UserAgent
  58.          */
  59.         const CLAIM_TRACE = 'urn:oid:1.3.6.1.4.1.37476.2.5.2.7.5';
  60.  
  61.         /**
  62.          * "Automated AJAX" plugin
  63.          */
  64.         const JWT_GENERATOR_AJAX   = 10;
  65.         /**
  66.          * "REST API" plugin
  67.          */
  68.         const JWT_GENERATOR_REST   = 20;
  69.         /**
  70.          * Web browser login method
  71.          */
  72.         const JWT_GENERATOR_LOGIN  = 40;
  73.         /**
  74.          * "Manually crafted" JWT tokens
  75.          */
  76.         const JWT_GENERATOR_MANUAL = 80;
  77.  
  78.         /**
  79.          * @return string
  80.          */
  81.         public static function getAudIss(): string {
  82.                 $oid = OIDplus::getSystemId(true);
  83.                 if ($oid !== false) return 'urn:oid:'.$oid;
  84.                 $url = OIDplus::webpath(null, OIDplus::PATH_ABSOLUTE_CANONICAL);
  85.                 if ($url) return $url;
  86.                 return 'http://oidplus.com/';
  87.         }
  88.  
  89.         /**
  90.          * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
  91.          * @param string $sub
  92.          * @return string
  93.          */
  94.         private static function jwtGetBlacklistConfigKey(int $gen, string $sub): string {
  95.                 // Note: Needs to be <= 50 characters! If $gen is 2 chars, then the config key is 49 chars long
  96.                 return 'jwt_blacklist_gen('.$gen.')_sub('.trim(base64_encode(md5($sub,true)),'=').')';
  97.         }
  98.  
  99.         /**
  100.          * @param int $gen
  101.          */
  102.         private static function generatorName($gen) {
  103.                 // Note: The strings are not translated, because the name is used in config keys or logs
  104.                 if ($gen === self::JWT_GENERATOR_AJAX)   return 'Automated AJAX calls';
  105.                 if ($gen === self::JWT_GENERATOR_REST)   return 'REST API';
  106.                 if ($gen === self::JWT_GENERATOR_LOGIN)  return 'Browser login';
  107.                 if ($gen === self::JWT_GENERATOR_MANUAL) return 'Manually created';
  108.                 return 'Unknown generator';
  109.         }
  110.  
  111.         /**
  112.          * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
  113.          * @param string $sub
  114.          * @return void
  115.          * @throws OIDplusException
  116.          */
  117.         public static function jwtBlacklist(int $gen, string $sub) {
  118.                 $cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
  119.                 $bl_time = time()-1;
  120.  
  121.                 $gen_desc = self::generatorName($gen);
  122.  
  123.                 OIDplus::config()->prepareConfigKey($cfg, "Revoke timestamp of all JWT tokens for $sub with generator $gen ($gen_desc)", "$bl_time", OIDplusConfig::PROTECTION_HIDDEN, function($value) {});
  124.                 OIDplus::config()->setValue($cfg, $bl_time);
  125.         }
  126.  
  127.         /**
  128.          * @param int $gen OIDplusAuthContentStoreJWT::JWT_GENERATOR_...
  129.          * @param string $sub E-Mail-Adress of RA or 'admin'
  130.          * @return int
  131.          * @throws OIDplusException
  132.          */
  133.         public static function jwtGetBlacklistTime(int $gen, string $sub): int {
  134.                 $cfg = self::jwtGetBlacklistConfigKey($gen, $sub);
  135.                 return (int)OIDplus::config()->getValue($cfg,0);
  136.         }
  137.  
  138.         /**
  139.          * We include a hash of the server-secret here (ssh = server-secret-hash), so that the JWT can be invalidated by changing the server-secret
  140.          * @return string
  141.          * @throws OIDplusException
  142.          */
  143.         private static function getSsh(): string {
  144.                 $hexadecimal_string = OIDplus::authUtils()->makeSecret(['bb1aebd6-fe6a-11ed-a553-3c4a92df8582']);
  145.                 return base64_encode(pack('H*',$hexadecimal_string));
  146.         }
  147.  
  148.         /**
  149.          * Do various checks if the token is allowed and not blacklisted
  150.          * @param OIDplusAuthContentStoreJWT $contentProvider
  151.          * @param int|null $validGenerators Bitmask which generators to allow (null = allow all)
  152.          * @return void
  153.          * @throws OIDplusException
  154.          */
  155.         private static function jwtSecurityCheck(OIDplusAuthContentStoreJWT $contentProvider, int $validGenerators=null) {
  156.                 // Check if the token is intended for us
  157.                 // Note 'aud' is mandatory for OIDplus, so we do not check for exists()
  158.                 if ($contentProvider->getValue('aud','') !== $contentProvider->getAudIss()) {
  159.                         throw new OIDplusException(_L('Token has wrong audience: Given %1 but expected %2.', $contentProvider->getValue('aud',''), $contentProvider->getAudIss()));
  160.                 }
  161.  
  162.                 // Note CLAIM_SSH is mandatory for OIDplus, so we do not check for exists()
  163.                 if ($contentProvider->getValue(self::CLAIM_SSH, '') !== self::getSsh()) {
  164.                         throw new OIDplusException(_L('"Server Secret" was changed; therefore the JWT is not valid anymore'));
  165.                 }
  166.  
  167.                 // Note CLAIM_GENERATOR is mandatory for OIDplus, so we do not check for exists()
  168.                 $gen = $contentProvider->getValue(self::CLAIM_GENERATOR, -1);
  169.  
  170.                 $has_admin = $contentProvider->isAdminLoggedIn();
  171.                 $has_ra = $contentProvider->raNumLoggedIn() > 0;
  172.  
  173.                 // Check if the token generator is allowed
  174.                 if ($gen === self::JWT_GENERATOR_AJAX) {
  175.                         if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_ADMIN', true)) {
  176.                                 // Generator: plugins/viathinksoft/adminPages/910_automated_ajax_calls/OIDplusPageAdminAutomatedAJAXCalls.class.php
  177.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_ADMIN'));
  178.                         }
  179.                         if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_AJAX_USER', true)) {
  180.                                 // Generator: plugins/viathinksoft/raPages/910_automated_ajax_calls/OIDplusPageRaAutomatedAJAXCalls.class.php
  181.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_AJAX_USER'));
  182.                         }
  183.                 }
  184.                 else if ($gen === self::JWT_GENERATOR_REST) {
  185.                         if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_ADMIN', true)) {
  186.                                 // Generator: plugins/viathinksoft/adminPages/911_rest_api/OIDplusPageAdminRestApi.class.php
  187.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_ADMIN'));
  188.                         }
  189.                         if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_REST_USER', true)) {
  190.                                 // Generator: plugins/viathinksoft/raPages/911_rest_api/OIDplusPageRaRestApi.class.php
  191.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_REST_USER'));
  192.                         }
  193.                 }
  194.                 else if ($gen === self::JWT_GENERATOR_LOGIN) {
  195.                         // Used for web browser login (use JWT token in a cookie as alternative to PHP session):
  196.                         // - No PHP session will be used
  197.                         // - Session will not be bound to IP address (therefore, you can switch between mobile/WiFi for example)
  198.                         // - No server-side session needed
  199.                         if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
  200.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
  201.                         }
  202.                         if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
  203.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
  204.                         }
  205.                 }
  206.                 else if ($gen === self::JWT_GENERATOR_MANUAL) {
  207.                         // Generator: "hand-crafted" tokens
  208.                         if (($has_admin) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL_ADMIN', false)) {
  209.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL_ADMIN'));
  210.                         }
  211.                         if (($has_ra) && !OIDplus::baseConfig()->getValue('JWT_ALLOW_MANUAL_USER', false)) {
  212.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_MANUAL_USER'));
  213.                         }
  214.                 } else {
  215.                         throw new OIDplusException(_L('Token generator %1 not recognized',$gen));
  216.                 }
  217.  
  218.                 // Every token must have and issued timestamp
  219.                 $iat = $contentProvider->getValue('iat',null);
  220.                 if (is_null($iat)) {
  221.                         throw new OIDplusException(_L('The claim "%1" of the JWT token is missing or invalid','iat'));
  222.                 }
  223.  
  224.                 // Verify that IAT has a valid value
  225.                 // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure.
  226.                 if (($iat-120/*leeway 2min*/) > time()) {
  227.                         // Token was created in the future. Something is wrong!
  228.                         throw new OIDplusException(_L('JWT Token cannot be verified because the server time is wrong'));
  229.                 }
  230.  
  231.                 // Check if token is not yet valid
  232.                 // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure.
  233.                 $nbf = $contentProvider->getValue('nbf',null);
  234.                 if (!is_null($nbf)) {
  235.                         if (time() < $nbf-120/*leeway 2min*/) {
  236.                                 throw new OIDplusException(_L('Token not valid before %1',date('d F Y, H:i:s',$nbf)));
  237.                         }
  238.                 }
  239.  
  240.                 // Check if token has expired
  241.                 // Note: This check is already done in Firebase\JWT. However, we do it again, just to be 100% sure.
  242.                 $exp = $contentProvider->getValue('exp',null);
  243.                 if (!is_null($exp)) {
  244.                         if (time() > $exp+120/*leeway 2min*/) {
  245.                                 throw new OIDplusException(_L('Token has expired on %1',date('d F Y, H:i:s',$exp)));
  246.                         }
  247.                 }
  248.  
  249.                 // Make sure that the IAT (issued at time) isn't in a blacklisted timeframe
  250.                 // When an user believes that a token was compromised, then they can blacklist the tokens identified by their "iat" ("Issued at") property
  251.                 // When a user logs out of a web browser session, the JWT token will be blacklisted as well
  252.                 // Small side effect: All web browser login sessions of that user will be revoked then
  253.                 $sublist = $contentProvider->loggedInRaList();
  254.                 $usernames = array();
  255.                 foreach ($sublist as $sub) {
  256.                         $usernames[] = $sub->raEmail();
  257.                 }
  258.                 if ($has_admin) $usernames[] = 'admin';
  259.                 foreach ($usernames as $username) {
  260.                         $bl_time = self::jwtGetBlacklistTime($gen, $username);
  261.                         if ($iat <= $bl_time) {
  262.                                 // Token is blacklisted (it was created before the last blacklist time)
  263.                                 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)));
  264.                         }
  265.                 }
  266.  
  267.                 // Optional feature: Limit the JWT to a specific IP address (used if JWT_FIXED_IP_USER or JWT_FIXED_IP_ADMIN is true)
  268.                 $ip = $contentProvider->getValue(self::CLAIM_LIMIT_IP, null);
  269.                 if (!is_null($ip)) {
  270.                         if ($ip !== OIDplus::getClientIpAddress()) {
  271.                                 throw new OIDplusException(_L('Your IP address is not allowed to use this token'));
  272.                         }
  273.                 }
  274.  
  275.                 // Checks if JWT are dependent on the generator
  276.                 if (!is_null($validGenerators)) {
  277.                         if (($gen & $validGenerators) === 0) {
  278.                                 throw new OIDplusException(_L('This kind of JWT token (%1) cannot be used in this request type', self::generatorName($gen)));
  279.                         }
  280.                 }
  281.         }
  282.  
  283.         // Override abstract functions
  284.  
  285.         /**
  286.          * @var array
  287.          */
  288.         protected $content = array();
  289.  
  290.         /**
  291.          * @param string $name
  292.          * @param mixed|null $default
  293.          * @return mixed|null
  294.          */
  295.         public function getValue(string $name, $default = NULL) {
  296.                 return $this->content[$name] ?? $default;
  297.         }
  298.  
  299.         /**
  300.          * @param string $name
  301.          * @param mixed $value
  302.          * @return void
  303.          */
  304.         public function setValue(string $name, $value) {
  305.                 $this->content[$name] = $value;
  306.         }
  307.  
  308.         /**
  309.          * @param string $name
  310.          * @return bool
  311.          */
  312.         public function exists(string $name): bool {
  313.                 return isset($this->content[$name]);
  314.         }
  315.  
  316.         /**
  317.          * @param string $name
  318.          * @return void
  319.          */
  320.         public function delete(string $name) {
  321.                 unset($this->content[$name]);
  322.         }
  323.  
  324.         /**
  325.          * @return void
  326.          */
  327.         public function activate() {
  328.                 // Send cookie at the end of the HTTP request, in case there are multiple activate() calls
  329.                 OIDplus::register_shutdown_function(array($this,'activateNow'));
  330.         }
  331.  
  332.         /**
  333.          * @return void
  334.          * @throws OIDplusException
  335.          */
  336.         public function activateNow() {
  337.                 $token = $this->getJWTToken();
  338.                 $exp = $this->getValue('exp',0);
  339.                 OIDplus::cookieUtils()->setcookie(self::COOKIE_NAME, $token, $exp, false);
  340.         }
  341.  
  342.         /**
  343.          * @return void
  344.          * @throws OIDplusException
  345.          */
  346.         public function destroySession() {
  347.                 OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
  348.         }
  349.  
  350.         /**
  351.          * @param string[] $ra RAs
  352.          * @param bool $admin Admin yes or no?
  353.          * @param int $gen Generator
  354.          * @param bool $limit_ip Limit IP to the current IP address?
  355.          * @param int $ttl How many seconds valid?
  356.          * @return string JWT token
  357.          */
  358.         public static function craftJWT(array $ra, bool $admin, int $gen, bool $limit_ip=false, int $ttl=10*365*24*60*60): string {
  359.                 $authSimulation = new OIDplusAuthContentStoreJWT();
  360.                 foreach ($ra as $username) {
  361.                         if ($username == 'admin') continue;
  362.                         $authSimulation->raLogin($username);
  363.                 }
  364.                 if ($admin) $authSimulation->adminLogin();
  365.                 $authSimulation->setValue(OIDplusAuthContentStoreJWT::CLAIM_GENERATOR, $gen);
  366.                 $authSimulation->setValue('exp', time()+$ttl);
  367.                 if ($limit_ip) {
  368.                         $cur_ip = OIDplus::getClientIpAddress();
  369.                         if ($cur_ip !== false) {
  370.                                 $authSimulation->setValue(self::CLAIM_LIMIT_IP, $cur_ip);
  371.                         }
  372.                 }
  373.                 return $authSimulation->getJWTToken();
  374.         }
  375.  
  376.         // RA authentication functions (low-level)
  377.  
  378.         /**
  379.          * @param string $email
  380.          * @return void
  381.          */
  382.         public function raLogin(string $email) {
  383.                 if ($email == 'admin') return;
  384.  
  385.                 $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
  386.                 if (is_null($list)) $list = [];
  387.                 if (!in_array($email, $list)) $list[] = $email;
  388.                 $this->setValue(self::CLAIM_LOGIN_LIST, $list);
  389.         }
  390.  
  391.         /**
  392.          * @return int
  393.          */
  394.         public function raNumLoggedIn(): int {
  395.                 return count($this->loggedInRaList());
  396.         }
  397.  
  398.         /**
  399.          * @return OIDplusRA[]
  400.          */
  401.         public function loggedInRaList(): array {
  402.                 $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
  403.                 if (is_null($list)) $list = [];
  404.  
  405.                 $res = array();
  406.                 foreach (array_unique($list) as $username) {
  407.                         if ($username == '') continue; // should not happen
  408.                         if ($username == 'admin') continue;
  409.                         $res[] = new OIDplusRA($username);
  410.                 }
  411.                 return $res;
  412.         }
  413.  
  414.         /**
  415.          * @param string $email
  416.          * @return bool
  417.          */
  418.         public function isRaLoggedIn(string $email): bool {
  419.                 foreach ($this->loggedInRaList() as $ra) {
  420.                         if ($email == $ra->raEmail()) return true;
  421.                 }
  422.                 return false;
  423.         }
  424.  
  425.         /**
  426.          * @param string $email
  427.          * @return void
  428.          * @throws OIDplusException
  429.          */
  430.         public function raLogout(string $email) {
  431.                 if ($email == 'admin') return;
  432.  
  433.                 $gen = $this->getValue(self::CLAIM_GENERATOR, -1);
  434.                 if ($gen >= 0) self::jwtBlacklist($gen, $email);
  435.  
  436.                 $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
  437.                 if (is_null($list)) $list = [];
  438.                 $key = array_search($email, $list);
  439.                 if ($key !== false) unset($list[$key]);
  440.                 $this->setValue(self::CLAIM_LOGIN_LIST, $list);
  441.         }
  442.  
  443.         /**
  444.          * @param string $email
  445.          * @param string $loginfo
  446.          * @return void
  447.          * @throws OIDplusException
  448.          */
  449.         public function raLogoutEx(string $email, string &$loginfo) {
  450.                 $this->raLogout($email);
  451.                 $loginfo = 'from JWT session';
  452.         }
  453.  
  454.         // Admin authentication functions (low-level)
  455.  
  456.         /**
  457.          * @return void
  458.          */
  459.         public function adminLogin() {
  460.                 $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
  461.                 if (is_null($list)) $list = [];
  462.                 if (!in_array('admin', $list)) $list[] = 'admin';
  463.                 $this->setValue(self::CLAIM_LOGIN_LIST, $list);
  464.         }
  465.  
  466.         /**
  467.          * @return bool
  468.          */
  469.         public function isAdminLoggedIn(): bool {
  470.                 $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
  471.                 if (is_null($list)) $list = [];
  472.                 return in_array('admin', $list);
  473.         }
  474.  
  475.         /**
  476.          * @return void
  477.          * @throws OIDplusException
  478.          */
  479.         public function adminLogout() {
  480.                 $gen = $this->getValue(self::CLAIM_GENERATOR, -1);
  481.                 if ($gen >= 0) self::jwtBlacklist($gen, 'admin');
  482.  
  483.                 $list = $this->getValue(self::CLAIM_LOGIN_LIST, null);
  484.                 if (is_null($list)) $list = [];
  485.                 $key = array_search('admin', $list);
  486.                 if ($key !== false) unset($list[$key]);
  487.                 $this->setValue(self::CLAIM_LOGIN_LIST, $list);
  488.         }
  489.  
  490.         /**
  491.          * @param string $loginfo
  492.          * @return void
  493.          * @throws OIDplusException
  494.          */
  495.         public function adminLogoutEx(string &$loginfo) {
  496.                 $this->adminLogout();
  497.                 $loginfo = 'from JWT session';
  498.         }
  499.  
  500.         private static $contentProvider = null;
  501.  
  502.         /**
  503.          * @return OIDplusAuthContentStoreJWT|null
  504.          * @throws OIDplusException
  505.          */
  506.         public static function getActiveProvider()/*: ?OIDplusAuthContentStoreJWT*/ {
  507.                 if (!self::$contentProvider) {
  508.  
  509.                         $tmp = null;
  510.                         $silent_error = false;
  511.  
  512.                         try {
  513.  
  514.                                 if (isset($_SERVER['REQUEST_URI'])) {
  515.                                         $rel_url = substr($_SERVER['REQUEST_URI'], strlen(OIDplus::webpath(null, OIDplus::PATH_RELATIVE_TO_ROOT)));
  516.                                         $only_use_bearer = 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.
  517.                                 } else {
  518.                                         $only_use_bearer = false;
  519.                                 }
  520.  
  521.                                 if ($only_use_bearer) {
  522.  
  523.                                         // REST may only use Bearer Authentication
  524.                                         $bearer = getBearerToken();
  525.                                         if (!is_null($bearer)) {
  526.                                                 $silent_error = false;
  527.                                                 $tmp = new OIDplusAuthContentStoreJWT();
  528.                                                 $tmp->loadJWT($bearer);
  529.                                                 self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_REST | self::JWT_GENERATOR_MANUAL);
  530.                                         }
  531.  
  532.                                 } else {
  533.  
  534.                                         // A web-visitor (HTML and AJAX, but not REST) can use a JWT Cookie
  535.                                         if (isset($_COOKIE[self::COOKIE_NAME])) {
  536.                                                 $silent_error = true;
  537.                                                 $tmp = new OIDplusAuthContentStoreJWT();
  538.                                                 $tmp->loadJWT($_COOKIE[self::COOKIE_NAME]);
  539.                                                 self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_LOGIN | self::JWT_GENERATOR_MANUAL);
  540.                                         }
  541.  
  542.                                         // AJAX may additionally use GET/POST automated AJAX (in addition to the normal web browser login Cookie)
  543.                                         if (isset($_SERVER['SCRIPT_FILENAME']) && (strtolower(basename($_SERVER['SCRIPT_FILENAME'])) !== 'ajax.php')) {
  544.                                                 if (isset($_POST[self::COOKIE_NAME])) {
  545.                                                         $silent_error = false;
  546.                                                         $tmp = new OIDplusAuthContentStoreJWT();
  547.                                                         $tmp->loadJWT($_POST[self::COOKIE_NAME]);
  548.                                                         self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
  549.                                                 }
  550.                                                 if (isset($_GET[self::COOKIE_NAME])) {
  551.                                                         $silent_error = false;
  552.                                                         $tmp = new OIDplusAuthContentStoreJWT();
  553.                                                         $tmp->loadJWT($_GET[self::COOKIE_NAME]);
  554.                                                         self::jwtSecurityCheck($tmp, self::JWT_GENERATOR_AJAX | self::JWT_GENERATOR_MANUAL);
  555.                                                 }
  556.                                         }
  557.  
  558.                                 }
  559.  
  560.                         } catch (\Exception $e) {
  561.                                 if (!$silent_error || OIDplus::baseConfig()->getValue('DEBUG',false)) {
  562.                                         // Most likely an AJAX request. We can throw an Exception
  563.                                         if (OIDplus::baseConfig()->getValue('DEBUG',false)) {
  564.                                                 OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
  565.                                         }
  566.                                         throw new OIDplusException(_L('The JWT token was rejected: %1',$e->getMessage()));
  567.                                 } else {
  568.                                         // Most likely an expired Cookie/Login session. We must not throw an Exception, otherwise we will break jsTree
  569.                                         OIDplus::cookieUtils()->unsetcookie(self::COOKIE_NAME);
  570.                                         return null;
  571.                                 }
  572.                         }
  573.  
  574.                         self::$contentProvider = $tmp;
  575.                 }
  576.  
  577.                 return self::$contentProvider;
  578.         }
  579.  
  580.         /**
  581.          * @param string $email
  582.          * @param string $loginfo
  583.          * @return void
  584.          * @throws OIDplusException
  585.          */
  586.         public function raLoginEx(string $email, string &$loginfo) {
  587.                 if (is_null(self::getActiveProvider())) {
  588.                         $loginfo = 'into new JWT session';
  589.                         self::$contentProvider = $this;
  590.                 } else {
  591.                         $gen = $this->getValue(self::CLAIM_GENERATOR,-1);
  592.                         switch ($gen) {
  593.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
  594.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
  595.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
  596.                                         throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
  597.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
  598.                                         if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
  599.                                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
  600.                                         }
  601.                                         break;
  602.                                 default:
  603.                                         assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
  604.                                         break;
  605.                         }
  606.                         $loginfo = 'into existing JWT session';
  607.                 }
  608.                 $this->raLogin($email);
  609.                 $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 30*24*60*60);
  610.                 $this->setValue('exp', time()+$ttl); // JWT "exp" attribute
  611.                 if (OIDplus::baseConfig()->getValue('JWT_FIXED_IP_USER', false)) {
  612.                         $cur_ip = OIDplus::getClientIpAddress();
  613.                         if ($cur_ip !== false) {
  614.                                 $this->setValue(self::CLAIM_LIMIT_IP, $cur_ip);
  615.                         }
  616.                 }
  617.         }
  618.  
  619.         /**
  620.          * @param string $loginfo
  621.          * @return void
  622.          * @throws OIDplusException
  623.          */
  624.         public function adminLoginEx(string &$loginfo) {
  625.                 if (is_null(self::getActiveProvider())) {
  626.                         $loginfo = 'into new JWT session';
  627.                         self::$contentProvider = $this;
  628.                 } else {
  629.                         $gen = $this->getValue(self::CLAIM_GENERATOR,-1);
  630.                         switch ($gen) {
  631.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_AJAX :
  632.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_REST :
  633.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_MANUAL :
  634.                                         throw new OIDplusException(_L('This kind of JWT token cannot be altered. Therefore you cannot do this action.'));
  635.                                 case OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN :
  636.                                         if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
  637.                                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
  638.                                         }
  639.                                         break;
  640.                                 default:
  641.                                         assert(false); // This cannot happen because jwtSecurityCheck will check for unknown generators
  642.                                         break;
  643.                         }
  644.                         $loginfo = 'into existing JWT session';
  645.                 }
  646.                 $this->adminLogin();
  647.                 $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 30*24*60*60);
  648.                 $this->setValue('exp', time()+$ttl); // JWT "exp" attribute
  649.                 if (OIDplus::baseConfig()->getValue('JWT_FIXED_IP_ADMIN', false)) {
  650.                         $cur_ip = OIDplus::getClientIpAddress();
  651.                         if ($cur_ip !== false) {
  652.                                 $this->setValue(self::CLAIM_LIMIT_IP, $cur_ip);
  653.                         }
  654.                 }
  655.         }
  656.  
  657.         // Individual functions
  658.  
  659.         /**
  660.          * Decode the JWT. In this step, the signature as well as EXP/NBF times will be checked
  661.          * @param string $jwt
  662.          * @return void
  663.          * @throws OIDplusException
  664.          */
  665.         public function loadJWT(string $jwt) {
  666.                 \Firebase\JWT\JWT::$leeway = 60; // leeway in seconds
  667.                 $cls_content = null;
  668.                 if (OIDplus::getPkiStatus()) {
  669.                         $pubKey = OIDplus::getSystemPublicKey();
  670.                         $k = new \Firebase\JWT\Key($pubKey, 'RS256'); // RSA+SHA256 is hardcoded in getPkiStatus() generation
  671.                         $cls_content = \Firebase\JWT\JWT::decode($jwt, $k);
  672.                 } else {
  673.                         $key = OIDplus::authUtils()->makeSecret(['0be35e52-f4ef-11ed-b67e-3c4a92df8582']);
  674.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
  675.                         $k = new \Firebase\JWT\Key($key, 'HS512'); // HMAC+SHA512 is hardcoded here
  676.                         $cls_content = \Firebase\JWT\JWT::decode($jwt, $k);
  677.                 }
  678.                 $this->content = json_decode(json_encode($cls_content), true); // convert stdClass to array
  679.         }
  680.  
  681.         /**
  682.          * @return string
  683.          * @throws OIDplusException
  684.          */
  685.         public function getJWTToken(): string {
  686.                 $payload = $this->content;
  687.                 $payload[self::CLAIM_SSH] = self::getSsh(); // SSH = Server Secret Hash
  688.                 // see also https://www.iana.org/assignments/jwt/jwt.xhtml#claims for some generic claims
  689.                 if (!isset($payload["iss"])) $payload["iss"] = $this->getAudIss();
  690.                 if (!isset($payload["aud"])) $payload["aud"] = $this->getAudIss();
  691.                 $payload["jti"] = gen_uuid(); // always set/renew it; therefore not checking isset()
  692.                 $payload["iat"] = time(); // always set/renew it; therefore not checking isset()
  693.                 if (!isset($payload["nbf"])) $payload["nbf"] = time();
  694.                 if (!isset($payload["exp"])) $payload["exp"] = time()+3600/*1h*/;
  695.  
  696.                 $cur_ip = OIDplus::getClientIpAddress();
  697.                 if (!isset($payload[self::CLAIM_TRACE])) {
  698.                         // "Trace" can be used for later updates
  699.                         // For example, if the IP changes "too much" (different country, different AS, etc.)
  700.                         // Or revoke all tokens from a single login flow (sequence 1, 2, 3, ...)
  701.                         $payload[self::CLAIM_TRACE] = array();
  702.                         $payload[self::CLAIM_TRACE]['iat_1st'] = $payload["iat"];
  703.                         $payload[self::CLAIM_TRACE]['jti_1st'] = $payload["jti"];
  704.                         $payload[self::CLAIM_TRACE]['seq'] = 1;
  705.                         if ($cur_ip !== false) $payload[self::CLAIM_TRACE]['ip'] = $cur_ip;
  706.                         $payload[self::CLAIM_TRACE]['ip_1st'] = $payload[self::CLAIM_TRACE]['ip'];
  707.                         $payload[self::CLAIM_TRACE]['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
  708.                         $payload[self::CLAIM_TRACE]['ua_1st'] = $payload[self::CLAIM_TRACE]['ua'];
  709.                 } else {
  710.                         assert(is_numeric($payload[self::CLAIM_TRACE]['seq']));
  711.                         $payload[self::CLAIM_TRACE]['seq']++;
  712.                         if ($cur_ip !== false) $payload[self::CLAIM_TRACE]['ip'] = $cur_ip;
  713.                         $payload[self::CLAIM_TRACE]['ua'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
  714.                 }
  715.  
  716.                 uksort($payload, "strnatcmp"); // this is natsort on the key. Just to make the JWT look nicer.
  717.  
  718.                 if (OIDplus::getPkiStatus()) {
  719.                         $privKey = OIDplus::getSystemPrivateKey();
  720.                         return \Firebase\JWT\JWT::encode($payload, $privKey, 'RS256'); // RSA+SHA256 is hardcoded in getPkiStatus() generation
  721.                 } else {
  722.                         $key = OIDplus::authUtils()->makeSecret(['0be35e52-f4ef-11ed-b67e-3c4a92df8582']);
  723.                         $key = hash_pbkdf2('sha512', $key, '', 10000, 32/*256bit*/, false);
  724.                         return \Firebase\JWT\JWT::encode($payload, $key, 'HS512'); // HMAC+SHA512 is hardcoded here
  725.                 }
  726.         }
  727.  
  728. }
  729.