authx - Add authx_login({principal: array, useSession: bool}) API for use by backend...
authorTim Otten <totten@civicrm.org>
Fri, 10 Dec 2021 06:56:33 +0000 (22:56 -0800)
committerTim Otten <totten@civicrm.org>
Tue, 21 Dec 2021 23:36:16 +0000 (15:36 -0800)
ext/authx/Civi/Authx/Authenticator.php
ext/authx/authx.php
ext/authx/settings/authx.setting.php
ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php

index a52a1675ded0349f7d6b6ce4e2a71582200f9e01..72b2019e526a7e731b648a311eac215ae0909f82 100644 (file)
@@ -46,32 +46,54 @@ class Authenticator {
    *
    * @param \Civi\Core\Event\GenericHookEvent $e
    *   Details for the 'civi.invoke.auth' event.
-   * @param array $details
-   *   Mix of these properties:
+   * @param array{flow: string, useSession: ?bool, cred: ?string, principal: ?array} $details
+   *   Describe the authentication process with these properties:
+   *
    *   - string $flow (required);
    *     The type of authentication flow being used
    *     Ex: 'param', 'header', 'auto'
-   *   - string $cred (required)
-   *     The credential, as formatted in the 'Authorization' header.
-   *     Ex: 'Bearer 12345', 'Basic ASDFFDSA=='
    *   - bool $useSession (default FALSE)
    *     If TRUE, then the authentication should be persistent (in a session variable).
    *     If FALSE, then the authentication should be ephemeral (single page-request).
+   *
+   *   And then ONE of these properties to describe the user/principal:
+   *
+   *   - string $cred
+   *     The credential, as formatted in the 'Authorization' header.
+   *     Ex: 'Bearer 12345', 'Basic ASDFFDSA=='
+   *   - array $principal
+   *     Description of a validated principal.
+   *     Must include 'contactId', 'userId', xor 'user'
    * @return bool
    *   Returns TRUE on success.
    *   Exits with failure
    * @throws \Exception
    */
   public function auth($e, $details) {
+    if (!(isset($details['cred']) xor isset($details['principal']))) {
+      $this->reject('Authentication logic error: Must specify "cred" xor "principal".');
+    }
+    if (!isset($details['flow'])) {
+      $this->reject('Authentication logic error: Must specify "flow".');
+    }
+
     $tgt = AuthenticatorTarget::create([
       'flow' => $details['flow'],
-      'cred' => $details['cred'],
+      'cred' => $details['cred'] ?? NULL,
       'siteKey' => $details['siteKey'] ?? NULL,
       'useSession' => $details['useSession'] ?? FALSE,
     ]);
-    if ($principal = $this->checkCredential($tgt)) {
-      $tgt->setPrincipal($principal);
+
+    if (isset($tgt->cred)) {
+      if ($principal = $this->checkCredential($tgt)) {
+        $tgt->setPrincipal($principal);
+      }
+    }
+    elseif (isset($details['principal'])) {
+      $details['principal']['credType'] = 'assigned';
+      $tgt->setPrincipal($details['principal']);
     }
+
     $this->checkPolicy($tgt);
     $this->login($tgt);
     return TRUE;
@@ -138,12 +160,19 @@ class Authenticator {
       $this->reject('Invalid credential');
     }
 
-    $allowCreds = \Civi::settings()->get('authx_' . $tgt->flow . '_cred');
-    if (!in_array($tgt->credType, $allowCreds)) {
-      $this->reject(sprintf('Authentication type "%s" is not allowed for this principal.', $tgt->credType));
+    if ($tgt->contactId) {
+      $findContact = \Civi\Api4\Contact::get(0)->addWhere('id', '=', $tgt->contactId);
+      if ($findContact->execute()->count() === 0) {
+        $this->reject(sprintf('Contact ID %d is invalid', $tgt->contactId));
+      }
+    }
+
+    $allowCreds = \Civi::settings()->get('authx_' . $tgt->flow . '_cred') ?: [];
+    if ($tgt->credType !== 'assigned' && !in_array($tgt->credType, $allowCreds)) {
+      $this->reject(sprintf('Authentication type "%s" with flow "%s" is not allowed for this principal.', $tgt->credType, $tgt->flow));
     }
 
-    $userMode = \Civi::settings()->get('authx_' . $tgt->flow . '_user');
+    $userMode = \Civi::settings()->get('authx_' . $tgt->flow . '_user') ?: 'optional';
     switch ($userMode) {
       case 'ignore':
         $tgt->userId = NULL;
@@ -168,6 +197,7 @@ class Authenticator {
       $passGuard[] = in_array('perm', $useGuards) && isset($perms[$tgt->credType]) && \CRM_Core_Permission::check($perms[$tgt->credType], $tgt->contactId);
       // JWTs are signed by us. We don't need user to prove that they're allowed to use them.
       $passGuard[] = ($tgt->credType === 'jwt');
+      $passGuard[] = ($tgt->credType === 'assigned');
       if (!max($passGuard)) {
         $this->reject(sprintf('Login not permitted. Must satisfy guard (%s).', implode(', ', $useGuards)));
       }
@@ -202,7 +232,7 @@ class Authenticator {
 
     if (empty($tgt->contactId)) {
       // It shouldn't be possible to get here due policy checks. But just in case.
-      throw new \LogicException("Cannot login. Failed to determine contact ID.");
+      $this->reject("Cannot login. Failed to determine contact ID.");
     }
 
     if (!($tgt->useSession)) {
@@ -262,7 +292,7 @@ class AuthenticatorTarget {
    * The authentication-flow by which we received the credential.
    *
    * @var string
-   *   Ex: 'param', 'header', 'xheader', 'auto'
+   *   Ex: 'param', 'header', 'xheader', 'auto', 'script'
    */
   public $flow;
 
@@ -337,20 +367,29 @@ class AuthenticatorTarget {
    * Specify the authenticated principal for this request.
    *
    * @param array $args
-   *   Mix of: 'userId', 'contactId', 'credType'
+   *   Mix of: 'user', 'userId', 'contactId', 'credType'
    *   It is valid to give 'userId' or 'contactId' - the missing one will be
    *   filled in via UFMatch (if available).
    * @return $this
    */
   public function setPrincipal($args) {
+    if (!empty($args['user'])) {
+      $args['userId'] = $args['userId'] ?? \CRM_Core_Config::singleton()->userSystem->getUfId($args['user']);
+      if ($args['userId']) {
+        unset($args['user']);
+      }
+      else {
+        throw new AuthxException("Must specify principal with valid user, userId, or contactId");
+      }
+    }
     if (empty($args['userId']) && empty($args['contactId'])) {
-      throw new \InvalidArgumentException("Must specify principal by userId and/or contactId");
+      throw new AuthxException("Must specify principal with valid user, userId, or contactId");
     }
     if (empty($args['credType'])) {
-      throw new \InvalidArgumentException("Must specify the type of credential used to identify the principal");
+      throw new AuthxException("Must specify the type of credential used to identify the principal");
     }
     if ($this->hasPrincipal()) {
-      throw new \LogicException("Principal has already been specified");
+      throw new AuthxException("Principal has already been specified");
     }
 
     if (empty($args['contactId']) && !empty($args['userId'])) {
index a30dcede14f054457e3081fee07acba6cf925dd7..25fe959f4c37ddce5b238e5780010f2974a8a16a 100644 (file)
@@ -38,6 +38,44 @@ Civi::dispatcher()->addListener('civi.invoke.auth', function($e) {
   }
 });
 
+/**
+ * Perform a system login.
+ *
+ * This is useful for backend scripts that need to switch to a specific user.
+ *
+ * As needed, this will update the Civi session and CMS data.
+ *
+ * @param array{flow: ?string, useSession: ?bool, principal: ?array, cred: ?string,} $details
+ *   Describe the authentication process with these properties:
+ *
+ *   - string $flow (default 'script');
+ *     The type of authentication flow being used
+ *     Ex: 'param', 'header', 'auto'
+ *   - bool $useSession (default FALSE)
+ *     If TRUE, then the authentication should be persistent (in a session variable).
+ *     If FALSE, then the authentication should be ephemeral (single page-request).
+ *
+ *   And then ONE of these properties to describe the user/principal:
+ *
+ *   - string $cred
+ *     The credential, as formatted in the 'Authorization' header.
+ *     Ex: 'Bearer 12345', 'Basic ASDFFDSA=='
+ *   - array $principal
+ *     Description of a validated principal.
+ *     Must include 'contactId', 'userId', xor 'user'
+ * @return array{contactId: int, userId: ?int, flow: string, credType: string, useSession: bool}
+ *   An array describing the authenticated session.
+ * @throws \Civi\Authx\AuthxException
+ */
+function authx_login(array $details): array {
+  $defaults = ['flow' => 'script', 'useSession' => FALSE];
+  $details = array_merge($defaults, $details);
+  $auth = new \Civi\Authx\Authenticator();
+  $auth->setRejectMode('exception');
+  $auth->auth(NULL, array_merge($defaults, $details));
+  return \CRM_Core_Session::singleton()->get("authx");
+}
+
 /**
  * @return \Civi\Authx\AuthxInterface
  */
index 8d679529cc21abd372a5f738162bd625b28fef3c..d08d376c3c405527842a41c4b844e1834099772a 100644 (file)
@@ -17,7 +17,7 @@ use CRM_Authx_ExtensionUtil as E;
  * @copyright CiviCRM LLC https://civicrm.org/licensing
  */
 $_authx_settings = function() {
-  $flows = ['param', 'header', 'xheader', 'login', 'auto'];
+  $flows = ['param', 'header', 'xheader', 'login', 'auto', 'script'];
   $basic = [
     'group_name' => 'CiviCRM Preferences',
     'group' => 'authx',
index e7792914f41681b2bd970d9957796d114ad9127e..cb583345f9b3cf14a37047ca4b8574533198cd79 100644 (file)
@@ -463,6 +463,55 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     }
   }
 
+  /**
+   * The internal API `authx_login()` should be used by background services to set the active user.
+   *
+   * To test this, we call `cv ev 'authx_login(...);'` and check the resulting identity.
+   *
+   * @throws \CiviCRM_API3_Exception
+   */
+  public function testCliServiceLogin() {
+    $withCv = function($phpStmt) {
+      $cmd = strtr('cv ev -v @PHP', ['@PHP' => escapeshellarg($phpStmt)]);
+      exec($cmd, $output, $val);
+      $fullOutput = implode("\n", $output);
+      $this->assertEquals(0, $val, "Command returned error ($cmd) ($val):\n\"$fullOutput\"");
+      return json_decode($fullOutput, TRUE);
+    };
+
+    $principals = [
+      'contactId' => $this->getDemoCID(),
+      'userId' => $this->getDemoUID(),
+      'user' => $GLOBALS['_CV']['DEMO_USER'],
+    ];
+    foreach ($principals as $principalField => $principalValue) {
+      $msg = "Logged in with $principalField=$principalValue. We should see this user as authenticated.";
+
+      $loginArgs = ['principal' => [$principalField => $principalValue]];
+      $report = $withCv(sprintf('return authx_login(%s);', var_export($loginArgs, 1)));
+      $this->assertEquals($this->getDemoCID(), $report['contactId'], $msg);
+      $this->assertEquals($this->getDemoUID(), $report['userId'], $msg);
+      $this->assertEquals('script', $report['flow'], $msg);
+      $this->assertEquals('assigned', $report['credType'], $msg);
+      $this->assertEquals(FALSE, $report['useSession'], $msg);
+    }
+
+    $invalidPrincipals = [
+      ['contactId', 999999, AuthxException::CLASS, ';Contact ID 999999 is invalid;'],
+      ['userId', 999999, AuthxException::CLASS, ';Cannot login. Failed to determine contact ID.;'],
+      ['user', 'randuser' . mt_rand(0, 32767), AuthxException::CLASS, ';Must specify principal with valid user, userId, or contactId;'],
+    ];
+    foreach ($invalidPrincipals as $invalidPrincipal) {
+      [$principalField, $principalValue, $expectExceptionClass, $expectExceptionMessage] = $invalidPrincipal;
+
+      $loginArgs = ['principal' => [$principalField => $principalValue]];
+      $report = $withCv(sprintf('try { return authx_login(%s); } catch (Exception $e) { return [get_class($e), $e->getMessage()]; }', var_export($loginArgs, 1)));
+      $this->assertTrue(isset($report[0], $report[1]), "authx_login() should fail with invalid credentials ($principalField=>$principalValue). Received array: " . json_encode($report));
+      $this->assertRegExp($expectExceptionMessage, $report[1], "Invalid principal ($principalField=>$principalValue) should generate exception.");
+      $this->assertEquals($expectExceptionClass, $report[0], "Invalid principal ($principalField=>$principalValue) should generate exception.");
+    }
+  }
+
   /**
    * Filter a request, applying the given authentication options
    *