5 use Civi\Test\HttpTestTrait
;
6 use CRM_Authx_ExtensionUtil
as E
;
7 use Civi\Test\EndToEndInterface
;
8 use GuzzleHttp\Cookie\CookieJar
;
9 use GuzzleHttp\Psr7\AppendStream
;
10 use GuzzleHttp\Psr7\Request
;
11 use GuzzleHttp\Psr7\Uri
;
12 use Psr\Http\Message\ResponseInterface
;
13 use function GuzzleHttp\Psr7\stream_for
;
16 * This is a matrix-style test which assesses all supported permutations of
20 class AllFlowsTest
extends \PHPUnit\Framework\TestCase
implements EndToEndInterface
{
25 * Backup copy of the original settings.
29 protected $settingsBackup;
32 * List of CMS-dependent quirks that should be ignored during testing.
35 protected $quirks = [];
37 public static function setUpBeforeClass() {
42 \CRM_Utils_System
::synchronizeUsers();
49 public function setUp() {
51 'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
52 'WordPress' => ['sendsExcessCookies'],
54 $this->quirks
= $quirks[CIVICRM_UF
] ??
[];
57 $this->settingsBackup
= [];
58 foreach (\Civi\Authx\Meta
::getFlowTypes() as $flowType) {
59 foreach (["authx_{$flowType}_cred", "authx_{$flowType}_user", "authx_guards"] as $setting) {
60 $this->settingsBackup
[$setting] = \Civi
::settings()->get($setting);
64 \Civi
::settings()->set('authx_guards', []);
67 public function tearDown() {
68 foreach ($this->settingsBackup
as $setting => $value) {
69 \Civi
::settings()->set($setting, $value);
74 public function getStatelessExamples() {
76 $exs[] = ['pass', 'param'];
77 $exs[] = ['pass', 'header'];
78 $exs[] = ['pass', 'xheader'];
79 $exs[] = ['api_key', 'param'];
80 $exs[] = ['api_key', 'header'];
81 $exs[] = ['api_key', 'xheader'];
82 $exs[] = ['jwt', 'param'];
83 $exs[] = ['jwt', 'header'];
84 $exs[] = ['jwt', 'xheader'];
88 public function getCredTypes() {
96 public function testAnonymous() {
97 $http = $this->createGuzzle(['http_errors' => FALSE]);
99 /** @var \Psr\Http\Message\RequestInterface $request */
100 $request = $this->requestMyContact();
101 $response = $http->send($request);
102 $this->assertAnonymousContact($response);
106 * Send a request using a stateless protocol. Assert that identities are setup correctly.
108 * @param string $credType
109 * The type of credential to put in the `Authorization:` header.
110 * @param string $flowType
111 * The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
112 * @throws \CiviCRM_API3_Exception
113 * @throws \GuzzleHttp\Exception\GuzzleException
114 * @dataProvider getStatelessExamples
116 public function testStatelessContactOnly($credType, $flowType) {
117 if ($credType === 'pass') {
118 $this->assertTrue(TRUE, 'No need to test password credentials with non-user contacts');
121 $http = $this->createGuzzle(['http_errors' => FALSE]);
123 /** @var \Psr\Http\Message\RequestInterface $request */
124 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getLebowskiCID());
126 // Phase 1: Request fails if this credential type is not enabled
127 \Civi
::settings()->set("authx_{$flowType}_cred", []);
128 $response = $http->send($request);
129 $this->assertFailedDueToProhibition($response);
131 // Phase 2: Request succeeds if this credential type is enabled
132 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
133 $response = $http->send($request);
134 $this->assertMyContact($this->getLebowskiCID(), NULL, $response);
135 if (!in_array('sendsExcessCookies', $this->quirks
)) {
136 $this->assertNoCookies($response);
141 * Send a request using a stateless protocol. Assert that identities are setup correctly.
143 * @param string $credType
144 * The type of credential to put in the `Authorization:` header.
145 * @param string $flowType
146 * The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
147 * @throws \CiviCRM_API3_Exception
148 * @throws \GuzzleHttp\Exception\GuzzleException
149 * @dataProvider getStatelessExamples
151 public function testStatelessUserContact($credType, $flowType) {
152 $http = $this->createGuzzle(['http_errors' => FALSE]);
154 /** @var \Psr\Http\Message\RequestInterface $request */
155 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
157 // Phase 1: Request fails if this credential type is not enabled
158 \Civi
::settings()->set("authx_{$flowType}_cred", []);
159 $response = $http->send($request);
160 $this->assertFailedDueToProhibition($response);
162 // Phase 2: Request succeeds if this credential type is enabled
163 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
164 $response = $http->send($request);
165 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
166 if (!in_array('sendsExcessCookies', $this->quirks
)) {
167 $this->assertNoCookies($response);
172 * The setting "authx_guard" may be used to require (or not require) the site_key.
174 * @throws \CiviCRM_API3_Exception
175 * @throws \GuzzleHttp\Exception\GuzzleException
177 public function testStatelessGuardSiteKey() {
178 if (!defined('CIVICRM_SITE_KEY')) {
179 $this->markTestIncomplete("Cannot run test without CIVICRM_SITE_KEY");
182 $addParam = function($request, $key, $value) {
183 $query = $request->getUri()->getQuery();
184 return $request->withUri(
185 $request->getUri()->withQuery($query . '&' . urlencode($key) . '=' . urlencode($value))
189 [$credType, $flowType] = ['pass', 'header'];
190 $http = $this->createGuzzle(['http_errors' => FALSE]);
191 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
193 /** @var \Psr\Http\Message\RequestInterface $request */
194 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
196 // Request OK. Policy requires site_key, and we have one.
197 \Civi
::settings()->set("authx_guards", ['site_key']);
198 $response = $http->send($request->withHeader('X-Civi-Key', CIVICRM_SITE_KEY
));
199 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
201 // Request OK. Policy does not require site_key, and we do not have one
202 \Civi
::settings()->set("authx_guards", []);
203 $response = $http->send($request);
204 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
206 // Request fails. Policy requires site_key, but we don't have the wrong value.
207 \Civi
::settings()->set("authx_guards", ['site_key']);
208 $response = $http->send($request->withHeader('X-Civi-Key', 'not-the-site-key'));
209 $this->assertFailedDueToProhibition($response);
211 // Request fails. Policy requires site_key, but we don't have one.
212 \Civi
::settings()->set("authx_guards", ['site_key']);
213 $response = $http->send($request);
214 $this->assertFailedDueToProhibition($response);
218 * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout'
219 * to setup/teardown a session.
221 * @param string $credType
222 * The type of credential to put in the login request.
223 * @throws \CiviCRM_API3_Exception
224 * @throws \GuzzleHttp\Exception\GuzzleException
225 * @dataProvider getCredTypes
227 public function testStatefulLoginAllowed($credType) {
229 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
231 // Phase 1: Some pages are not accessible.
232 $http = $this->createGuzzle(['http_errors' => FALSE]);
233 $http->get('civicrm/user');
234 $this->assertDashboardUnauthorized();
236 // Phase 2: Request succeeds if this credential type is enabled
237 $cookieJar = new CookieJar();
238 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
239 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
240 $response = $http->post('civicrm/authx/login', [
241 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
243 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
244 $this->assertHasCookies($response);
246 // Phase 3: We can use cookies to request other pages
247 $response = $http->get('civicrm/authx/id');
248 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
249 $response = $http->get('civicrm/user');
250 $this->assertDashboardOk();
252 // Phase 4: After logout, requests should fail.
253 $oldCookies = clone $cookieJar;
254 $http->get('civicrm/authx/logout');
255 $this->assertStatusCode(200);
256 $http->get('civicrm/user');
257 $this->assertDashboardUnauthorized();
259 $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]);
260 $httpHaxor->get('civicrm/user');
261 $this->assertDashboardUnauthorized();
265 * The login flow 'civicrm/authx/login' may be prohibited by policy.
267 * @param string $credType
268 * The type of credential to put in the login request.
269 * @throws \CiviCRM_API3_Exception
270 * @throws \GuzzleHttp\Exception\GuzzleException
271 * @dataProvider getCredTypes
273 public function testStatefulLoginProhibited($credType) {
275 $http = $this->createGuzzle(['http_errors' => FALSE]);
276 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
278 \Civi
::settings()->set("authx_{$flowType}_cred", []);
279 $response = $http->post('civicrm/authx/login', [
280 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
282 $this->assertFailedDueToProhibition($response);
286 * The auto-login flow allows you to request a specific page with specific
287 * credentials. The new session is setup, and the page is displayed.
289 * @param string $credType
290 * The type of credential to put in the login request.
291 * @throws \CiviCRM_API3_Exception
292 * @throws \GuzzleHttp\Exception\GuzzleException
293 * @dataProvider getCredTypes
295 public function testStatefulAutoAllowed($credType) {
297 $cookieJar = new CookieJar();
298 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
300 /** @var \Psr\Http\Message\RequestInterface $request */
301 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
303 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
304 $this->assertEquals(0, $cookieJar->count());
305 $response = $http->send($request);
306 $this->assertTrue($cookieJar->count() >= 1);
307 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
309 // FIXME: Assert that re-using cookies yields correct result.
313 * The auto-login flow allows you to request a specific page with specific
314 * credentials. The new session is setup, and the page is displayed.
316 * @param string $credType
317 * The type of credential to put in the login request.
318 * @throws \CiviCRM_API3_Exception
319 * @throws \GuzzleHttp\Exception\GuzzleException
320 * @dataProvider getCredTypes
322 public function testStatefulAutoProhibited($credType) {
324 $cookieJar = new CookieJar();
325 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
327 /** @var \Psr\Http\Message\RequestInterface $request */
328 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
330 \Civi
::settings()->set("authx_{$flowType}_cred", []);
331 $response = $http->send($request);
332 $this->assertFailedDueToProhibition($response);
336 * Create a session for $demoCID. Within the session, make a single
337 * stateless request as $lebowskiCID.
339 * @throws \CiviCRM_API3_Exception
340 * @throws \GuzzleHttp\Exception\GuzzleException
342 public function testStatefulStatelessOverlap() {
343 \Civi
::settings()->set("authx_login_cred", ['api_key']);
344 \Civi
::settings()->set("authx_header_cred", ['api_key']);
346 $cookieJar = new CookieJar();
347 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
349 // Phase 1: Login, create a session.
350 $response = $http->post('civicrm/authx/login', [
351 'form_params' => ['_authx' => $this->credApikey($this->getDemoCID())],
353 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
354 $this->assertHasCookies($response);
355 $response = $http->get('civicrm/authx/id');
356 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
358 // Phase 2: Make a single, stateless request with different creds
359 /** @var \Psr\Http\Message\RequestInterface $request */
360 $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
361 $response = $http->send($request);
362 $this->assertFailedDueToProhibition($response);
363 // The following assertion merely identifies current behavior. If you can get it working generally, then huzza.
364 $this->assertBodyRegexp(';Session already active;', $response);
365 // $this->assertMyContact($this->getLebowskiCID(), NULL, $response);
366 // $this->assertNoCookies($response);
368 // Phase 3: Original session is still valid
369 $response = $http->get('civicrm/authx/id');
370 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
374 * This consumer intends to make stateless requests with a handful of different identities,
375 * but their browser happens to be cookie-enabled. Ensure that identities do not leak between requests.
377 * @throws \CiviCRM_API3_Exception
378 * @throws \GuzzleHttp\Exception\GuzzleException
380 public function testMultipleStateless() {
381 \Civi
::settings()->set("authx_header_cred", ['api_key']);
382 $cookieJar = new CookieJar();
383 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
385 /** @var \Psr\Http\Message\RequestInterface $request */
387 // Alternate calls among (A)nonymous, (D)emo, and (L)ebowski
388 $planSteps = 'LADA LDLD DDLLAA';
391 for ($i = 0; $i < strlen($planSteps); $i++
) {
392 switch ($planSteps[$i]) {
394 $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
395 $response = $http->send($request);
396 $this->assertMyContact($this->getLebowskiCID(), NULL, $response, 'Expected Lebowski in step #' . $i);
401 $request = $this->requestMyContact();
402 $response = $http->send($request);
403 $this->assertAnonymousContact($response);
408 $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getDemoCID());
409 $response = $http->send($request);
410 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response, 'Expected demo in step #' . $i);
419 $this->fail('Unrecognized step #' . $i);
423 $this->assertEquals($actualSteps, $planSteps);
427 * Filter a request, applying the given authentication options
429 * @param \Psr\Http\Message\RequestInterface $request
430 * @param string $credType
431 * Ex: 'pass', 'jwt', 'api_key'
432 * @param string $flowType
433 * Ex: 'param', 'header', 'xheader'
435 * @return \Psr\Http\Message\RequestInterface
437 protected function applyAuth($request, $credType, $flowType, $cid) {
438 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
439 $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
440 return $this->$flowFunc($request, $this->$credFunc($cid));
443 // ------------------------------------------------
444 // Library: Base requests
447 * Make an AJAX request with info about the current contact.
449 * @return \GuzzleHttp\Psr7\Request
451 public function requestMyContact() {
452 $p = (['where' => [['id', '=', 'user_contact_id']]]);
453 $uri = (new Uri('civicrm/authx/id'))
454 ->withQuery('params=' . urlencode(json_encode($p)));
455 $req = new Request('GET', $uri);
460 * Assert the AJAX request provided the expected contact.
463 * The expected contact ID
464 * @param int|null $uid
465 * The expected user ID
466 * @param \Psr\Http\Message\ResponseInterface $response
468 public function assertMyContact($cid, $uid, ResponseInterface
$response) {
469 $this->assertContentType('application/json', $response);
470 $this->assertStatusCode(200, $response);
471 $j = json_decode((string) $response->getBody(), 1);
472 $formattedFailure = $this->formatFailure($response);
473 $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $formattedFailure);
474 $this->assertEquals($uid, $j['user_id'], "Response did not give expected user ID\n" . $formattedFailure);
478 * Assert the AJAX request provided empty contact information
480 * @param \Psr\Http\Message\ResponseInterface $response
482 public function assertAnonymousContact(ResponseInterface
$response) {
483 $formattedFailure = $this->formatFailure($response);
484 $this->assertContentType('application/json', $response);
485 $this->assertStatusCode(200, $response);
486 $j = json_decode((string) $response->getBody(), 1);
487 if (json_last_error() !== JSON_ERROR_NONE ||
empty($j)) {
488 $this->fail('Malformed JSON' . $formattedFailure);
490 $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL, 'contact_id should be null' . $formattedFailure);
491 $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL, 'user_id should be null' . $formattedFailure);
495 * Assert that the $response indicates the user cannot view the dashboard.
497 * @param \Psr\Http\Message\ResponseInterface $response
499 public function assertDashboardUnauthorized($response = NULL) {
500 $response = $this->resolveResponse($response);
501 if (!in_array('authErrorShowsForm', $this->quirks
)) {
502 $this->assertStatusCode(403, $response);
505 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
506 'Response should not contain a dashboard' . $this->formatFailure($response)
510 public function assertDashboardOk($response = NULL) {
511 $response = $this->resolveResponse($response);
512 $this->assertStatusCode(200, $response);
513 $this->assertContentType('text/html', $response);
514 // If the first two assertions pass but the next fails, then... perhaps the
515 // local site permissions are wrong?
517 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
518 'Response should contain a dashboard' . $this->formatFailure($response)
522 // ------------------------------------------------
523 // Library: Flow functions
526 * Add query parameter ("&_authx=<CRED>").
528 * @param \GuzzleHttp\Psr7\Request $request
529 * @param string $cred
530 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
531 * @return \GuzzleHttp\Psr7\Request
533 public function authParam(Request
$request, $cred) {
534 $query = $request->getUri()->getQuery();
535 return $request->withUri(
536 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred))
541 * Add query parameter ("&_authx=<CRED>&_authxSes=1").
543 * @param \GuzzleHttp\Psr7\Request $request
544 * @param string $cred
545 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
546 * @return \GuzzleHttp\Psr7\Request
548 public function authAuto(Request
$request, $cred) {
549 $query = $request->getUri()->getQuery();
550 return $request->withUri(
551 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred) . '&_authxSes=1')
555 public function authLogin(Request
$request, $cred) {
556 return $request->withMethod('POST')
557 ->withBody(new AppendStream([
558 stream_for('_authx=' . urlencode($cred) . '&'),
563 public function authHeader(Request
$request, $cred) {
564 return $request->withHeader('Authorization', $cred);
567 public function authXHeader(Request
$request, $cred) {
568 return $request->withHeader('X-Civi-Auth', $cred);
571 public function authNone(Request
$request, $cred) {
575 // ------------------------------------------------
576 // Library: Credential functions
581 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
583 public function credPass($cid) {
584 if ($cid === $this->getDemoCID()) {
585 return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']);
588 $this->fail("This test does have the password the requested contact.");
592 public function credApikey($cid) {
593 $api_key = md5(\random_bytes
(16));
594 \
civicrm_api3('Contact', 'create', [
596 'api_key' => $api_key,
598 return 'Bearer ' . $api_key;
601 public function credJwt($cid) {
602 if (empty(\Civi
::service('crypto.registry')->findKeysByTag('SIGN'))) {
603 $this->markTestIncomplete('Cannot test JWT. No CIVICRM_SIGN_KEYS are defined.');
605 $token = \Civi
::service('crypto.jwt')->encode([
606 'exp' => time() +
60 * 60,
610 return 'Bearer ' . $token;
613 public function credNone($cid) {
618 * @param \Psr\Http\Message\ResponseInterface $response
620 private function assertFailedDueToProhibition($response) {
621 $this->assertBodyRegexp(';HTTP 401;', $response);
622 $this->assertContentType('text/plain', $response);
623 if (!in_array('sendsExcessCookies', $this->quirks
)) {
624 $this->assertNoCookies($response);
626 $this->assertStatusCode(401, $response);
631 * @param \Psr\Http\Message\ResponseInterface $response
633 private function assertNoCookies($response = NULL) {
634 $response = $this->resolveResponse($response);
636 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
637 'Response should not have cookies' . $this->formatFailure($response)
643 * @param \Psr\Http\Message\ResponseInterface $response
645 private function assertHasCookies($response = NULL) {
646 $response = $this->resolveResponse($response);
647 $this->assertNotEmpty(
648 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
649 'Response should have cookies' . $this->formatFailure($response)
656 * @param \Psr\Http\Message\ResponseInterface $response
658 private function assertBodyRegexp($regexp, $response = NULL) {
659 $response = $this->resolveResponse($response);
660 $this->assertRegexp($regexp, (string) $response->getBody(),
661 'Response body does not match pattern' . $this->formatFailure($response));
667 * @throws \CiviCRM_API3_Exception
669 private function getDemoCID(): int {
670 if (!isset(\Civi
::$statics[__CLASS__
]['demoId'])) {
671 \Civi
::$statics[__CLASS__
]['demoId'] = (int) \
civicrm_api3('Contact', 'getvalue', [
672 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'],
676 return \Civi
::$statics[__CLASS__
]['demoId'];
679 private function getDemoUID(): int {
680 return \CRM_Core_Config
::singleton()->userSystem
->getUfId($GLOBALS['_CV']['DEMO_USER']);
683 public function getLebowskiCID() {
684 if (!isset(\Civi
::$statics[__CLASS__
]['lebowskiCID'])) {
685 $contact = \
civicrm_api3('Contact', 'create', [
686 'contact_type' => 'Individual',
687 'first_name' => 'Jeffrey',
688 'last_name' => 'Lebowski',
689 'external_identifier' => __CLASS__
,
691 'match' => 'external_identifier',
694 \Civi
::$statics[__CLASS__
]['lebowskiCID'] = $contact['id'];
696 return \Civi
::$statics[__CLASS__
]['lebowskiCID'];