dev/core#2141 - Allow OAuth2 services to define mail setup routine
authorTim Otten <totten@civicrm.org>
Fri, 30 Oct 2020 08:39:58 +0000 (01:39 -0700)
committerTim Otten <totten@civicrm.org>
Tue, 3 Nov 2020 12:32:48 +0000 (04:32 -0800)
Overview
--------

For certain types of mail accounts -- such as Google Mail and Microsoft
Exchange Online -- the setup process may require interaction with a remote
web-service.

If you have OAuth2 enabled for one of these services, then this will create
an option for "Add Mail Account".

Before
------

There is no setup procedure.

After
-----

* Navigate to "Administer => CiviMail => Mail Accounts".
* Below the table, there is a select2 box for "Add Mail Account".
* If "Microsoft Exchange Online" is configured, then it will appear in the dropdown. Choose it.
* It redirects to MS to get authorization from the user (OAuth2 Authorization Code).
* The user comes back.
* We initialize a new mail account (`MailSettings` / `civicrm_mail_settings`)
* We accept the code and save the token (`OAuthSysToken`) with the account.
* We redirect to the account configuration form.

Technical Details
-----------------

The new mail account will have some details, such as `server`, `protocol`,
and `username` pre-filled.  This uses a template -- see e.g.
`providers/ms-exchange.dist.json` (`mailSettingsTemplate`).

ext/oauth-client/CRM/OAuth/MailSetup.php [new file with mode: 0644]
ext/oauth-client/oauth_client.php
ext/oauth-client/tests/phpunit/CRM/OAuth/MailSetupTest.php [new file with mode: 0644]

diff --git a/ext/oauth-client/CRM/OAuth/MailSetup.php b/ext/oauth-client/CRM/OAuth/MailSetup.php
new file mode 100644 (file)
index 0000000..7f144f1
--- /dev/null
@@ -0,0 +1,144 @@
+<?php
+
+class CRM_OAuth_MailSetup {
+
+  /**
+   * Return a list of setup-options based on OAuth2 services.
+   *
+   * @see CRM_Utils_Hook::mailSetupActions()
+   */
+  public static function buildSetupLinks() {
+    $clients = Civi\Api4\OAuthClient::get(0)->addWhere('is_active', '=', 1)->execute();
+    $providers = Civi\Api4\OAuthProvider::get(0)->execute()->indexBy('name');
+
+    $setupActions = [];
+    foreach ($clients as $client) {
+      $provider = $providers[$client['provider']] ?? NULL;
+      if ($provider === NULL) {
+        continue;
+      }
+      // v api OptionValue.get option_group_id=mail_protocol
+      if (!empty($provider['mailSettingsTemplate'])) {
+        $setupActions['oauth_' . $client['id']] = [
+          'title' => sprintf('%s (ID #%s)', $provider['title'] ?? $provider['name'] ?? ts('OAuth2'), $client['id']),
+          'callback' => ['CRM_OAuth_MailSetup', 'setup'],
+          'oauth_client_id' => $client['id'],
+        ];
+      }
+    }
+
+    return $setupActions;
+  }
+
+  /**
+   * When a user chooses to add one of our mail options, we kick off
+   * the authorization-code workflow.
+   *
+   * @param array $setupAction
+   *   The chosen descriptor from mailSetupActions.
+   * @return array
+   *   With keys:
+   *   - url: string, the final URL to go to.
+   * @see CRM_Utils_Hook::mailSetupActions()
+   */
+  public static function setup($setupAction) {
+    $authCode = Civi\Api4\OAuthClient::authorizationCode(0)
+      ->addWhere('id', '=', $setupAction['oauth_client_id'])
+      ->setStorage('OAuthSysToken')
+      ->setTag('MailSettings:setup')
+      ->execute()
+      ->single();
+
+    return [
+      'url' => $authCode['url'],
+    ];
+  }
+
+  /**
+   * When the user returns with a token, we add a new record to
+   * civicrm_mail_settings with defaults and redirect to the edit screen.
+   *
+   * @param array $token
+   *   OAuthSysToken
+   * @param string $nextUrl
+   */
+  public static function onReturn($token, &$nextUrl) {
+    if ($token['tag'] !== 'MailSettings:setup') {
+      return;
+    }
+
+    $client = \Civi\Api4\OAuthClient::get(0)->addWhere('id', '=', $token['client_id'])->execute()->single();
+    $provider = \Civi\Api4\OAuthProvider::get(0)->addWhere('name', '=', $client['provider'])->execute()->single();
+
+    $vars = ['token' => $token, 'client' => $client, 'provider' => $provider];
+    $mailSettings = civicrm_api4('MailSettings', 'create', [
+      'values' => self::evalArrayTemplate($provider['mailSettingsTemplate'], $vars),
+    ])->single();
+
+    \Civi\Api4\OAuthSysToken::update(0)
+      ->addWhere('id', '=', $token['id'])
+      ->setValues(['tag' => 'MailSettings:' . $mailSettings['id']])
+      ->execute();
+
+    CRM_Core_Session::setStatus(
+      ts('Here are the account defaults we detected for %1. Please check them carefully.', [
+        1 => $mailSettings['name'],
+      ]),
+      ts('Account created!'),
+      'info'
+    );
+
+    $nextUrl = CRM_Utils_System::url('civicrm/admin/mailSettings', [
+      'action' => 'update',
+      'id' => $mailSettings['id'],
+      'reset' => 1,
+    ], TRUE, NULL, FALSE);
+  }
+
+  /**
+   * @param array $template
+   *   List of key-value expressions.
+   *   Ex: ['name' => '{{person.first}} {{person.last}}']
+   *   Expressions begin with the dotted-name of a variable.
+   *   Optionally, the value may be piped through other functions
+   * @param array $vars
+   *   Array tree of data to interpolate.
+   * @return array
+   *   The template array, with '{{...}}' expressions evaluated.
+   */
+  public static function evalArrayTemplate($template, $vars) {
+    $filters = [
+      'getMailDomain' => function($v) {
+        $parts = explode('@', $v);
+        return $parts[1] ?? NULL;
+      },
+      'getMailUser' => function($v) {
+        $parts = explode('@', $v);
+        return $parts[0] ?? NULL;
+      },
+    ];
+
+    $lookupVars = function($m) use ($vars, $filters) {
+      $parts = explode('|', $m[1]);
+      $value = (string) CRM_Utils_Array::pathGet($vars, explode('.', array_shift($parts)));
+      foreach ($parts as $part) {
+        if (isset($filters[$part])) {
+          $value = $filters[$part]($value);
+        }
+        else {
+          $value = NULL;
+        }
+      }
+      return $value;
+    };
+
+    $values = [];
+    foreach ($template as $key => $value) {
+      $values[$key] = is_string($value)
+        ? preg_replace_callback(';{{([a-zA-Z0-9_\.\|]+)}};', $lookupVars, $value)
+        : $value;
+    }
+    return $values;
+  }
+
+}
index cc24a4a419c3b2f2000a4137e53dd7c26b9918ee..684187ab573a558c1e1675ba1109921f421a52be 100644 (file)
@@ -223,3 +223,19 @@ function oauth_client_civicrm_oauthProviders(&$providers) {
     $ingest($localDir . '/*.json');
   }
 }
