Subversion Repositories oidplus

Rev

Rev 1304 | Rev 1306 | 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 OIDplusAuthUtils extends OIDplusBaseClass {
  27.  
  28.         // Useful functions
  29.  
  30.         /**
  31.          * @param string $password
  32.          * @return string
  33.          * @throws OIDplusException
  34.          */
  35.         private function raPepperProcessing(string $password): string {
  36.                 // Additional feature: Pepper
  37.                 // The pepper is stored inside the base configuration file
  38.                 // It prevents that an attacker with SQL write rights can
  39.                 // create accounts.
  40.                 // ATTENTION!!! If a pepper is used, then the
  41.                 // hashes are bound to that pepper. If you change the pepper,
  42.                 // then ALL passwords of RAs become INVALID!
  43.                 $pepper = OIDplus::baseConfig()->getValue('RA_PASSWORD_PEPPER','');
  44.                 if ($pepper !== '') {
  45.                         $algo = OIDplus::baseConfig()->getValue('RA_PASSWORD_PEPPER_ALGO','sha512'); // sha512 works with PHP 7.0
  46.                         if (strtolower($algo) === 'sha3-512') {
  47.                                 $hmac = sha3_512_hmac($password, $pepper);
  48.                         } else {
  49.                                 $hmac = hash_hmac($algo, $password, $pepper);
  50.                         }
  51.                         if ($hmac === "") throw new OIDplusException(_L('HMAC failed'));
  52.                         return $hmac;
  53.                 } else {
  54.                         return $password;
  55.                 }
  56.         }
  57.  
  58.         // Content provider
  59.  
  60.         /**
  61.          * @return string
  62.          * @throws OIDplusException
  63.          */
  64.         public function getAuthMethod(): string {
  65.                 $acs = $this->getAuthContentStore();
  66.                 if (is_null($acs)) return 'null';
  67.                 return get_class($acs);
  68.         }
  69.  
  70.         /**
  71.          * @return OIDplusAuthContentStoreJWT|null
  72.          * @throws OIDplusException
  73.          */
  74.         protected function getAuthContentStore()/*: ?OIDplusAuthContentStore*/ {
  75.                 // Logged in via JWT
  76.                 // (The JWT can come from a login cookie, an REST Authentication Bearer, an AJAX Cookie, or an Automated AJAX Call GET/POST token.)
  77.                 $tmp = OIDplusAuthContentStoreJWT::getActiveProvider();
  78.                 if ($tmp) return $tmp;
  79.  
  80.                 // No active session and no JWT token available. User is not logged in.
  81.                 return null;
  82.         }
  83.  
  84.         /**
  85.          * @param string $name
  86.          * @param mixed|null $default
  87.          * @return mixed
  88.          * @throws OIDplusException
  89.          */
  90.         public function getExtendedAttribute(string $name, $default=NULL) {
  91.                 $acs = $this->getAuthContentStore();
  92.                 if (is_null($acs)) return $default;
  93.                 return $acs->getValue($name, $default);
  94.         }
  95.  
  96.         // RA authentication functions
  97.  
  98.         /**
  99.          * @param string $email
  100.          * @return void
  101.          * @throws OIDplusException
  102.          */
  103.         public function raLogin(string $email) {
  104.                 $acs = $this->getAuthContentStore();
  105.                 if (is_null($acs)) return;
  106.                 $acs->raLogin($email);
  107.         }
  108.  
  109.         /**
  110.          * @param string $email
  111.          * @return void
  112.          * @throws OIDplusException
  113.          */
  114.         public function raLogout(string $email) {
  115.                 $acs = $this->getAuthContentStore();
  116.                 if (is_null($acs)) return;
  117.                 $acs->raLogout($email);
  118.         }
  119.  
  120.         /**
  121.          * @param string $ra_email
  122.          * @param string $password
  123.          * @return bool
  124.          * @throws OIDplusException
  125.          */
  126.         public function raCheckPassword(string $ra_email, string $password): bool {
  127.                 $ra = new OIDplusRA($ra_email);
  128.  
  129.                 // Get RA info from RA
  130.                 $authInfo = $ra->getAuthInfo();
  131.                 if (!$authInfo) return false; // user not found
  132.  
  133.                 // Ask plugins if they can verify this hash
  134.                 $plugins = OIDplus::getAuthPlugins();
  135.                 if (count($plugins) == 0) {
  136.                         throw new OIDplusException(_L('No RA authentication plugins found'));
  137.                 }
  138.                 foreach ($plugins as $plugin) {
  139.                         if ($plugin->verify($authInfo, $this->raPepperProcessing($password))) return true;
  140.                 }
  141.  
  142.                 return false;
  143.         }
  144.  
  145.         /**
  146.          * @return int
  147.          * @throws OIDplusException
  148.          */
  149.         public function raNumLoggedIn(): int {
  150.                 $acs = $this->getAuthContentStore();
  151.                 if (is_null($acs)) return 0;
  152.                 return $acs->raNumLoggedIn();
  153.         }
  154.  
  155.         /**
  156.          * @return OIDplusRA[]
  157.          * @throws OIDplusException
  158.          */
  159.         public function loggedInRaList(): array {
  160.                 if ($this->forceAllLoggedOut()) {
  161.                         return array();
  162.                 } else {
  163.                         $acs = $this->getAuthContentStore();
  164.                         if (is_null($acs)) return array();
  165.                         return $acs->loggedInRaList();
  166.                 }
  167.         }
  168.  
  169.         /**
  170.          * @param string|OIDplusRA $ra
  171.          * @return bool
  172.          * @throws OIDplusException
  173.          */
  174.         public function isRaLoggedIn($ra): bool {
  175.                 $email = $ra instanceof OIDplusRA ? $ra->raEmail() : $ra;
  176.                 $acs = $this->getAuthContentStore();
  177.                 if (is_null($acs)) return false;
  178.                 return $acs->isRaLoggedIn($email);
  179.         }
  180.  
  181.         // "High level" function including logging and checking for valid JWT alternations
  182.  
  183.         /**
  184.          * @param string $email
  185.          * @param string $origin
  186.          * @return void
  187.          * @throws OIDplusException
  188.          */
  189.         public function raLoginEx(string $email, string $origin='') {
  190.                 $loginfo = '';
  191.                 $acs = $this->getAuthContentStore();
  192.                 if (!is_null($acs)) {
  193.                         // User is already logged in (a session or JWT exists), so we modify their login status
  194.                         $acs->raLoginEx($email, $loginfo);
  195.                         $acs->activate();
  196.                 } else {
  197.                         // No user is logged in (no session or JWT exists). We now create a auth content store and activate it (cookies will be set etc.)
  198.                         if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_USER', true)) {
  199.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_USER'));
  200.                         }
  201.                         $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_USER', 10*365*24*60*60);
  202.                         $newAuthStore = new OIDplusAuthContentStoreJWT();
  203.                         $newAuthStore->setValue(OIDplusAuthContentStoreJWT::CLAIM_GENERATOR, OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
  204.                         $newAuthStore->setValue('exp', time()+$ttl); // JWT "exp" attribute
  205.                         $newAuthStore->raLoginEx($email, $loginfo);
  206.                         $newAuthStore->activate();
  207.                 }
  208.                 $logmsg = "RA '$email' logged in";
  209.                 if ($origin != '') $logmsg .= " via $origin";
  210.                 if ($loginfo != '') $logmsg .= " ($loginfo)";
  211.                 OIDplus::logger()->log("V2:[OK]RA(%1)", "%2", $email, $logmsg);
  212.         }
  213.  
  214.         /**
  215.          * @param string $email
  216.          * @return void
  217.          * @throws OIDplusException
  218.          */
  219.         public function raLogoutEx(string $email) {
  220.                 $loginfo = '';
  221.  
  222.                 $acs = $this->getAuthContentStore();
  223.                 if (is_null($acs)) return;
  224.                 $acs->raLogoutEx($email, $loginfo);
  225.  
  226.                 OIDplus::logger()->log("V2:[OK]RA(%1)", "RA '%1' logged out (%2)", $email, $loginfo);
  227.  
  228.                 if (($this->raNumLoggedIn() == 0) && (!$this->isAdminLoggedIn())) {
  229.                         // Nobody logged in anymore. Destroy session cookie to make GDPR people happy
  230.                         $acs->destroySession();
  231.                 } else {
  232.                         // Get a new token for the remaining users
  233.                         $acs->activate();
  234.                 }
  235.         }
  236.  
  237.         // Admin authentication functions
  238.  
  239.         /**
  240.          * @return void
  241.          * @throws OIDplusException
  242.          */
  243.         public function adminLogin() {
  244.                 $acs = $this->getAuthContentStore();
  245.                 if (is_null($acs)) return;
  246.                 $acs->adminLogin();
  247.         }
  248.  
  249.         /**
  250.          * @return void
  251.          * @throws OIDplusException
  252.          */
  253.         public function adminLogout() {
  254.                 $acs = $this->getAuthContentStore();
  255.                 if (is_null($acs)) return;
  256.                 $acs->adminLogout();
  257.         }
  258.  
  259.         /**
  260.          * @param string $password
  261.          * @return bool
  262.          * @throws OIDplusException
  263.          */
  264.         public function adminCheckPassword(string $password): bool {
  265.                 $cfgData = OIDplus::baseConfig()->getValue('ADMIN_PASSWORD', '');
  266.                 if (empty($cfgData)) {
  267.                         throw new OIDplusException(_L('No admin password set in %1','userdata/baseconfig/config.inc.php'));
  268.                 }
  269.  
  270.                 if (!is_array($cfgData)) {
  271.                         $passwordDataArray = array($cfgData);
  272.                 } else {
  273.                         $passwordDataArray = $cfgData; // Multiple Administrator passwords
  274.                 }
  275.  
  276.                 foreach ($passwordDataArray as $passwordData) {
  277.                         if (str_starts_with($passwordData, '$')) {
  278.                                 // Version 3: BCrypt (or any other crypt)
  279.                                 $ok = password_verify($password, $passwordData);
  280.                         } else if (strpos($passwordData, '$') !== false) {
  281.                                 // Version 2: SHA3-512 with salt
  282.                                 list($salt, $hash) = explode('$', $passwordData, 2);
  283.                                 $ok = hash_equals(sha3_512($salt.$password, true), base64_decode($hash));
  284.                         } else {
  285.                                 // Version 1: SHA3-512 without salt
  286.                                 $ok = hash_equals(sha3_512($password, true), base64_decode($passwordData));
  287.                         }
  288.                         if ($ok) return true;
  289.                 }
  290.  
  291.                 return false;
  292.         }
  293.  
  294.         /**
  295.          * @return bool
  296.          * @throws OIDplusException
  297.          */
  298.         public function isAdminLoggedIn(): bool {
  299.                 if ($this->forceAllLoggedOut()) {
  300.                         return false;
  301.                 } else {
  302.                         $acs = $this->getAuthContentStore();
  303.                         if (is_null($acs)) return false;
  304.                         return $acs->isAdminLoggedIn();
  305.                 }
  306.         }
  307.  
  308.         /**
  309.          * "High level" function including logging and checking for valid JWT alternations
  310.          * @param string $origin
  311.          * @return void
  312.          * @throws OIDplusException
  313.          */
  314.         public function adminLoginEx(string $origin='') {
  315.                 $loginfo = '';
  316.                 $acs = $this->getAuthContentStore();
  317.                 if (!is_null($acs)) {
  318.                         // User is already logged in (a session or JWT exists), so we modify their login status
  319.                         $acs->adminLoginEx($loginfo);
  320.                         $acs->activate();
  321.                 } else {
  322.                         // No user is logged in (no session or JWT exists). We now create a auth content store and activate it (cookies will be set etc.)
  323.                         if (!OIDplus::baseConfig()->getValue('JWT_ALLOW_LOGIN_ADMIN', true)) {
  324.                                 throw new OIDplusException(_L('The administrator has disabled this feature. (Base configuration setting %1).','JWT_ALLOW_LOGIN_ADMIN'));
  325.                         }
  326.                         $ttl = OIDplus::baseConfig()->getValue('JWT_TTL_LOGIN_ADMIN', 10*365*24*60*60);
  327.                         $newAuthStore = new OIDplusAuthContentStoreJWT();
  328.                         $newAuthStore->setValue(OIDplusAuthContentStoreJWT::CLAIM_GENERATOR, OIDplusAuthContentStoreJWT::JWT_GENERATOR_LOGIN);
  329.                         $newAuthStore->setValue('exp', time()+$ttl); // JWT "exp" attribute
  330.                         $newAuthStore->adminLoginEx($loginfo);
  331.                         $newAuthStore->activate();
  332.                 }
  333.                 $logmsg = "Admin logged in";
  334.                 if ($origin != '') $logmsg .= " via $origin";
  335.                 if ($loginfo != '') $logmsg .= " ($loginfo)";
  336.                 OIDplus::logger()->log("V2:[OK]A", "%1", $logmsg);
  337.         }
  338.  
  339.         /**
  340.          * @return void
  341.          * @throws OIDplusException
  342.          */
  343.         public function adminLogoutEx() {
  344.                 $loginfo = '';
  345.  
  346.                 $acs = $this->getAuthContentStore();
  347.                 if (is_null($acs)) return;
  348.                 $acs->adminLogoutEx($loginfo);
  349.  
  350.                 if ($this->raNumLoggedIn() == 0) {
  351.                         // Nobody here anymore. Destroy the cookie to make GDPR people happy
  352.                         $acs->destroySession();
  353.                 } else {
  354.                         // Get a new token for the remaining users
  355.                         $acs->activate();
  356.                 }
  357.  
  358.                 OIDplus::logger()->log("V2:[OK]A", "Admin logged out (%1)", $loginfo);
  359.         }
  360.  
  361.         // Authentication keys for generating secrets or validating arguments (e.g. sent by mail)
  362.  
  363.         /**
  364.          * @param array|string $data
  365.          * @return string
  366.          * @throws OIDplusException
  367.          */
  368.         public function makeSecret($data): string {
  369.                 if (!is_array($data)) $data = [$data];
  370.                 $data = json_encode($data);
  371.                 return sha3_512_hmac($data, 'OIDplus:'.OIDplus::baseConfig()->getValue('SERVER_SECRET'), false);
  372.         }
  373.  
  374.         /**
  375.          * @param array|string $data Arbitary data to be validated later
  376.          * @return string A string that need to be validated with validateAuthKey
  377.          * @throws OIDplusException
  378.          */
  379.         public function makeAuthKey($data): string {
  380.                 if (!is_array($data)) $data = [$data];
  381.                 $ts = time();
  382.                 $data_ext = [$ts, $data];
  383.                 $secret = $this->makeSecret($data_ext);
  384.                 return $ts.'.'.$secret;
  385.         }
  386.  
  387.         /**
  388.          * @param array|string $data The original data that had been passed to makeAuthKey()
  389.          * @param string $auth_key The result from makeAuthKey()
  390.          * @param int $valid_secs How many seconds is the auth key valid? (-1 for infinite)
  391.          * @return bool True if the key is valid and not expired.
  392.          * @throws OIDplusException
  393.          */
  394.         public function validateAuthKey($data, string $auth_key, int $valid_secs=-1): bool {
  395.                 $auth_key_ary = explode('.', $auth_key, 2);
  396.                 if (count($auth_key_ary) != 2) return false; // invalid auth key syntax
  397.                 list($ts, $secret) = $auth_key_ary;
  398.                 if (!is_numeric($ts)) return false; // invalid auth key syntax
  399.                 if ($valid_secs >= 0) {
  400.                         if (time() > ($ts+$valid_secs)) return false; // expired auth key
  401.                 }
  402.                 if (!is_array($data)) $data = [$data];
  403.                 $data_ext = [(int)$ts, $data];
  404.                 return hash_equals($this->makeSecret($data_ext), $secret);
  405.         }
  406.  
  407.         // "Veto" functions to force logout state
  408.  
  409.         /**
  410.          * @return bool
  411.          */
  412.         protected function forceAllLoggedOut(): bool {
  413.                 if (isset($_SERVER['SCRIPT_FILENAME']) && (basename($_SERVER['SCRIPT_FILENAME']) == 'sitemap.php')) {
  414.                         // The sitemap may not contain any confidential information,
  415.                         // even if the user is logged in, because the admin could
  416.                         // accidentally copy-paste the sitemap to a
  417.                         // search engine control panel while they are logged in
  418.                         return true;
  419.                 } else {
  420.                         return false;
  421.                 }
  422.         }
  423.  
  424.         // CSRF functions
  425.  
  426.         private $enable_csrf = true;
  427.  
  428.         /**
  429.          * @return void
  430.          */
  431.         public function enableCSRF() {
  432.                 $this->enable_csrf = true;
  433.         }
  434.  
  435.         /**
  436.          * @return void
  437.          */
  438.         public function disableCSRF() {
  439.                 $this->enable_csrf = false;
  440.         }
  441.  
  442.         /**
  443.          * @return string
  444.          * @throws \Random\RandomException
  445.          */
  446.         public function genCSRFToken(): string {
  447.                 return random_bytes_ex(64, false, false);
  448.         }
  449.  
  450.         /**
  451.          * @return void
  452.          * @throws OIDplusException
  453.          */
  454.         public function checkCSRF() {
  455.                 if (!$this->enable_csrf) return;
  456.  
  457.                 $request_token = $_REQUEST['csrf_token'] ?? '';
  458.                 $cookie_token = $_COOKIE['csrf_token'] ?? '';
  459.  
  460.                 if (empty($request_token) || empty($cookie_token) || ($request_token !== $cookie_token)) {
  461.                         if (OIDplus::baseConfig()->getValue('DEBUG')) {
  462.                                 throw new OIDplusException(_L('Missing or wrong CSRF Token: Request %1 vs Cookie %2',
  463.                                         isset($_REQUEST['csrf_token']) ? '"'.$_REQUEST['csrf_token'].'"' : 'NULL',
  464.                                         $_COOKIE['csrf_token'] ?? 'NULL'
  465.                                 ));
  466.                         } else {
  467.                                 throw new OIDplusException(_L('Missing or wrong "CSRF Token". To fix the issue, try clearing your browser cache and reload the page. If you visited the page via HTTPS before, try HTTPS in case you are currently connected via HTTP.'));
  468.                         }
  469.                 }
  470.         }
  471.  
  472.         // Generate RA passwords
  473.  
  474.         /**
  475.          * @param string $password
  476.          * @return OIDplusRAAuthInfo
  477.          * @throws OIDplusException
  478.          */
  479.         public function raGeneratePassword(string $password): OIDplusRAAuthInfo {
  480.                 $plugin = OIDplus::getDefaultRaAuthPlugin(true);
  481.                 return $plugin->generate($this->raPepperProcessing($password));
  482.         }
  483.  
  484.         // Generate admin password
  485.  
  486.         /* Nothing here; the admin password will be generated in setup_base.js , purely in the web-browser */
  487.  
  488. }
  489.