From d0528c96e3747cb593bf5c0e6e7037673e4c03e2 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Mon, 15 Feb 2021 22:12:06 -0800 Subject: [PATCH] authx - Support login/logout flow --- ext/authx/CRM/Authx/Page/AJAX.php | 44 +++++ ext/authx/CRM/Authx/Page/Id.php | 20 -- ext/authx/Civi/Authx/Meta.php | 2 +- ext/authx/README.md | 6 +- ext/authx/authx.php | 2 +- ext/authx/settings/authx.setting.php | 2 +- .../tests/phpunit/Civi/Authx/AllFlowsTest.php | 173 +++++++++++++----- ext/authx/xml/Menu/authx.xml | 17 +- 8 files changed, 196 insertions(+), 70 deletions(-) create mode 100644 ext/authx/CRM/Authx/Page/AJAX.php delete mode 100644 ext/authx/CRM/Authx/Page/Id.php diff --git a/ext/authx/CRM/Authx/Page/AJAX.php b/ext/authx/CRM/Authx/Page/AJAX.php new file mode 100644 index 0000000000..ecdba043b2 --- /dev/null +++ b/ext/authx/CRM/Authx/Page/AJAX.php @@ -0,0 +1,44 @@ + CRM_Core_Session::getLoggedInContactID(), + 'user_id' => $authxUf->getCurrentUserId(), + ]; + + CRM_Utils_JSON::output($response); + } + + /** + * Present the outcome of an authx login. + * + * Note that the actual authentication is handled in the authentication layer. + * This method just renders the response page after a successful login. + */ + public static function login() { + self::getId(); + } + + /** + * Logout of Civi+CMS. + * + * GET /civicrm/authx/logout + * POST /civicrm/authx/logout + */ + public static function logout() { + _authx_uf()->logoutSession(); + CRM_Utils_JSON::output([]); + } + +} diff --git a/ext/authx/CRM/Authx/Page/Id.php b/ext/authx/CRM/Authx/Page/Id.php deleted file mode 100644 index 24c14dfd4b..0000000000 --- a/ext/authx/CRM/Authx/Page/Id.php +++ /dev/null @@ -1,20 +0,0 @@ - CRM_Core_Session::getLoggedInContactID(), - 'user_id' => $authxUf->getCurrentUserId(), - ]; - - CRM_Utils_System::setHttpHeader('Content-Type', 'application/json'); - echo json_encode($response); - CRM_Utils_System::civiExit(); - } - -} diff --git a/ext/authx/Civi/Authx/Meta.php b/ext/authx/Civi/Authx/Meta.php index 26cf29cc89..9e5915cec9 100644 --- a/ext/authx/Civi/Authx/Meta.php +++ b/ext/authx/Civi/Authx/Meta.php @@ -36,7 +36,7 @@ class Meta { 'param' => E::ts('Ephemeral: Paramter'), 'header' => E::ts('Ephemeral: Common Header'), 'xheader' => E::ts('Ephemeral: X-Header'), - 'endpoint' => E::ts('Persistent: End-point session'), + 'login' => E::ts('Persistent: Login session'), 'auto' => E::ts('Persistent: Auto session'), ]; } diff --git a/ext/authx/README.md b/ext/authx/README.md index 6afa305736..f0515b8a3e 100644 --- a/ext/authx/README.md +++ b/ext/authx/README.md @@ -15,7 +15,7 @@ There are two general flows of authentication, each with a few variations: * __X-Header (`xheader`)__: The credential is again submitted with an HTTP header (`X-Civi-Auth:`). The header behaves the same as the common header. The differing name means that clients must specifically support it, but it also reduces the odsd of interference. * __Persistent / Stateful__: The client makes a request for a persistent session, attaching the contact ID and/or user ID. These will be used in subsequent requests. - * __End-point session (`endpoint`)__: The client submits an explicit authentication request (`POST /civicrm/authx/login?_authx=`) which creates a session and cookie. + * __End-point session (`login`)__: The client submits an explicit authentication request (`POST /civicrm/authx/login?_authx=`) which creates a session and cookie. The authenticated session endures until one logs out (`/civicrm/authx/logout`). * __Auto session (`auto`)__: The clients submits a GET request for any page (`?_authx=&_authxSes=1`). The session is initialized. The user redirects to original page. @@ -48,8 +48,8 @@ For each authentication flow, one may toggle support for different credentials a * Accepted credentials (`authx_xheader_cred`): `['jwt']` * User link (`authx_xheader_user`): `'optional'` * Persistent: End-point session flow - * Accepted credentials (`authx_endpoint_cred`): `['jwt']` - * User link (`authx_endpoint_user`): `require` + * Accepted credentials (`authx_login_cred`): `['jwt']` + * User link (`authx_login_user`): `require` * Persistent: Auto session flow * Accepted credentials (`authx_auto_cred`): `['paramalogin']` for Joomla, and `[]` for all others * User link (`authx_auto_user`): `require` diff --git a/ext/authx/authx.php b/ext/authx/authx.php index 4485c07efa..21224dfb9c 100644 --- a/ext/authx/authx.php +++ b/ext/authx/authx.php @@ -17,7 +17,7 @@ Civi::dispatcher()->addListener('civi.invoke.auth', function($e) { $params = ($_SERVER['REQUEST_METHOD'] === 'GET') ? $_GET : $_POST; if (!empty($params['_authx'])) { if ((implode('/', $e->args) === 'civicrm/authx/login')) { - (new \Civi\Authx\Authenticator('endpoint'))->auth($e, $params['_authx'], TRUE); + (new \Civi\Authx\Authenticator('login'))->auth($e, $params['_authx'], TRUE); _authx_redact(['_authx']); } elseif (!empty($params['_authxSes'])) { diff --git a/ext/authx/settings/authx.setting.php b/ext/authx/settings/authx.setting.php index 6aaf3b12b9..6a317fe171 100644 --- a/ext/authx/settings/authx.setting.php +++ b/ext/authx/settings/authx.setting.php @@ -17,7 +17,7 @@ use CRM_Authx_ExtensionUtil as E; * @copyright CiviCRM LLC https://civicrm.org/licensing */ function _authx_settings() { - $flows = ['param', 'header', 'xheader', 'endpoint', 'auto']; + $flows = ['param', 'header', 'xheader', 'login', 'auto']; $basic = [ 'group_name' => 'CiviCRM Preferences', 'group' => 'authx', diff --git a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php index da87539b5f..db3d59f3bf 100644 --- a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php +++ b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php @@ -5,9 +5,12 @@ namespace Civi\Authx; use Civi\Test\HttpTestTrait; use CRM_Authx_ExtensionUtil as E; use Civi\Test\EndToEndInterface; +use GuzzleHttp\Cookie\CookieJar; +use GuzzleHttp\Psr7\AppendStream; use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Uri; use Psr\Http\Message\ResponseInterface; +use function GuzzleHttp\Psr7\stream_for; /** * This is a matrix-style test which assesses all supported permutations of @@ -71,11 +74,11 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf return $exs; } - public function getStatefulExamples() { + public function getCredTypes() { $exs = []; - $exs[] = ['pass', 'auto']; - $exs[] = ['api_key', 'auto']; - $exs[] = ['jwt', 'auto']; + $exs[] = ['pass']; + $exs[] = ['api_key']; + $exs[] = ['jwt']; return $exs; } @@ -100,84 +103,127 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf * @dataProvider getStatelessExamples */ public function testStateless($credType, $flowType) { - $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType)); - $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType)); - - $cid = \civicrm_api3('Contact', 'getvalue', [ - 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'], - 'return' => 'id', - ]); - $http = $this->createGuzzle(['http_errors' => FALSE]); /** @var \Psr\Http\Message\RequestInterface $request */ - $request = $this->$flowFunc($this->requestMyContact(), $this->$credFunc($cid)); + $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID()); // Phase 1: Request fails if this credential type is not enabled \Civi::settings()->set("authx_{$flowType}_cred", []); $response = $http->send($request); - $this->assertBodyRegexp(';HTTP 401;', $response); - $this->assertContentType('text/plain', $response); - if (!in_array('sendsExcessCookies', $this->quirks)) { - $this->assertNoCookies($response); - } - $this->assertStatusCode(401, $response); + $this->assertFailedDueToProhibition($response); // Phase 2: Request succeeds if this credential type is enabled \Civi::settings()->set("authx_{$flowType}_cred", [$credType]); $response = $http->send($request); - $this->assertStatusCode(200, $response); + $this->assertMyContact($this->getDemoCID(), $response); if (!in_array('sendsExcessCookies', $this->quirks)) { $this->assertNoCookies($response); } - $this->assertMyContact($cid, $response); } /** - * Send a request using a stateful protocol. Assert that identities are setup correctly. + * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout' + * to setup/teardown a session. * * @param string $credType - * The type of credential to put in the `Authorization:` header. - * @param string $flowType - * The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header). + * The type of credential to put in the login request. * @throws \CiviCRM_API3_Exception * @throws \GuzzleHttp\Exception\GuzzleException - * @dataProvider getStatefulExamples + * @dataProvider getCredTypes */ - public function testStateful($credType, $flowType) { + public function testStatefulLogin($credType) { + $flowType = 'login'; + $cookieJar = new CookieJar(); + $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]); $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType)); - $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType)); - $cid = \civicrm_api3('Contact', 'getvalue', [ - 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'], - 'return' => 'id', + // Phase 0: Some pages are not accessible to anonymous users. + $http->get('civicrm/dashboard'); + $this->assertStatusCode(403); + + // Phase 1: Request fails if this credential type is not enabled + \Civi::settings()->set("authx_{$flowType}_cred", []); + $response = $http->post('civicrm/authx/login', [ + 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())], + ]); + $this->assertFailedDueToProhibition($response); + + // Phase 2: Request succeeds if this credential type is enabled + \Civi::settings()->set("authx_{$flowType}_cred", [$credType]); + $response = $http->post('civicrm/authx/login', [ + 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())], ]); + $this->assertMyContact($this->getDemoCID(), $response); + $this->assertHasCookies($response); - $http = $this->createGuzzle(['http_errors' => FALSE]); + // Phase 3: We can use cookies to request other pages + $response = $http->get('civicrm/authx/id'); + $this->assertMyContact($this->getDemoCID(), $response); + $response = $http->get('civicrm/dashboard'); + $this->assertStatusCode(200)->assertContentType('text/html'); + + // Phase 4: After logout, requests should fail. + $oldCookies = clone $cookieJar; + $http->get('civicrm/authx/logout'); + $this->assertStatusCode(200); + $http->get('civicrm/dashboard'); + $this->assertStatusCode(403); + + $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]); + $httpHaxor->get('civicrm/dashboard'); + $this->assertStatusCode(403); + } + + /** + * The auto-login flow allows you to request a specific page with specific + * credentials. The new session is setup, and the page is displayed. + * + * @param string $credType + * The type of credential to put in the login request. + * @throws \CiviCRM_API3_Exception + * @throws \GuzzleHttp\Exception\GuzzleException + * @dataProvider getCredTypes + */ + public function testStatefulAuto($credType) { + $flowType = 'auto'; + $cookieJar = new CookieJar(); + $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]); /** @var \Psr\Http\Message\RequestInterface $request */ - $request = $this->$flowFunc($this->requestMyContact(), $this->$credFunc($cid)); + $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID()); // Phase 1: Request fails if this credential type is not enabled \Civi::settings()->set("authx_{$flowType}_cred", []); $response = $http->send($request); - $this->assertBodyRegexp(';HTTP 401;', $response); - $this->assertContentType('text/plain', $response); - if (!in_array('sendsExcessCookies', $this->quirks)) { - $this->assertNoCookies($response); - } - $this->assertStatusCode(401, $response); + $this->assertFailedDueToProhibition($response); // Phase 2: Request succeeds if this credential type is enabled \Civi::settings()->set("authx_{$flowType}_cred", [$credType]); $response = $http->send($request); - $this->assertStatusCode(200, $response); $this->assertHasCookies($response); - $this->assertMyContact($cid, $response); + $this->assertMyContact($this->getDemoCID(), $response); // FIXME: Assert that re-using cookies yields correct result. } + /** + * Filter a request, applying the given authentication options + * + * @param \Psr\Http\Message\RequestInterface $request + * @param string $credType + * Ex: 'pass', 'jwt', 'api_key' + * @param string $flowType + * Ex: 'param', 'header', 'xheader' + * @param int $cid + * @return \Psr\Http\Message\RequestInterface + */ + protected function applyAuth($request, $credType, $flowType, $cid) { + $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType)); + $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType)); + return $this->$flowFunc($request, $this->$credFunc($cid)); + } + // ------------------------------------------------ // Library: Base requests @@ -254,6 +300,14 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf ); } + public function authLogin(Request $request, $cred) { + return $request->withMethod('POST') + ->withBody(new AppendStream([ + stream_for('_authx=' . urlencode($cred) . '&'), + $request->getBody(), + ])); + } + public function authHeader(Request $request, $cred) { return $request->withHeader('Authorization', $cred); } @@ -275,7 +329,12 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). */ public function credPass($cid) { - return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']); + if ($cid === $this->getDemoCID()) { + return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']); + } + else { + $this->fail("This test does have the password the requested contact."); + } } public function credApikey($cid) { @@ -315,6 +374,19 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf // ]); // } + /** + * @param \Psr\Http\Message\ResponseInterface $response + */ + private function assertFailedDueToProhibition($response) { + $this->assertBodyRegexp(';HTTP 401;', $response); + $this->assertContentType('text/plain', $response); + if (!in_array('sendsExcessCookies', $this->quirks)) { + $this->assertNoCookies($response); + } + $this->assertStatusCode(401, $response); + + } + /** * @param \Psr\Http\Message\ResponseInterface $response */ @@ -345,8 +417,23 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf */ private function assertBodyRegexp($regexp, $response = NULL) { $response = $this->resolveResponse($response); - $this->assertRegexp($regexp, (string) $response->getBody()); + $this->assertRegexp($regexp, (string) $response->getBody(), + 'Response body does not match pattern' . $this->formatFailure($response)); return $this; } + /** + * @return int + * @throws \CiviCRM_API3_Exception + */ + private function getDemoCID(): int { + if (!isset(\Civi::$statics[__CLASS__]['demoId'])) { + \Civi::$statics[__CLASS__]['demoId'] = (int) \civicrm_api3('Contact', 'getvalue', [ + 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'], + 'return' => 'id', + ]); + } + return \Civi::$statics[__CLASS__]['demoId']; + } + } diff --git a/ext/authx/xml/Menu/authx.xml b/ext/authx/xml/Menu/authx.xml index c0b893aedd..1f0e738339 100644 --- a/ext/authx/xml/Menu/authx.xml +++ b/ext/authx/xml/Menu/authx.xml @@ -2,8 +2,23 @@ civicrm/authx/id - CRM_Authx_Page_Id + CRM_Authx_Page_AJAX::getId Id *always allow* + true + + + civicrm/authx/login + CRM_Authx_Page_AJAX::login + Id + *always allow* + true + + + civicrm/authx/logout + CRM_Authx_Page_AJAX::logout + Id + *always allow* + true -- 2.25.1