From 97209e6164164569e9abcefe6c01d24f0932b274 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Thu, 9 Dec 2021 22:56:33 -0800 Subject: [PATCH] authx - Add authx_login({principal: array, useSession: bool}) API for use by backend/service scripts --- ext/authx/Civi/Authx/Authenticator.php | 75 ++++++++++++++----- ext/authx/authx.php | 38 ++++++++++ ext/authx/settings/authx.setting.php | 2 +- .../tests/phpunit/Civi/Authx/AllFlowsTest.php | 49 ++++++++++++ 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/ext/authx/Civi/Authx/Authenticator.php b/ext/authx/Civi/Authx/Authenticator.php index a52a1675de..72b2019e52 100644 --- a/ext/authx/Civi/Authx/Authenticator.php +++ b/ext/authx/Civi/Authx/Authenticator.php @@ -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'])) { diff --git a/ext/authx/authx.php b/ext/authx/authx.php index a30dcede14..25fe959f4c 100644 --- a/ext/authx/authx.php +++ b/ext/authx/authx.php @@ -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 */ diff --git a/ext/authx/settings/authx.setting.php b/ext/authx/settings/authx.setting.php index 8d679529cc..d08d376c3c 100644 --- a/ext/authx/settings/authx.setting.php +++ b/ext/authx/settings/authx.setting.php @@ -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', diff --git a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php index e7792914f4..cb583345f9 100644 --- a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php +++ b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php @@ -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 * -- 2.25.1