*
* @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;
$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;
$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)));
}
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)) {
* The authentication-flow by which we received the credential.
*
* @var string
- * Ex: 'param', 'header', 'xheader', 'auto'
+ * Ex: 'param', 'header', 'xheader', 'auto', 'script'
*/
public $flow;
* 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'])) {
}
});
+/**
+ * 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
*/
}
}
+ /**
+ * 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
*