Subversion Repositories oidplus

Rev

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