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() {
38 \Civi\Test
::e2e()->installMe(__DIR__
)->apply();
41 public function setUp() {
43 'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
44 'WordPress' => ['sendsExcessCookies'],
46 $this->quirks
= $quirks[CIVICRM_UF
] ??
[];
49 $this->settingsBackup
= [];
50 foreach (\Civi\Authx\Meta
::getFlowTypes() as $flowType) {
51 foreach (["authx_{$flowType}_cred", "authx_{$flowType}_user"] as $setting) {
52 $this->settingsBackup
[$setting] = \Civi
::settings()->get($setting);
57 public function tearDown() {
58 foreach ($this->settingsBackup
as $setting => $value) {
59 \Civi
::settings()->set($setting, $value);
64 public function getStatelessExamples() {
66 $exs[] = ['pass', 'param'];
67 $exs[] = ['pass', 'header'];
68 $exs[] = ['pass', 'xheader'];
69 $exs[] = ['api_key', 'param'];
70 $exs[] = ['api_key', 'header'];
71 $exs[] = ['api_key', 'xheader'];
72 $exs[] = ['jwt', 'param'];
73 $exs[] = ['jwt', 'header'];
74 $exs[] = ['jwt', 'xheader'];
78 public function getCredTypes() {
86 public function testAnonymous() {
87 $http = $this->createGuzzle(['http_errors' => FALSE]);
89 /** @var \Psr\Http\Message\RequestInterface $request */
90 $request = $this->requestMyContact();
91 $response = $http->send($request);
92 $this->assertNoContact(NULL, $response);
96 * Send a request using a stateless protocol. Assert that identities are setup correctly.
98 * @param string $credType
99 * The type of credential to put in the `Authorization:` header.
100 * @param string $flowType
101 * The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
102 * @throws \CiviCRM_API3_Exception
103 * @throws \GuzzleHttp\Exception\GuzzleException
104 * @dataProvider getStatelessExamples
106 public function testStateless($credType, $flowType) {
107 $http = $this->createGuzzle(['http_errors' => FALSE]);
109 /** @var \Psr\Http\Message\RequestInterface $request */
110 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
112 // Phase 1: Request fails if this credential type is not enabled
113 \Civi
::settings()->set("authx_{$flowType}_cred", []);
114 $response = $http->send($request);
115 $this->assertFailedDueToProhibition($response);
117 // Phase 2: Request succeeds if this credential type is enabled
118 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
119 $response = $http->send($request);
120 $this->assertMyContact($this->getDemoCID(), $response);
121 if (!in_array('sendsExcessCookies', $this->quirks
)) {
122 $this->assertNoCookies($response);
127 * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout'
128 * to setup/teardown a session.
130 * @param string $credType
131 * The type of credential to put in the login request.
132 * @throws \CiviCRM_API3_Exception
133 * @throws \GuzzleHttp\Exception\GuzzleException
134 * @dataProvider getCredTypes
136 public function testStatefulLoginAllowed($credType) {
138 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
140 // Phase 1: Some pages are not accessible.
141 $http = $this->createGuzzle(['http_errors' => FALSE]);
142 $http->get('civicrm/user');
143 $this->assertDashboardUnauthorized();
145 // Phase 2: Request succeeds if this credential type is enabled
146 $cookieJar = new CookieJar();
147 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
148 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
149 $response = $http->post('civicrm/authx/login', [
150 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
152 $this->assertMyContact($this->getDemoCID(), $response);
153 $this->assertHasCookies($response);
155 // Phase 3: We can use cookies to request other pages
156 $response = $http->get('civicrm/authx/id');
157 $this->assertMyContact($this->getDemoCID(), $response);
158 $response = $http->get('civicrm/user');
159 $this->assertDashboardOk();
161 // Phase 4: After logout, requests should fail.
162 $oldCookies = clone $cookieJar;
163 $http->get('civicrm/authx/logout');
164 $this->assertStatusCode(200);
165 $http->get('civicrm/user');
166 $this->assertDashboardUnauthorized();
168 $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]);
169 $httpHaxor->get('civicrm/user');
170 $this->assertDashboardUnauthorized();
174 * The login flow 'civicrm/authx/login' may be prohibited by policy.
176 * @param string $credType
177 * The type of credential to put in the login request.
178 * @throws \CiviCRM_API3_Exception
179 * @throws \GuzzleHttp\Exception\GuzzleException
180 * @dataProvider getCredTypes
182 public function testStatefulLoginProhibited($credType) {
184 $http = $this->createGuzzle(['http_errors' => FALSE]);
185 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
187 \Civi
::settings()->set("authx_{$flowType}_cred", []);
188 $response = $http->post('civicrm/authx/login', [
189 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
191 $this->assertFailedDueToProhibition($response);
195 * The auto-login flow allows you to request a specific page with specific
196 * credentials. The new session is setup, and the page is displayed.
198 * @param string $credType
199 * The type of credential to put in the login request.
200 * @throws \CiviCRM_API3_Exception
201 * @throws \GuzzleHttp\Exception\GuzzleException
202 * @dataProvider getCredTypes
204 public function testStatefulAutoAllowed($credType) {
206 $cookieJar = new CookieJar();
207 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
209 /** @var \Psr\Http\Message\RequestInterface $request */
210 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
212 \Civi
::settings()->set("authx_{$flowType}_cred", [$credType]);
213 $response = $http->send($request);
214 $this->assertHasCookies($response);
215 $this->assertMyContact($this->getDemoCID(), $response);
217 // FIXME: Assert that re-using cookies yields correct result.
221 * The auto-login flow allows you to request a specific page with specific
222 * credentials. The new session is setup, and the page is displayed.
224 * @param string $credType
225 * The type of credential to put in the login request.
226 * @throws \CiviCRM_API3_Exception
227 * @throws \GuzzleHttp\Exception\GuzzleException
228 * @dataProvider getCredTypes
230 public function testStatefulAutoProhibited($credType) {
232 $cookieJar = new CookieJar();
233 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
235 /** @var \Psr\Http\Message\RequestInterface $request */
236 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
238 \Civi
::settings()->set("authx_{$flowType}_cred", []);
239 $response = $http->send($request);
240 $this->assertFailedDueToProhibition($response);
244 * Filter a request, applying the given authentication options
246 * @param \Psr\Http\Message\RequestInterface $request
247 * @param string $credType
248 * Ex: 'pass', 'jwt', 'api_key'
249 * @param string $flowType
250 * Ex: 'param', 'header', 'xheader'
252 * @return \Psr\Http\Message\RequestInterface
254 protected function applyAuth($request, $credType, $flowType, $cid) {
255 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
256 $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
257 return $this->$flowFunc($request, $this->$credFunc($cid));
260 // ------------------------------------------------
261 // Library: Base requests
264 * Make an AJAX request with info about the current contact.
266 * @return \GuzzleHttp\Psr7\Request
268 public function requestMyContact() {
269 $p = (['where' => [['id', '=', 'user_contact_id']]]);
270 $uri = (new Uri('civicrm/authx/id'))
271 ->withQuery('params=' . urlencode(json_encode($p)));
272 $req = new Request('GET', $uri);
277 * Assert the AJAX request provided the expected contact.
280 * @param \Psr\Http\Message\ResponseInterface $response
282 public function assertMyContact($cid, ResponseInterface
$response) {
283 $this->assertContentType('application/json', $response);
284 $this->assertStatusCode(200, $response);
285 $j = json_decode((string) $response->getBody(), 1);
286 $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $this->formatFailure($response));
290 * Assert the AJAX request provided empty contact information
293 * @param \Psr\Http\Message\ResponseInterface $response
295 public function assertNoContact($cid, ResponseInterface
$response) {
296 $this->assertContentType('application/json', $response);
297 $this->assertStatusCode(200, $response);
298 $j = json_decode((string) $response->getBody(), 1);
299 if (json_last_error() !== JSON_ERROR_NONE ||
empty($j)) {
300 $this->fail('Malformed JSON' . $this->formatFailure());
302 $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL);
303 $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL);
307 * Assert that the $response indicates the user cannot view the dashboard.
309 * @param \Psr\Http\Message\ResponseInterface $response
311 public function assertDashboardUnauthorized($response = NULL) {
312 $response = $this->resolveResponse($response);
313 if (!in_array('authErrorShowsForm', $this->quirks
)) {
314 $this->assertStatusCode(403, $response);
317 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
318 'Response should not contain a dashboard' . $this->formatFailure($response)
322 public function assertDashboardOk($response = NULL) {
323 $response = $this->resolveResponse($response);
324 $this->assertStatusCode(200, $response);
325 $this->assertContentType('text/html', $response);
326 // If the first two assertions pass but the next fails, then... perhaps the
327 // local site permissions are wrong?
329 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
330 'Response should contain a dashboard' . $this->formatFailure($response)
334 // ------------------------------------------------
335 // Library: Flow functions
338 * Add query parameter ("&_authx=<CRED>").
340 * @param \GuzzleHttp\Psr7\Request $request
341 * @param string $cred
342 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
343 * @return \GuzzleHttp\Psr7\Request
345 public function authParam(Request
$request, $cred) {
346 $query = $request->getUri()->getQuery();
347 return $request->withUri(
348 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred))
353 * Add query parameter ("&_authx=<CRED>&_authxSes=1").
355 * @param \GuzzleHttp\Psr7\Request $request
356 * @param string $cred
357 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
358 * @return \GuzzleHttp\Psr7\Request
360 public function authAuto(Request
$request, $cred) {
361 $query = $request->getUri()->getQuery();
362 return $request->withUri(
363 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred) . '&_authxSes=1')
367 public function authLogin(Request
$request, $cred) {
368 return $request->withMethod('POST')
369 ->withBody(new AppendStream([
370 stream_for('_authx=' . urlencode($cred) . '&'),
375 public function authHeader(Request
$request, $cred) {
376 return $request->withHeader('Authorization', $cred);
379 public function authXHeader(Request
$request, $cred) {
380 return $request->withHeader('X-Civi-Auth', $cred);
383 public function authNone(Request
$request, $cred) {
387 // ------------------------------------------------
388 // Library: Credential functions
393 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
395 public function credPass($cid) {
396 if ($cid === $this->getDemoCID()) {
397 return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']);
400 $this->fail("This test does have the password the requested contact.");
404 public function credApikey($cid) {
405 $api_key = md5(\random_bytes
(16));
406 \
civicrm_api3('Contact', 'create', [
408 'api_key' => $api_key,
410 return 'Bearer ' . $api_key;
413 public function credJwt($cid) {
414 if (empty(\Civi
::service('crypto.registry')->findKeysByTag('SIGN'))) {
415 $this->markTestIncomplete('Cannot test JWT. No CIVICRM_SIGN_KEYS are defined.');
417 $token = \Civi
::service('crypto.jwt')->encode([
418 'exp' => time() +
60 * 60,
422 return 'Bearer ' . $token;
425 public function credNone($cid) {
429 // public function createBareJwtCred() {
430 // $contact = \civicrm_api3('Contact', 'create', [
431 // 'contact_type' => 'Individual',
432 // 'first_name' => 'Jeffrey',
433 // 'last_name' => 'Lebowski',
434 // 'external_identifier' => __CLASS__,
436 // 'match' => 'external_identifier',
442 * @param \Psr\Http\Message\ResponseInterface $response
444 private function assertFailedDueToProhibition($response) {
445 $this->assertBodyRegexp(';HTTP 401;', $response);
446 $this->assertContentType('text/plain', $response);
447 if (!in_array('sendsExcessCookies', $this->quirks
)) {
448 $this->assertNoCookies($response);
450 $this->assertStatusCode(401, $response);
455 * @param \Psr\Http\Message\ResponseInterface $response
457 private function assertNoCookies($response = NULL) {
458 $response = $this->resolveResponse($response);
460 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
461 'Response should not have cookies' . $this->formatFailure($response)
467 * @param \Psr\Http\Message\ResponseInterface $response
469 private function assertHasCookies($response = NULL) {
470 $response = $this->resolveResponse($response);
471 $this->assertNotEmpty(
472 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
473 'Response should have cookies' . $this->formatFailure($response)
480 * @param \Psr\Http\Message\ResponseInterface $response
482 private function assertBodyRegexp($regexp, $response = NULL) {
483 $response = $this->resolveResponse($response);
484 $this->assertRegexp($regexp, (string) $response->getBody(),
485 'Response body does not match pattern' . $this->formatFailure($response));
491 * @throws \CiviCRM_API3_Exception
493 private function getDemoCID(): int {
494 if (!isset(\Civi
::$statics[__CLASS__
]['demoId'])) {
495 \Civi
::$statics[__CLASS__
]['demoId'] = (int) \
civicrm_api3('Contact', 'getvalue', [
496 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'],
500 return \Civi
::$statics[__CLASS__
]['demoId'];