Skip to content

Commit

Permalink
Merge pull request #26044 from totten/jwt_generation_alt
Browse files Browse the repository at this point in the history
Authx - Add APIv4 support for creating and validating credentials
  • Loading branch information
seamuslee001 authored May 8, 2023
2 parents 5747d3f + d37ef75 commit 3af8f62
Show file tree
Hide file tree
Showing 6 changed files with 296 additions and 0 deletions.
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;
}

}

0 comments on commit 3af8f62

Please sign in to comment.