From b9cbf1a6fb78935a5c72e02bcd90b01a00ed69c3 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 15 Feb 2021 18:28:18 -0800 Subject: [PATCH] authx - Support JWT credentials --- ext/authx/Civi/Authx/Authenticator.php | 19 +++++- ext/authx/README.md | 61 ++++++++++++++----- .../tests/phpunit/Civi/Authx/AllFlowsTest.php | 18 +++--- 3 files changed, 72 insertions(+), 26 deletions(-) diff --git a/ext/authx/Civi/Authx/Authenticator.php b/ext/authx/Civi/Authx/Authenticator.php index d336e2b24c..59cb2a1d0d 100644 --- a/ext/authx/Civi/Authx/Authenticator.php +++ b/ext/authx/Civi/Authx/Authenticator.php @@ -11,6 +11,7 @@ namespace Civi\Authx; +use Civi\Crypto\Exception\CryptoException; use GuzzleHttp\Psr7\Response; class Authenticator { @@ -180,9 +181,21 @@ class Authenticator { return $c; } } - // if (in_array('jwt', $this->allowCreds)) { - // TODO - // } + if (in_array('jwt', $this->allowCreds)) { + try { + $claims = \Civi::service('crypto.jwt')->decode($credValue); + $scopes = isset($claims['scope']) ? explode(' ', $claims['scope']) : []; + if (!in_array('authx', $scopes)) { + $this->reject('JWT does not permit general authentication'); + } + if (empty($claims['sub']) || substr($claims['sub'], 0, 4) !== 'cid:') { + $this->reject('JWT does not specify the contact ID (sub)'); + } + return substr($claims['sub'], 4); + } + catch (CryptoException $e) { + } + } return NULL; } diff --git a/ext/authx/README.md b/ext/authx/README.md index 478414b320..6afa305736 100644 --- a/ext/authx/README.md +++ b/ext/authx/README.md @@ -5,7 +5,7 @@ This is useful for automated testing and developing multi-modal pageflows (eg em ## Overview -There are two general flows of authentication: +There are two general flows of authentication, each with a few variations: * __Ephemeral / Stateless__: The client submits a singlular request (such as an API call) which includes credentials. The request is authenticated and processed, but then it is abandoned. There are a couple flavors of stateless authentication: @@ -68,30 +68,59 @@ Some modes may not be supported in some environments. For example: ### JSON Web Token +By default, JSON Web Tokens are accepted for authentication in all flows. + +To use JWT authentication, you must first prepare a token on the server: + ```php -// First, on the server, prepare a token -$token = Civi::service('authx.jwt')->create([ - 'contact_id' => 123, - 'ttl' => 5*60*60, +$token = Civi::service('crypto.jwt')->encode([ + 'exp' => time() + 5*60, // Expires in 5 minutes + 'sub' => 'cid:203', // Subject (contact ID) + 'scope' => 'authx', // Allow general authentication ]); +``` + +This `$token` should be given to some other agent (e.g. web browser, custom script, or email client). + +For example, here's a custom script which uses the `$token` to call APIv3 (`Contact.get`). The token is based with the common HTTP authorization header: -// Next, on the client, use this same token +```php $options = ['http' => [ - 'method' => 'GET', - 'header' => 'Authorization: Bearer '.$token + 'method' => 'GET', + 'header' => "Authorization: Bearer $token", ]]; +$url = 'https://example.org/civicrm/ajax/rest?entity=Contact&action=get&json=' + . urlencode(json_encode(["id" => "user_contact_id"])); $context = stream_context_create($options); $response = file_get_contents($url, false, $context); ``` -### Username and Password +Alternatively, if you needed to send an email with a sign-in link, the JWT could be passed as an `?_authx` parameter. ```php -$auth = base64_encode("username:password"); -$context = stream_context_create([ - "http" => [ - "header" => "Authorization: Basic $auth" - ] -]); -$homepage = file_get_contents("http://example.com/file", false, $context ); +$url = CRM_Utils_System::url('civicrm/dashboard', [ + '_authx' => "Bearer $token", + '_authxSes' => 1, +], TRUE, NULL, FALSE); +$html = sprintf('Here is your login link: %s', + htmlentities($url), htmlentities($url)); +CRM_Utils_Mail::send([...'html' => $html...]); +``` + +### Username and Password + +By default, username/password authentication is not enabled. + +``` +$ curl 'https://demouser:demopass@example.org/civicrm/authx/id' +HTTP 401 Password authentication is not supported +``` + +However, if you activate it, then it will work with standard HTTP clients: + +``` +$ cv ev 'Civi::settings()->set("authx_header_cred", ["pass","jwt"]);' + +$ curl 'https://demouser:demopass@example.org/civicrm/authx/id' +{"contact_id":203,"user_id":"2"} ``` diff --git a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php index b2af6adefe..da87539b5f 100644 --- a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php +++ b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php @@ -65,9 +65,9 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf $exs[] = ['api_key', 'param']; $exs[] = ['api_key', 'header']; $exs[] = ['api_key', 'xheader']; - // $exs[] = ['jwt', 'param']; - // $exs[] = ['jwt', 'header']; - // $exs[] = ['jwt', 'xheader']; + $exs[] = ['jwt', 'param']; + $exs[] = ['jwt', 'header']; + $exs[] = ['jwt', 'xheader']; return $exs; } @@ -75,7 +75,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf $exs = []; $exs[] = ['pass', 'auto']; $exs[] = ['api_key', 'auto']; - // $exs[] = ['jwt', 'auto']; + $exs[] = ['jwt', 'auto']; return $exs; } @@ -288,9 +288,13 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf } public function credJwt($cid) { - $token = \Civi::service('authx.jwt')->create([ - 'contact_id' => $cid, - 'ttl' => 60 * 60, + if (empty(\Civi::service('crypto.registry')->findKeysByTag('SIGN'))) { + $this->markTestIncomplete('Cannot test JWT. No CIVICRM_SIGN_KEYS are defined.'); + } + $token = \Civi::service('crypto.jwt')->encode([ + 'exp' => time() + 60 * 60, + 'sub' => "cid:$cid", + 'scope' => 'authx', ]); return 'Bearer ' . $token; } -- 2.25.1