7641452bd5997e9e49709e31bf2a806d67bc750b
[civicrm-core.git] / ext / authx / tests / phpunit / Civi / Authx / AllFlowsTest.php
1 <?php
2
3 namespace Civi\Authx;
4
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;
14
15 /**
16 * This is a matrix-style test which assesses all supported permutations of
17 *
18 * @group e2e
19 */
20 class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterface {
21
22 use HttpTestTrait;
23
24 /**
25 * Backup copy of the original settings.
26 *
27 * @var array
28 */
29 protected $settingsBackup;
30
31 /**
32 * List of CMS-dependent quirks that should be ignored during testing.
33 * @var array
34 */
35 protected $quirks = [];
36
37 public static function setUpBeforeClass() {
38 \Civi\Test::e2e()->installMe(__DIR__)->apply();
39 }
40
41 public function setUp() {
42 $quirks = [
43 'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
44 'WordPress' => ['sendsExcessCookies'],
45 ];
46 $this->quirks = $quirks[CIVICRM_UF] ?? [];
47
48 parent::setUp();
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);
53 }
54 }
55 }
56
57 public function tearDown() {
58 foreach ($this->settingsBackup as $setting => $value) {
59 \Civi::settings()->set($setting, $value);
60 }
61 parent::tearDown();
62 }
63
64 public function getStatelessExamples() {
65 $exs = [];
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'];
75 return $exs;
76 }
77
78 public function getCredTypes() {
79 $exs = [];
80 $exs[] = ['pass'];
81 $exs[] = ['api_key'];
82 $exs[] = ['jwt'];
83 return $exs;
84 }
85
86 public function testAnonymous() {
87 $http = $this->createGuzzle(['http_errors' => FALSE]);
88
89 /** @var \Psr\Http\Message\RequestInterface $request */
90 $request = $this->requestMyContact();
91 $response = $http->send($request);
92 $this->assertNoContact(NULL, $response);
93 }
94
95 /**
96 * Send a request using a stateless protocol. Assert that identities are setup correctly.
97 *
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
105 */
106 public function testStateless($credType, $flowType) {
107 $http = $this->createGuzzle(['http_errors' => FALSE]);
108
109 /** @var \Psr\Http\Message\RequestInterface $request */
110 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
111
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);
116
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);
123 }
124 }
125
126 /**
127 * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout'
128 * to setup/teardown a session.
129 *
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
135 */
136 public function testStatefulLoginAllowed($credType) {
137 $flowType = 'login';
138 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
139
140 // Phase 1: Some pages are not accessible.
141 $http = $this->createGuzzle(['http_errors' => FALSE]);
142 $http->get('civicrm/user');
143 $this->assertDashboardUnauthorized();
144
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())],
151 ]);
152 $this->assertMyContact($this->getDemoCID(), $response);
153 $this->assertHasCookies($response);
154
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();
160
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();
167
168 $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]);
169 $httpHaxor->get('civicrm/user');
170 $this->assertDashboardUnauthorized();
171 }
172
173 /**
174 * The login flow 'civicrm/authx/login' may be prohibited by policy.
175 *
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
181 */
182 public function testStatefulLoginProhibited($credType) {
183 $flowType = 'login';
184 $http = $this->createGuzzle(['http_errors' => FALSE]);
185 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
186
187 \Civi::settings()->set("authx_{$flowType}_cred", []);
188 $response = $http->post('civicrm/authx/login', [
189 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
190 ]);
191 $this->assertFailedDueToProhibition($response);
192 }
193
194 /**
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.
197 *
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
203 */
204 public function testStatefulAutoAllowed($credType) {
205 $flowType = 'auto';
206 $cookieJar = new CookieJar();
207 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
208
209 /** @var \Psr\Http\Message\RequestInterface $request */
210 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
211
212 \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
213 $response = $http->send($request);
214 $this->assertHasCookies($response);
215 $this->assertMyContact($this->getDemoCID(), $response);
216
217 // FIXME: Assert that re-using cookies yields correct result.
218 }
219
220 /**
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.
223 *
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
229 */
230 public function testStatefulAutoProhibited($credType) {
231 $flowType = 'auto';
232 $cookieJar = new CookieJar();
233 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
234
235 /** @var \Psr\Http\Message\RequestInterface $request */
236 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
237
238 \Civi::settings()->set("authx_{$flowType}_cred", []);
239 $response = $http->send($request);
240 $this->assertFailedDueToProhibition($response);
241 }
242
243 /**
244 * Filter a request, applying the given authentication options
245 *
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'
251 * @param int $cid
252 * @return \Psr\Http\Message\RequestInterface
253 */
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));
258 }
259
260 // ------------------------------------------------
261 // Library: Base requests
262
263 /**
264 * Make an AJAX request with info about the current contact.
265 *
266 * @return \GuzzleHttp\Psr7\Request
267 */
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);
273 return $req;
274 }
275
276 /**
277 * Assert the AJAX request provided the expected contact.
278 *
279 * @param int $cid
280 * @param \Psr\Http\Message\ResponseInterface $response
281 */
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));
287 }
288
289 /**
290 * Assert the AJAX request provided empty contact information
291 *
292 * @param int $cid
293 * @param \Psr\Http\Message\ResponseInterface $response
294 */
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());
301 }
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);
304 }
305
306 /**
307 * Assert that the $response indicates the user cannot view the dashboard.
308 *
309 * @param \Psr\Http\Message\ResponseInterface $response
310 */
311 public function assertDashboardUnauthorized($response = NULL) {
312 $response = $this->resolveResponse($response);
313 if (!in_array('authErrorShowsForm', $this->quirks)) {
314 $this->assertStatusCode(403, $response);
315 }
316 $this->assertFalse(
317 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
318 'Response should not contain a dashboard' . $this->formatFailure($response)
319 );
320 }
321
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?
328 $this->assertTrue(
329 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
330 'Response should contain a dashboard' . $this->formatFailure($response)
331 );
332 }
333
334 // ------------------------------------------------
335 // Library: Flow functions
336
337 /**
338 * Add query parameter ("&_authx=<CRED>").
339 *
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
344 */
345 public function authParam(Request $request, $cred) {
346 $query = $request->getUri()->getQuery();
347 return $request->withUri(
348 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred))
349 );
350 }
351
352 /**
353 * Add query parameter ("&_authx=<CRED>&_authxSes=1").
354 *
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
359 */
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')
364 );
365 }
366
367 public function authLogin(Request $request, $cred) {
368 return $request->withMethod('POST')
369 ->withBody(new AppendStream([
370 stream_for('_authx=' . urlencode($cred) . '&'),
371 $request->getBody(),
372 ]));
373 }
374
375 public function authHeader(Request $request, $cred) {
376 return $request->withHeader('Authorization', $cred);
377 }
378
379 public function authXHeader(Request $request, $cred) {
380 return $request->withHeader('X-Civi-Auth', $cred);
381 }
382
383 public function authNone(Request $request, $cred) {
384 return $request;
385 }
386
387 // ------------------------------------------------
388 // Library: Credential functions
389
390 /**
391 * @param int $cid
392 * @return string
393 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
394 */
395 public function credPass($cid) {
396 if ($cid === $this->getDemoCID()) {
397 return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']);
398 }
399 else {
400 $this->fail("This test does have the password the requested contact.");
401 }
402 }
403
404 public function credApikey($cid) {
405 $api_key = md5(\random_bytes(16));
406 \civicrm_api3('Contact', 'create', [
407 'id' => $cid,
408 'api_key' => $api_key,
409 ]);
410 return 'Bearer ' . $api_key;
411 }
412
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.');
416 }
417 $token = \Civi::service('crypto.jwt')->encode([
418 'exp' => time() + 60 * 60,
419 'sub' => "cid:$cid",
420 'scope' => 'authx',
421 ]);
422 return 'Bearer ' . $token;
423 }
424
425 public function credNone($cid) {
426 return NULL;
427 }
428
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__,
435 // 'options' => [
436 // 'match' => 'external_identifier',
437 // ],
438 // ]);
439 // }
440
441 /**
442 * @param \Psr\Http\Message\ResponseInterface $response
443 */
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);
449 }
450 $this->assertStatusCode(401, $response);
451
452 }
453
454 /**
455 * @param \Psr\Http\Message\ResponseInterface $response
456 */
457 private function assertNoCookies($response = NULL) {
458 $response = $this->resolveResponse($response);
459 $this->assertEmpty(
460 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
461 'Response should not have cookies' . $this->formatFailure($response)
462 );
463 return $this;
464 }
465
466 /**
467 * @param \Psr\Http\Message\ResponseInterface $response
468 */
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)
474 );
475 return $this;
476 }
477
478 /**
479 * @param $regexp
480 * @param \Psr\Http\Message\ResponseInterface $response
481 */
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));
486 return $this;
487 }
488
489 /**
490 * @return int
491 * @throws \CiviCRM_API3_Exception
492 */
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'],
497 'return' => 'id',
498 ]);
499 }
500 return \Civi::$statics[__CLASS__]['demoId'];
501 }
502
503 }