authx - Primary implementation, including Drupal 7 and WordPress
authorTim Otten <totten@civicrm.org>
Fri, 12 Feb 2021 20:51:52 +0000 (12:51 -0800)
committerTim Otten <totten@civicrm.org>
Tue, 2 Mar 2021 19:37:53 +0000 (11:37 -0800)
ext/authx/Civi/Authx/Authenticator.php [new file with mode: 0644]
ext/authx/Civi/Authx/AuthxInterface.php [new file with mode: 0644]
ext/authx/Civi/Authx/Drupal.php [new file with mode: 0644]
ext/authx/Civi/Authx/None.php [new file with mode: 0644]
ext/authx/Civi/Authx/WordPress.php [new file with mode: 0644]
ext/authx/authx.php

diff --git a/ext/authx/Civi/Authx/Authenticator.php b/ext/authx/Civi/Authx/Authenticator.php
new file mode 100644 (file)
index 0000000..d336e2b
--- /dev/null
@@ -0,0 +1,189 @@
+<?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;
+  }
+
+}
diff --git a/ext/authx/Civi/Authx/AuthxInterface.php b/ext/authx/Civi/Authx/AuthxInterface.php
new file mode 100644 (file)
index 0000000..8a7cc36
--- /dev/null
@@ -0,0 +1,74 @@
+<?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();
+
+}
diff --git a/ext/authx/Civi/Authx/Drupal.php b/ext/authx/Civi/Authx/Drupal.php
new file mode 100644 (file)
index 0000000..e9722ca
--- /dev/null
@@ -0,0 +1,58 @@
+<?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;
+  }
+
+}
diff --git a/ext/authx/Civi/Authx/None.php b/ext/authx/Civi/Authx/None.php
new file mode 100644 (file)
index 0000000..8401d5a
--- /dev/null
@@ -0,0 +1,51 @@
+<?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");
+  }
+
+}
diff --git a/ext/authx/Civi/Authx/WordPress.php b/ext/authx/Civi/Authx/WordPress.php
new file mode 100644 (file)
index 0000000..70a29b8
--- /dev/null
@@ -0,0 +1,70 @@
+<?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;
+  }
+
+}
index e0654e601ce0b43902492ba3783d51808ca800f8..4485c07efa1af85e9c0d198a5ab15bfe6b57a01b 100644 (file)
@@ -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().
  *