From 3a429e3f118b0c315a9bfed7445c39d93c26b219 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 12 Feb 2021 12:51:52 -0800 Subject: [PATCH] authx - Primary implementation, including Drupal 7 and WordPress --- ext/authx/Civi/Authx/Authenticator.php | 189 ++++++++++++++++++++++++ ext/authx/Civi/Authx/AuthxInterface.php | 74 ++++++++++ ext/authx/Civi/Authx/Drupal.php | 58 ++++++++ ext/authx/Civi/Authx/None.php | 51 +++++++ ext/authx/Civi/Authx/WordPress.php | 70 +++++++++ ext/authx/authx.php | 47 ++++++ 6 files changed, 489 insertions(+) create mode 100644 ext/authx/Civi/Authx/Authenticator.php create mode 100644 ext/authx/Civi/Authx/AuthxInterface.php create mode 100644 ext/authx/Civi/Authx/Drupal.php create mode 100644 ext/authx/Civi/Authx/None.php create mode 100644 ext/authx/Civi/Authx/WordPress.php diff --git a/ext/authx/Civi/Authx/Authenticator.php b/ext/authx/Civi/Authx/Authenticator.php new file mode 100644 index 0000000000..d336e2b24c --- /dev/null +++ b/ext/authx/Civi/Authx/Authenticator.php @@ -0,0 +1,189 @@ +flow = $flow; + $this->allowCreds = \Civi::settings()->get('authx_' . $flow . '_cred'); + $this->userMode = \Civi::settings()->get('authx_' . $flow . '_user'); + } + + /** + * @param \Civi\Core\Event\GenericHookEvent $e + * @param string $cred + * The credential, as formatted in the 'Authorization' header. + * Ex: 'Bearer 12345' + * Ex: 'Basic ASDFFDSA==' + * @param bool $useSession + * If TRUE, then the authentication should be persistent (in a session variable). + * If FALSE, then the authentication should be ephemeral (single page-request). + * @return bool + * Returns TRUE on success. + * Exits with failure + */ + public function auth($e, $cred, $useSession = FALSE) { + $authxUf = _authx_uf(); + [$credType, $credValue] = explode(' ', $cred, 2); + switch ($credType) { + case 'Basic': + if (!in_array('pass', $this->allowCreds)) { + $this->reject('Password authentication is not supported'); + } + [$user, $pass] = explode(':', base64_decode($credValue), 2); + if ($userId = $authxUf->checkPassword($user, $pass)) { + $contactId = \CRM_Core_BAO_UFMatch::getContactId($userId); + $this->login($contactId, $userId, $useSession); + return TRUE; + } + break; + + case 'Bearer': + if ($contactId = $this->lookupContactToken($credValue)) { + $userId = \CRM_Core_BAO_UFMatch::getUFId($contactId); + $this->login($contactId, $userId, $useSession); + return TRUE; + } + break; + + default: + $this->reject(); + } + + $this->reject(); + } + + /** + * Update Civi and UF to recognize the authenticated user. + * + * @param int $contactId + * The CiviCRM contact which is logging in. + * @param int|string|null $userId + * The UF user which is logging in. May be NULL if there is no corresponding user. + * @param bool $useSession + * Whether the login should be part of a persistent session. + * @throws \Exception + */ + protected function login($contactId, $userId, bool $useSession) { + $authxUf = _authx_uf(); + + if (\CRM_Core_Session::getLoggedInContactID() || $authxUf->getCurrentUserId()) { + if (\CRM_Core_Session::getLoggedInContactID() === $contactId && $authxUf->getCurrentUserId() === $userId) { + return; + } + else { + // This is plausible if you have a dev or admin experimenting. + // We should probably show a more useful page - e.g. ask if they want + // logout and/or suggest using private browser window. + $this->reject('Cannot login. Session already active.'); + } + } + + if (empty($contactId)) { + $this->reject("Cannot login. Failed to determine contact ID."); + } + + switch ($this->userMode) { + case 'ignore': + $userId = NULL; + break; + + case 'require': + if (empty($userId)) { + $this->reject('Cannot login. No matching user is available.'); + } + break; + } + + if (!$useSession) { + \CRM_Core_Session::useFakeSession(); + } + + if ($userId && $useSession) { + $authxUf->loginSession($userId); + } + if ($userId && !$useSession) { + $authxUf->loginStateless($userId); + } + + // Post-login Civi stuff... + + $session = \CRM_Core_Session::singleton(); + $session->set('ufID', $userId); + $session->set('userID', $contactId); + + \CRM_Core_DAO::executeQuery('SET @civicrm_user_id = %1', + [1 => [$contactId, 'Integer']] + ); + } + + /** + * Reject a bad authentication attempt. + * + * @param string $message + */ + protected function reject($message = 'Authentication failed') { + $r = new Response(401, ['Content-Type' => 'text/plain'], "HTTP 401 $message"); + \CRM_Utils_System::sendResponse($r); + } + + /** + * If given a bearer token, then lookup (and validate) the corresponding identity. + * + * @param string $credValue + * Bearer token + * + * @return int|null + * The authenticated contact ID. + */ + protected function lookupContactToken($credValue) { + if (in_array('api_key', $this->allowCreds)) { + $c = \CRM_Core_DAO::singleValueQuery('SELECT id FROM civicrm_contact WHERE api_key = %1', [ + 1 => [$credValue, 'String'], + ]); + if ($c) { + return $c; + } + } + // if (in_array('jwt', $this->allowCreds)) { + // TODO + // } + return NULL; + } + +} diff --git a/ext/authx/Civi/Authx/AuthxInterface.php b/ext/authx/Civi/Authx/AuthxInterface.php new file mode 100644 index 0000000000..8a7cc367d2 --- /dev/null +++ b/ext/authx/Civi/Authx/AuthxInterface.php @@ -0,0 +1,74 @@ +uid ? $user->uid : NULL; + } + +} diff --git a/ext/authx/Civi/Authx/None.php b/ext/authx/Civi/Authx/None.php new file mode 100644 index 0000000000..8401d5abfc --- /dev/null +++ b/ext/authx/Civi/Authx/None.php @@ -0,0 +1,51 @@ +ID; + } + + /** + * @inheritDoc + */ + public function loginSession($userId) { + // We use wp_signon() to try to fire any session-related events. + // Note that we've already authenticated the user, so we filter 'authenticate' + // to signal the chosen user. + + $user = get_user_by('id', $userId); + $pickUser = function () use ($user) { + return $user; + }; + try { + add_filter('authenticate', $pickUser); + wp_signon(); + wp_set_current_user($userId); + } finally { + remove_filter('authenticate', $pickUser); + } + } + + /** + * @inheritDoc + */ + public function logoutSession() { + wp_logout(); + } + + /** + * @inheritDoc + */ + public function loginStateless($userId) { + wp_set_current_user($userId); + } + + /** + * @inheritDoc + */ + public function getCurrentUserId() { + $id = \get_current_user_id(); + return empty($id) ? NULL : $id; + } + +} diff --git a/ext/authx/authx.php b/ext/authx/authx.php index e0654e601c..4485c07efa 100644 --- a/ext/authx/authx.php +++ b/ext/authx/authx.php @@ -5,6 +5,53 @@ require_once 'authx.civix.php'; use CRM_Authx_ExtensionUtil as E; // phpcs:enable +Civi::dispatcher()->addListener('civi.invoke.auth', function($e) { + if (!empty($_SERVER['HTTP_X_CIVI_AUTH'])) { + return (new \Civi\Authx\Authenticator('xheader'))->auth($e, $_SERVER['HTTP_X_CIVI_AUTH']); + } + + if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { + return (new \Civi\Authx\Authenticator('header'))->auth($e, $_SERVER['HTTP_AUTHORIZATION']); + } + + $params = ($_SERVER['REQUEST_METHOD'] === 'GET') ? $_GET : $_POST; + if (!empty($params['_authx'])) { + if ((implode('/', $e->args) === 'civicrm/authx/login')) { + (new \Civi\Authx\Authenticator('endpoint'))->auth($e, $params['_authx'], TRUE); + _authx_redact(['_authx']); + } + elseif (!empty($params['_authxSes'])) { + (new \Civi\Authx\Authenticator('auto'))->auth($e, $params['_authx'], TRUE); + _authx_redact(['_authx', '_authxSes']); + } + else { + (new \Civi\Authx\Authenticator('param'))->auth($e, $params['_authx']); + _authx_redact(['_authx']); + } + } +}); + +/** + * @return \Civi\Authx\AuthxInterface + */ +function _authx_uf() { + $class = 'Civi\\Authx\\' . CIVICRM_UF; + return class_exists($class) ? new $class() : new \Civi\Authx\None(); +} + +/** + * For parameter-based authentication, this option will hide parameters. + * This is mostly a precaution, hedging against the possibility that some routes + * make broad use of $_GET or $_PARAMS. + * + * @param array $keys + */ +function _authx_redact(array $keys) { + foreach ($keys as $key) { + unset($_POST[$key], $_GET[$key], $_REQUEST[$key]); + } +} + /** * Implements hook_civicrm_config(). * -- 2.25.1