Subversion Repositories oidplus

Rev

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