Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authx - Add APIv4 support for creating and validating credentials #26044

Merged
merged 8 commits into from
May 8, 2023
57 changes: 57 additions & 0 deletions ext/authx/Civi/Api4/Action/AuthxCredential/Create.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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\Api4\Action\AuthxCredential;

use Civi\Api4\Generic\Result;

/**
* Generate a security checksum for anonymous access to CiviCRM.
*
* @method int getContactId() Get contact ID param (required)
* @method $this setContactId(int $contactId) Set the Contact Id
* @method $this setTtl(int $ttl) Set TTL param
* @method int getTtl() get the TTL param;
*/
class Create extends \Civi\Api4\Generic\AbstractAction {

/**
* ID of contact
*
* @var int
* @required
*/
protected $contactId;

/**
* Expiration time (in seconds). Defaults to 300 seconds
*
* @var int
*/
protected $ttl = 300;

/**
* @param \Civi\Api4\Generic\Result $result
*/
public function _run(Result $result) {
$token = \Civi::service('crypto.jwt')->encode([
'exp' => time() + $this->ttl,
'sub' => 'cid:' . $this->contactId,
'scope' => 'authx',
]);

$result[] = [
'cred' => 'Bearer ' . $token,
];
}

}
57 changes: 57 additions & 0 deletions ext/authx/Civi/Api4/Action/AuthxCredential/Validate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?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\Api4\Action\AuthxCredential;

use Civi\Api4\Generic\Result;

/**
* Validate that a credential is still valid and can be used in CiviCRM.
*
* @method string getCred() Get Token to validate (required)
* @method Validate setCred(string $token) Get contact ID param (required)
*/
class Validate extends \Civi\Api4\Generic\AbstractAction {

/**
* Identify the login-flow. Used for policy enforcement.
*
* @var string
*/
protected $flow = 'script';

/**
* Credential to validate
*
* @var string
* Ex: 'Bearer ABCD1234'
* @required
*/
protected $cred;

/**
* @param \Civi\Api4\Generic\Result $result
* @throws \Civi\Authx\AuthxException
*/
public function _run(Result $result) {
$details = [
'flow' => $this->flow,
'cred' => $this->cred,
'siteKey' => NULL, /* Old school. Hopefully, we don't need to expose this. */
'useSession' => FALSE,
];
$auth = new \Civi\Authx\Authenticator();
$auth->setRejectMode('exception');
$result[] = $auth->validate($details);
}

}
60 changes: 60 additions & 0 deletions ext/authx/Civi/Api4/AuthxCredential.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?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\Api4;

/**
* Methods of handling (JWT) authx credentialss
*
* @searchable none
* @since 5.62
* @package Authx
*/
class AuthxCredential extends Generic\AbstractEntity {

/**
* @param bool $checkPermissions
* @return Action\AuthxCredential\create
*/
public static function create($checkPermissions = TRUE) {
return (new Action\AuthxCredential\Create(__CLASS__, __FUNCTION__))
->setCheckPermissions($checkPermissions);
}

/**
* @param bool $checkPermissions
* @return Action\AuthxCredential\validate
*/
public static function validate($checkPermissions = TRUE) {
return (new Action\AuthxCredential\Validate(__CLASS__, __FUNCTION__))
->setCheckPermissions($checkPermissions);
}

/**
* @param bool $checkPermissions
* @return Generic\BasicGetFieldsAction
*/
public static function getFields($checkPermissions = TRUE) {
return (new Generic\BasicGetFieldsAction(__CLASS__, __FUNCTION__, function() {
return [];
}))->setCheckPermissions($checkPermissions);
}

public static function permissions() {
return [
'meta' => ['access CiviCRM'],
'default' => ['administer CiviCRM'],
'create' => ['generate any authx credential'],
'validate' => ['validate any authx credential'],
];
}

}
29 changes: 29 additions & 0 deletions ext/authx/Civi/Authx/Authenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,35 @@ public function auth($e, $details) {
return TRUE;
}

