standalone: password reset work
authorRich Lott / Artful Robot <code.commits@artfulrobot.uk>
Mon, 2 Oct 2023 10:48:27 +0000 (11:48 +0100)
committerRich Lott / Artful Robot <code.commits@artfulrobot.uk>
Mon, 2 Oct 2023 10:48:27 +0000 (11:48 +0100)
ext/standaloneusers/CRM/Standaloneusers/Page/ResetPassword.php
ext/standaloneusers/CRM/Standaloneusers/Upgrader.php
ext/standaloneusers/CRM/Standaloneusers/WorkflowMessage/PasswordReset.php [new file with mode: 0644]
ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php
ext/standaloneusers/Civi/Standalone/Security.php
ext/standaloneusers/ang/crmResetPassword.js
ext/standaloneusers/tests/phpunit/Civi/Standalone/SecurityTest.php
setup/plugins/init/StandaloneUsers.civi-setup.php

index 36c32d9e6f9cd5d4f187d23709d250c9011c9da5..1f18b66c394964b02adfc3838449f094660eafbd 100644 (file)
@@ -18,7 +18,7 @@ class CRM_Standaloneusers_Page_ResetPassword extends CRM_Core_Page {
     Civi::service('angularjs.loader')->addModules('crmResetPassword');
 
     // If we have a password reset token, validate it without 'spending' it.
-    $token = CRM_Utils_Request::retrieveValue('token', 'String',NULL, FALSE, $method = 'GET');
+    $token = CRM_Utils_Request::retrieveValue('token', 'String', NULL, FALSE, $method = 'GET');
     if ($token) {
       if (!Security::singleton()->checkPasswordResetToken($token, FALSE)) {
         $token = 'invalid';
index ecde5dc978065c0408b728426bf2e2b811a6c55c..f867c7c6f4eff6e9308a36d135409577501f3573 100644 (file)
@@ -44,21 +44,55 @@ class CRM_Standaloneusers_Upgrader extends CRM_Extension_Upgrader_Base {
    */
   public function postInstall() {
 
+    // Ensure users can login with username/password via authx.
     Civi::settings()->set('authx_login_cred', array_unique(array_merge(
       Civi::settings()->get('authx_login_cred'),
       ['pass']
     )));
 
-    $users = \Civi\Api4\User::get(FALSE)->selectRowCount()->execute()->countMatched();
-    if ($users == 0) {
-      CRM_Core_DAO::executeQuery('DELETE FROM civicrm_uf_match');
-    }
+    // $this->createPasswordResetMessageTemplate();
 
     // `standaloneusers` is installed as part of the overall install process for `Standalone`.
     // A subsequent step will configure some default users (*depending on local options*).
     // See also: `StandaloneUsers.civi-setup.php`
   }
 
+  protected function createPasswordResetMessageTemplate() {
+
+    $baseTpl = [
+      'workflow_name' => 'password_reset',
+      'msg_title' => 'Password reset',
+      'msg_subject' => 'Password reset link for {domain.name}',
+      'msg_text' => <<<TXT
+        A password reset link was requested for this account.  If this wasn\'t you (and nobody else can access this email account) you can safely ignore this email.
+
+        {\$resetUrlPlaintext}
+
+        {domain.name}
+        TXT,
+      'msg_html' => <<<HTML
+        <p>A password reset link was requested for this account.&nbsp; If this wasn\'t you (and nobody else can access this email account) you can safely ignore this email.</p>
+
+        <p><a href="{\$resetUrlHtml}">{\$resetUrlHtml}</a></p>
+
+        <p>{domain.name}</p>
+        HTML,
+    ];
+
+    // Create a "reserved" template. This is a pristine copy provided for reference.
+    civicrm_api4('MessageTemplate', 'create',
+      [
+        'values' => $baseTpl + ['is_reserved' => 1, 'is_default' => 0],
+      ]);
+
+    // Create a default template. This is live. The administrator may edit/customize.
+    civicrm_api4('MessageTemplate', 'create',
+      [
+        'values' => $baseTpl + ['is_reserved' => 0, 'is_default' => 1],
+      ]);
+
+  }
+
   /**
    * Example: Run an external SQL script when the module is uninstalled.
    */
diff --git a/ext/standaloneusers/CRM/Standaloneusers/WorkflowMessage/PasswordReset.php b/ext/standaloneusers/CRM/Standaloneusers/WorkflowMessage/PasswordReset.php
new file mode 100644 (file)
index 0000000..be625bc
--- /dev/null
@@ -0,0 +1,83 @@
+<?php
+use Civi\WorkflowMessage\GenericWorkflowMessage;
+
+/**
+ *
+ * @method static setResetUrlPlaintext(string $s)
+ * @method static setResetUrlHtml(string $s)
+ * @method static setUsernamePlaintext(string $s)
+ * @method static setUsernameHtml(string $s)
+ *
+ */
+class CRM_Standalone_WorkflowMessage_PasswordReset extends GenericWorkflowMessage {
+  public const WORKFLOW = 'password_reset';
+
+  /**
+   * Plaintext full URL to user's password reset.
+   *
+   * @var string
+   *
+   * @scope tplParams
+   */
+  public $resetUrlPlaintext;
+
+  /**
+   * HTML full URL to user's password reset.
+   *
+   * @var string
+   *
+   * @scope tplParams
+   */
+  public $resetUrlHtml;
+
+  /**
+   * Plaintext username.
+   *
+   * @var string
+   *
+   * @scope tplParams
+   */
+  public $usernamePlaintext;
+
+  /**
+   * HTML username.
+   *
+   * @var string
+   *
+   * @scope tplParams
+   */
+  public $usernameHtml;
+
+  /**
+   * @var array
+   */
+  protected $logParams;
+
+  /**
+   * Generate/regenerate a token for the user and load the tplParams
+   */
+  public function setDataFromUser(array $user, string $token) {
+    // xxx this is not generating https://blah - just the path. Why?
+    $resetUrlPlaintext = \CRM_Utils_System::url('civicrm/login/password', ['token' => $token], TRUE, NULL, FALSE);
+    $resetUrlHtml = htmlspecialchars($resetUrlPlaintext);
+    $usernamePlaintext = $user['name'];
+    $usernameHtml = htmlspecialchars($user['name']);
+    $this->logParams = [
+      'userID' => $user['id'],
+      'username' => $usernamePlaintext,
+      'email' => $user['uf_name'],
+    ];
+    $this
+      ->setResetUrlPlaintext($resetUrlPlaintext)
+      ->setResetUrlHtml($resetUrlHtml)
+      ->setUsernamePlaintext($usernamePlaintext)
+      ->setUsernameHtml($usernameHtml)
+      ->setTo($user['uf_name']);
+    return $this;
+  }
+
+  public function getParamsForLog(): array {
+    return $this->logParams;
+  }
+
+}
index db1db3584d0a6293b1470d54d2ac9f3b7309d0d3..73e89dc15518b731d8e570fa8daa915fb11fdd4e 100644 (file)
@@ -5,15 +5,18 @@ namespace Civi\Api4\Action\User;
 // URL is (a) just theh path in the emails.
 // clicking button on form with proper token does nothing.
 // should redirect to login on success
-//
 
 use Civi;
 use Civi\Api4\Generic\Result;
 use API_Exception;
 use Civi\Api4\User;
-use Civi\Api4\MessageTemplate;
+use Civi\Standalone\Security;
 use Civi\Api4\Generic\AbstractAction;
 
+/**
+ * @class API_Exception
+ */
+
 /**
  * This is designed to be a public API
  *
@@ -50,11 +53,10 @@ class SendPasswordReset extends AbstractAction {
     $userID = $user['id'] ?? 0;
 
     try {
-      // Allow flood control.
-      $ip = \CRM_Utils_System::ipAddress();
+      // Allow flood control by extensions. (e.g. Moat).
       $event = \Civi\Core\Event\GenericHookEvent::create([
-        'identifiers' => ["ip:$ip", "user:$userID"],
         'action'      => 'send_password_reset',
+        'identifiers' => ["user:$userID"],
       ]);
       \Civi::dispatcher()->dispatch('civi.flood.drip', $event);
     }
@@ -64,7 +66,24 @@ class SendPasswordReset extends AbstractAction {
     }
 
     if ($userID) {
-      $this->sendResetEmail($user);
+      // (Re)generate token and store on User.
+      $token = static::updateToken($userID);
+
+      $workflowMessage = Security::singleton()->preparePasswordResetWorkflow($user, $token);
+      if ($workflowMessage) {
+        /** @var \CRM_Standalone_WorkflowMessage_PasswordReset $workflowMessage */
+        // The template_params are used in the template like {$resetUrlHtml} and {$resetUrlHtml} {$usernamePlaintext} {$usernameHtml}
+        try {
+          [$sent, /*$subject, $text, $html*/] = $workflowMessage->sendTemplate();
+          if (!$sent) {
+            throw new \RuntimeException("sendTemplate() returned unsent.");
+          }
+          Civi::log()->info("Successfully sent password reset to user {userID} ({username}) to {email}", $workflowMessage->getParamsForLog());
+        }
+        catch (\Exception $e) {
+          Civi::log()->error("Failed to send password reset to user {userID} ({username}) to {email}", $workflowMessage->getParamsForLog() + ['exception' => $e]);
+        }
+      }
     }
 
     // Ensure we took at least 0.25s. The assumption is that it takes
@@ -75,50 +94,6 @@ class SendPasswordReset extends AbstractAction {
     usleep(1000000 * max(0, $endNoSoonerThan - microtime(TRUE)));
   }
 
-  protected function sendResetEmail(array $user) {
-    // Find the message template
-    $tplID = MessageTemplate::get(FALSE)
-      ->setSelect(['id'])
-      ->addWhere('workflow_name', '=', 'password_reset')
-      ->addWhere('is_default', '=', TRUE)
-      ->addWhere('is_active', '=', TRUE)
-      ->execute()->first()['id'];
-    if (!$tplID) {
-      // Some sites may deliberately disable this, but it's unusual, so leave a notice in the log.
-      Civi::log()->notice("There is no active, default password_reset message template, which has prevented emailing a reset to {username}", ['username' => $user['username']]);
-      return;
-    }
-    if (!filter_var($user['uf_name'] ?? '', FILTER_VALIDATE_EMAIL)) {
-      Civi::log()->warning("User $user[id] has an invalid email. Failed to send password reset.");
-      return;
-    }
-
-    $token = static::updateToken($user['id']);
-
-    list($domainFromName, $domainFromEmail) = \CRM_Core_BAO_Domain::getNameAndEmail(TRUE);
-    // xxx this is not generating https://blah - just the path. Why?
-    $resetUrlPlaintext = \CRM_Utils_System::url('civicrm/login/password', ['token' => $token], TRUE, NULL, FALSE);
-    $resetUrlHtml = htmlspecialchars($resetUrlPlaintext);
-    // The template_params are used in the template like {$resetUrlHtml} and {$resetUrlHtml}
-    $params = [
-      'id' => $tplID,
-      'template_params' => compact('resetUrlPlaintext', 'resetUrlHtml'),
-      'from' => "\"$domainFromName\" <$domainFromEmail>",
-      'to_email' => $user['uf_name'],
-      'disable_smarty' => 1,
-    ];
-
-    try {
-      civicrm_api3('MessageTemplate', 'send', $params);
-      Civi::log()->info("Sent password_reset_token MessageTemplate (ID {tplID}) to {to_email} for user {userID}",
-        $params + ['userID' => $user['id']]);
-    }
-    catch (\Exception $e) {
-      Civi::log()->error("Failed to send password_reset_token MessageTemplate (ID {tplID}) to {to_email} for user {userID}",
-        $params + ['userID' => $user['id'], 'exception' => $e]);
-    }
-  }
-
   /**
    * Generate and store a token on the User record.
    *
index 9fdb3a4ef00ae03e5b5cf3b0567b22d234a1794b..88009190267bc8db8ade3d8572173a7f221fd2a0 100644 (file)
@@ -4,6 +4,8 @@ namespace Civi\Standalone;
 use CRM_Core_Session;
 use Civi;
 use Civi\Api4\User;
+use Civi\Api4\MessageTemplate;
+use CRM_Standalone_WorkflowMessage_PasswordReset;
 
 /**
  * This is a single home for security related functions for Civi Standalone.
@@ -417,4 +419,37 @@ class Security {
     return $matched ? $userID : NULL;
   }
 
+  /**
+   * Prepare a password reset workflow email, if configured.
+   *
+   * @return \CRM_Standalone_WorkflowMessage_PasswordReset|null
+   */
+  public function preparePasswordResetWorkflow(array $user, string $token): ?CRM_Standalone_WorkflowMessage_PasswordReset {
+    // Find the message template
+    $tplID = MessageTemplate::get(FALSE)
+      ->setSelect(['id'])
+      ->addWhere('workflow_name', '=', 'password_reset')
+      ->addWhere('is_default', '=', TRUE)
+      ->addWhere('is_reserved', '=', FALSE)
+      ->addWhere('is_active', '=', TRUE)
+      ->execute()->first()['id'];
+    if (!$tplID) {
+      // Some sites may deliberately disable this, but it's unusual, so leave a notice in the log.
+      Civi::log()->notice("There is no active, default password_reset message template, which has prevented emailing a reset to {username}", ['username' => $user['username']]);
+      return NULL;
+    }
+    if (!filter_var($user['uf_name'] ?? '', \FILTER_VALIDATE_EMAIL)) {
+      Civi::log()->warning("User $user[id] has an invalid email. Failed to send password reset.");
+      return NULL;
+    }
+
+    // The template_params are used in the template like {$resetUrlHtml} and {$resetUrlHtml} {$usernamePlaintext} {$usernameHtml}
+    list($domainFromName, $domainFromEmail) = \CRM_Core_BAO_Domain::getNameAndEmail(TRUE);
+    $workflowMessage = (new \CRM_Standalone_WorkflowMessage_PasswordReset())
+      ->setDataFromUser($user, $token)
+      ->setFrom("\"$domainFromName\" <$domainFromEmail>");
+
+    return $workflowMessage;
+  }
+
 }
index 5a4108c19f6074004b1960a606e3e583ac33fd63..d86a6b1902f46d48acbbfa07d1eba2610fc387df 100644 (file)
             .then(r => {
               updateAngular('busy', ts('Password successfully updated. Redirecting to login...'));
               $timeout(() => {
-                window.location = '/civicrm/login'
-                ctrl[prop] = newVal;
+                window.location = '/civicrm/login';
               }, 1300);
             })
             .catch(e => {
index 025bb93b8a211c5aa9d78cf51ced168d1d793aa5..c1e2fe972b7a344d315c7f70d6df49093d7d9505 100644 (file)
@@ -414,6 +414,16 @@ class SecurityTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     catch (\Exception $e) {
       $this->assertEquals('Invalid token.', $e->getMessage());
     }
+
+    // Check the message template generation
+    $token = \Civi\Api4\Action\User\SendPasswordReset::updateToken($userID);
+    /** @var \CRM_Standalone_WorkflowMessage_PasswordReset */
+    $workflow = $security->preparePasswordResetWorkflow($user, $token);
+    $result = $workflow->renderTemplate();
+
+    $this->assertStringContainsString($token, $result['text']);
+    $this->assertStringContainsString(htmlspecialchars($token), $result['html']);
+    $this->assertEquals('x', $result['subject']);
   }
 
   protected function deleteStuffWeMade() {
index b9a71da9e31409648faee5a09edc2d454b1b6d6b..b756ef77c8613888f4b5867e522607f96a52f4bf 100644 (file)
@@ -55,7 +55,7 @@ if (!defined('CIVI_SETUP')) {
             'view event info',
             'register for events',
             'access password resets',
-            'authenticate with password', //xxx?
+            'authenticate with password',
           ],
         ],
         [