Subversion Repositories oidplus

Rev

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