From ae6188ceecbaf9e806f4c96e7534f2405be70a30 Mon Sep 17 00:00:00 2001
From: Rich Lott / Artful Robot
{domain.name}
+ 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 index 0000000000..be625bca3e --- /dev/null +++ b/ext/standaloneusers/CRM/Standaloneusers/WorkflowMessage/PasswordReset.php @@ -0,0 +1,83 @@ + $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; + } + +} diff --git a/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php b/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php index db1db3584d..73e89dc155 100644 --- a/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php +++ b/ext/standaloneusers/Civi/Api4/Action/User/SendPasswordReset.php @@ -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. * diff --git a/ext/standaloneusers/Civi/Standalone/Security.php b/ext/standaloneusers/Civi/Standalone/Security.php index 9fdb3a4ef0..8800919026 100644 --- a/ext/standaloneusers/Civi/Standalone/Security.php +++ b/ext/standaloneusers/Civi/Standalone/Security.php @@ -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; + } + } diff --git a/ext/standaloneusers/ang/crmResetPassword.js b/ext/standaloneusers/ang/crmResetPassword.js index 5a4108c19f..d86a6b1902 100644 --- a/ext/standaloneusers/ang/crmResetPassword.js +++ b/ext/standaloneusers/ang/crmResetPassword.js @@ -103,8 +103,7 @@ .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 => { diff --git a/ext/standaloneusers/tests/phpunit/Civi/Standalone/SecurityTest.php b/ext/standaloneusers/tests/phpunit/Civi/Standalone/SecurityTest.php index 025bb93b8a..c1e2fe972b 100644 --- a/ext/standaloneusers/tests/phpunit/Civi/Standalone/SecurityTest.php +++ b/ext/standaloneusers/tests/phpunit/Civi/Standalone/SecurityTest.php @@ -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() { diff --git a/setup/plugins/init/StandaloneUsers.civi-setup.php b/setup/plugins/init/StandaloneUsers.civi-setup.php index b9a71da9e3..b756ef77c8 100644 --- a/setup/plugins/init/StandaloneUsers.civi-setup.php +++ b/setup/plugins/init/StandaloneUsers.civi-setup.php @@ -55,7 +55,7 @@ if (!defined('CIVI_SETUP')) { 'view event info', 'register for events', 'access password resets', - 'authenticate with password', //xxx? + 'authenticate with password', ], ], [ -- 2.25.1