/**
* Determine whether credentials are valid. This is similar to `auth()`
* but stops short of performing an actual login.
*
* @param array $details
* @return array{flow: string, credType: string, jwt: ?array, useSession: bool, userId: ?int, contactId: ?int}
* Description of the validated principal (redacted).
* @throws \Civi\Authx\AuthxException
*/
public function validate(array $details): array {
if (!isset($details['flow'])) {
$this->reject('Authentication logic error: Must specify "flow".');
}

$tgt = AuthenticatorTarget::create([
'flow' => $details['flow'],
'cred' => $details['cred'] ?? NULL,
'siteKey' => $details['siteKey'] ?? NULL,
'useSession' => $details['useSession'] ?? FALSE,
]);

if ($principal = $this->checkCredential($tgt)) {
$tgt->setPrincipal($principal);
}

$this->checkPolicy($tgt);
return $tgt->createRedacted();
}

/**
* Assess the credential ($tgt->cred) and determine the matching principal.
*
Expand Down
2 changes: 2 additions & 0 deletions ext/authx/authx.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ function authx_civicrm_enable() {
function authx_civicrm_permission(&$permissions) {
$permissions['authenticate with password'] = E::ts('AuthX: Authenticate to services with password');
$permissions['authenticate with api key'] = E::ts('AuthX: Authenticate to services with API key');
$permissions['generate any authx credential'] = E::ts('Authx: Generate new JWT credentials for other users via the API');
$permissions['validate any authx credential'] = E::ts('Authx: Validate credentials for other users via the API');
}

// --- Functions below this ship commented out. Uncomment as required. ---
Expand Down
91 changes: 91 additions & 0 deletions ext/authx/tests/phpunit/api/v4/AuthxCredentialTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace api\v4\Authx;

use Civi\Api4\AuthxCredential;
use Civi\Authx\AuthxException;
use Civi\Test\HeadlessInterface;
use Civi\Test\TransactionalInterface;
use Firebase\JWT\JWT;
use PHPUnit\Framework\TestCase;

/**
* Test AuthxCredential API methods
* @group headless
*/
class AuthxCredentialTest extends TestCase implements HeadlessInterface, TransactionalInterface {

use \Civi\Test\Api4TestTrait;
use \Civi\Test\Api3TestTrait;
use \Civi\Test\ContactTestTrait;

public function setUpHeadless() {
return \Civi\Test::headless()
->installMe(__DIR__)
->apply();
}

public function testGenerateToken(): void {
$this->_apiversion = 4;
$contactRecord = $this->createTestRecord('Contact', ['contact_type' => 'Individual']);
$this->createLoggedInUser();
$this->setPermissions([
'access CiviCRM',
]);
try {
AuthxCredential::create()->setContactId($contactRecord['id'])->execute();
$this->fail('AuthxCredential Should not be created as permission is not granted');
}
catch (\Exception $e) {
}
$this->setPermissions([
'access CiviCRM',
'generate any authx credential',
]);
$jwt = AuthxCredential::create()->setContactId($contactRecord['id'])->execute();
$this->assertNotEmpty($jwt[0]['cred']);
}

public function testValidation(): void {
$this->_apiversion = 4;
$contactRecord = $this->createTestRecord('Contact', ['contact_type' => 'Individual']);
$this->createLoggedInUser();
$this->setPermissions([
'access CiviCRM',
'generate any authx credential',
]);
$jwt = AuthxCredential::create()->setContactId($contactRecord['id'])->execute();

$this->setPermissions([
'access CiviCRM',
'validate any authx credential',
]);
$validate = AuthxCredential::validate()->setCred($jwt[0]['cred'])->execute();
$this->assertEquals('jwt', $validate[0]['credType']);
$this->assertEquals($contactRecord['id'], $validate[0]['contactId']);
$this->assertEquals('cid:' . $contactRecord['id'], $validate[0]['jwt']['sub']);

try {
JWT::$timestamp = time() + 360;
AuthxCredential::validate()->setCred($jwt[0]['cred'])->execute();
$this->fail('Expected exception for expired token');
}
catch (AuthxException $e) {
$this->assertEquals('Expired token', $e->getMessage());
}
finally {
JWT::$timestamp = NULL;
}
}

/**
* Set ACL permissions, overwriting any existing ones.
*
* @param array $permissions
* Array of permissions e.g ['access CiviCRM','access CiviContribute'],
*/
protected function setPermissions(array $permissions): void {
\CRM_Core_Config::singleton()->userPermissionClass->permissions = $permissions;
}

}