+
+/**
+ * Implements hook_civicrm_mailSetupActions().
+ *
+ * @see CRM_Utils_Hook::mailSetupActions()
+ */
+function oauth_client_civicrm_mailSetupActions(&$setupActions) {
+  $setupActions = array_merge($setupActions, CRM_OAuth_MailSetup::buildSetupLinks());
+}
+
+/**
+ * Implements hook_civicrm_oauthReturn().
+ */
+function oauth_client_civicrm_oauthReturn($token, &$nextUrl) {
+  CRM_OAuth_MailSetup::onReturn($token, $nextUrl);
+}
diff --git a/ext/oauth-client/tests/phpunit/CRM/OAuth/MailSetupTest.php b/ext/oauth-client/tests/phpunit/CRM/OAuth/MailSetupTest.php
new file mode 100644 (file)
index 0000000..06ee4b8
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+use CRM_OAuth_ExtensionUtil as E;
+use Civi\Test\HeadlessInterface;
+use Civi\Test\HookInterface;
+use Civi\Test\TransactionalInterface;
+
+/**
+ * Test helper functions in CRM_OAuth_MailSetup.
+ *
+ * @group headless
+ */
+class CRM_OAuth_MailSetupTest extends \PHPUnit\Framework\TestCase implements HeadlessInterface, HookInterface, TransactionalInterface {
+
+  public function setUpHeadless() {
+    // Civi\Test has many helpers, like install(), uninstall(), sql(), and sqlFile().
+    // See: https://docs.civicrm.org/dev/en/latest/testing/phpunit/#civitest
+    return \Civi\Test::headless()->install('oauth-client')->apply();
+  }
+
+  public function setUp() {
+    parent::setUp();
+  }
+
+  public function tearDown() {
+    parent::tearDown();
+  }
+
+  public function testEvalArrayTemplate() {
+    $vars = array(
+      'token' => [
+        'client_id' => 10,
+        'resource_owner' => ['mail' => 'foo@bar.com'],
+      ],
+      'client' => [
+        'id' => 1,
+        'provider' => 'ms-exchange',
+        'guid' => 'abcd-1234-efgh-5678',
+        'secret' => '8765-hgfe-4321-dcba',
+        'options' => NULL,
+        'is_active' => TRUE,
+        'created_date' => '2020-10-29 10:11:12',
+        'modified_date' => '2020-10-29 10:11:12',
+      ],
+      'provider' => [
+        'name' => 'foozball',
+        'title' => 'Foozball Association',
+        'options' => [
+          'urlAuthorize' => 'https://login.example.com/common/oauth2/v2.0/authorize',
+          'urlAccessToken' => 'https://login.example.com/common/oauth2/v2.0/token',
+          'urlResourceOwnerDetails' => 'https://resource.example.com/v9.0/me',
+          'scopeSeparator' => ' ',
+          'scopes' => [],
+        ],
+        'mailSettingsTemplate' => [
+          'name' => '{{provider.title}}: {{token.resource_owner.mail}}',
+          'domain' => '{{token.resource_owner.mail|getMailDomain}}',
+          'localpart' => NULL,
+          'return_path' => NULL,
+          'protocol:name' => 'IMAP',
+          'server' => 'imap.foozball.com',
+          'username' => '{{token.resource_owner.mail}}',
+          'password' => NULL,
+          'is_ssl' => TRUE,
+        ],
+        'class' => 'Civi\\OAuth\\CiviGenericProvider',
+      ],
+    );
+    $expected = [
+      'name' => 'Foozball Association: foo@bar.com',
+      'domain' => 'bar.com',
+      'localpart' => NULL,
+      'return_path' => NULL,
+      'protocol:name' => 'IMAP',
+      'server' => 'imap.foozball.com',
+      'username' => 'foo@bar.com',
+      'password' => '',
+      'is_ssl' => TRUE,
+    ];
+    $actual = \CRM_OAuth_MailSetup::evalArrayTemplate($vars['provider']['mailSettingsTemplate'], $vars);
+    $this->assertEquals($expected, $actual);
+    $this->assertTrue($actual['localpart'] === NULL);
+  }
+
+}