--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Authx;
+
+use GuzzleHttp\Psr7\Response;
+
+class Authenticator {
+
+ /**
+ * @var string
+ * Ex: 'param', 'xheader', 'header'
+ */
+ protected $flow;
+
+ /**
+ * @var string
+ * Ex: 'optional', 'require', 'ignore'
+ */
+ protected $userMode;
+
+ /**
+ * @var array
+ * Ex: ['jwt', 'pass', 'api_key']
+ */
+ protected $allowCreds;
+
+ /**
+ * Authenticator constructor.
+ *
+ * @param string $flow
+ */
+ public function __construct(string $flow) {
+ $this->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;
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Authx;
+
+/**
+ * Interface AuthxInterface
+ * @package Civi\Authx
+ *
+ * Each user-framework (Drupal, Joomla, etc) has a slightly different set of
+ * methods for authenticating users and establishing a login. Provide an
+ * implementation of this interface for each user-framework.
+ *
+ * This is conceptually similar to some methods in `CRM_Utils_System_*`,
+ * but with less sadism.
+ */
+interface AuthxInterface {
+
+ /**
+ * Determine if the password is correct for the user.
+ *
+ * @param string $username
+ * The symbolic username known to the user.
+ * @param string $password
+ * The plaintext secret which identifies this user.
+ *
+ * @return int|string|NULL
+ * If the password is correct, this returns the internal user ID.
+ * If the password is incorrect (or if passwords are not supported), it returns NULL.
+ */
+ public function checkPassword(string $username, string $password);
+
+ /**
+ * Set the active user the in the CMS, binding the user ID durably to the session.
+ *
+ * @param int|string $userId
+ * The UF's internal user ID.
+ */
+ public function loginSession($userId);
+
+ /**
+ * Close an open session.
+ *
+ * This SHOULD NOT produce an HTTP response (redirect). However, consumers
+ * of the authx logout SHOULD be robust against unconventional responses.
+ */
+ public function logoutSession();
+
+ /**
+ * Set the active user the in the CMS -- but do *not* start a session.
+ *
+ * @param int|string $userId
+ * The UF's internal user ID.
+ */
+ public function loginStateless($userId);
+
+ /**
+ * Determine which (if any) user is currently logged in.
+ *
+ * @return int|string|NULL
+ * The UF's internal user ID for the active user.
+ * NULL indicates anonymous (not logged into CMS).
+ */
+ public function getCurrentUserId();
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Authx;
+
+class Drupal implements AuthxInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function checkPassword(string $username, string $password) {
+ $uid = user_authenticate($username, $password);
+ // Ensure strict nullness.
+ return $uid ? $uid : NULL;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function loginSession($userId) {
+ global $user;
+ $user = user_load($userId);
+ user_login_finalize();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function logoutSession() {
+ module_load_include('inc', 'user', 'user.pages');
+ user_logout_current_user();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function loginStateless($userId) {
+ global $user;
+ $user = user_load($userId);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getCurrentUserId() {
+ global $user;
+ return $user && $user->uid ? $user->uid : NULL;
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Authx;
+
+class None implements AuthxInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function checkPassword(string $username, string $password) {
+ return NULL;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function loginSession($userId) {
+ throw new \Exception("Cannot login: Unrecognized user framework");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function logoutSession() {
+ throw new \Exception("Cannot logout: Unrecognized user framework");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function loginStateless($userId) {
+ throw new \Exception("Cannot login: Unrecognized user framework");
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getCurrentUserId() {
+ throw new \Exception("Cannot determine active user: Unrecognized user framework");
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Authx;
+
+class WordPress implements AuthxInterface {
+
+ /**
+ * @inheritDoc
+ */
+ public function checkPassword(string $username, string $password) {
+ $user = wp_authenticate($username, $password);
+ if (is_wp_error($user)) {
+ return NULL;
+ }
+ return $user->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;
+ }
+
+}
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().
*