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';
*/
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. 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.
*/
--- /dev/null
+<?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;
+ }
+
+}
// 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
*
$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);
}
}
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
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.
*
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.
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;
+ }
+
}
.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 => {
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() {
'view event info',
'register for events',
'access password resets',
- 'authenticate with password', //xxx?
+ 'authenticate with password',
],
],
[