Skip to content

Commit

Permalink
standaloneusers - split User.PasswordResetEmail into public and priva…
Browse files Browse the repository at this point in the history
…te actions
  • Loading branch information
ufundo committed Dec 3, 2024
1 parent f4c6271 commit 56e3578
Show file tree
Hide file tree
Showing 10 changed files with 234 additions and 187 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
use CRM_Standaloneusers_ExtensionUtil as E;

use Civi\Standalone\Security;
use Civi\Api4\Action\User\PasswordReset;

/**
* Provide the send password reset / reset password page.
Expand All @@ -23,7 +23,7 @@ public function run() {
// If we have a password reset token, validate it without 'spending' it.
$token = CRM_Utils_Request::retrieveValue('token', 'String', NULL, FALSE, $method = 'GET');
if ($token) {
if (!Security::singleton()->checkPasswordResetToken($token, FALSE)) {
if (!PasswordReset::checkPasswordResetToken($token, FALSE)) {
$token = 'invalid';
}
$this->assign('token', $token);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,35 +50,24 @@ class CRM_Standaloneusers_WorkflowMessage_PasswordReset extends GenericWorkflowM
public $usernameHtml;

/**
* @var array
* Load the required tplParams
*/
protected $logParams;

/**
* Generate/regenerate a token for the user and load the tplParams
*/
public function setDataFromUser(array $user, string $token) {
public function setRequiredParams(
string $username,
string $email,
int $contactId,
string $token
) {
$resetUrlPlaintext = \CRM_Utils_System::url('civicrm/login/password', ['token' => $token], TRUE, NULL, FALSE);
$resetUrlHtml = htmlspecialchars($resetUrlPlaintext);
$this->logParams = [
'userID' => $user['id'],
'contactID' => $user['contact_id'],
'username' => $user['username'],
'email' => $user['uf_name'],
'url' => $resetUrlPlaintext,
];

$this
->setResetUrlPlaintext($resetUrlPlaintext)
->setResetUrlHtml($resetUrlHtml)
->setUsernamePlaintext($user['username'])
->setUsernameHtml(htmlspecialchars($user['username']))
->setTo(['name' => $user['username'], 'email' => $user['uf_name']])
->setContactID($user['contact_id']);
->setResetUrlHtml(htmlspecialchars($resetUrlPlaintext))
->setUsernamePlaintext($username)
->setUsernameHtml(htmlspecialchars($username))
->setTo(['name' => $username, 'email' => $email])
->setContactID($contactId);
return $this;
}

public function getParamsForLog(): array {
return $this->logParams;
}

}
96 changes: 92 additions & 4 deletions ext/standaloneusers/Civi/Api4/Action/User/PasswordReset.php
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
<?php
namespace Civi\Api4\Action\User;

use Civi\Crypto\Exception\CryptoException;
use Civi\Api4\Generic\Result;
use Civi\Standalone\Security;
use API_Exception;
use Civi;
use Civi\Api4\User;
use Civi\Api4\Generic\AbstractAction;

/**
* This is designed to be a public API
*
* @method static setIdentifier(string $identifier)
*/
class PasswordReset extends AbstractAction {

/**
* Scope identifier for password reset JWTs
*/
const PASSWORD_RESET_SCOPE = 'pw_reset';

/**
* Password reset token.
*
Expand All @@ -39,7 +43,7 @@ public function _run(Result $result) {
// todo: some minimum password quality check?
// Only valid change here is password, for a known ID.
$userID = Security::singleton()->checkPasswordResetToken($this->token);
$userID = self::checkPasswordResetToken($this->token);
if (!$userID) {
throw new API_Exception("Invalid token.");
}
Expand All @@ -53,4 +57,88 @@ public function _run(Result $result) {
\Civi::log()->info("Changed password for user {userID} via User.PasswordReset", compact('userID'));
}

/**
* Generate and store a token on the User record.
* @internal (only public for use in SecurityTest)
*
* @param int $userID
* @param int $minutes the number of minutes the token should be valid for
*
* @return string
* The token
*/
public static function updateToken(int $userID, int $minutes = 60): string {
// Generate a JWT that expires in 1 hour.
// We'll store this on the User record, that way invalidating any previous token that may have been generated.
$expires = time() + 60 * $minutes;
$token = \Civi::service('crypto.jwt')->encode([
'exp' => $expires,
'sub' => "uid:$userID",
'scope' => self::PASSWORD_RESET_SCOPE,
]);
User::update(FALSE)
->addValue('password_reset_token', $token)
->addWhere('id', '=', $userID)
->execute();

return $token;
}

/**
* Check a password reset token matches for a User.
*
* @param string $token
* @param bool $spend
* If TRUE, and the token matches, the token is then reset; so it can only be used once.
* If FALSE no changes are made.
*
* @return NULL|int
* If int, it's the UserID
*
*/
public static function checkPasswordResetToken(string $token, bool $spend = TRUE): ?int {
try {
$decodedToken = \Civi::service('crypto.jwt')->decode($token);
}
catch (CryptoException $e) {
Civi::log()->warning('Exception while decoding JWT', ['exception' => $e]);
return NULL;
}

$scope = $decodedToken['scope'] ?? '';
if ($scope != self::PASSWORD_RESET_SCOPE) {
Civi::log()->warning('Expected JWT password reset, got ' . $scope);
return NULL;
}

if (empty($decodedToken['sub']) || substr($decodedToken['sub'], 0, 4) !== 'uid:') {
Civi::log()->warning('Missing uid in JWT sub field');
return NULL;
}
else {
$userID = substr($decodedToken['sub'], 4);
}
if (!$userID > 0) {
// Hacker
Civi::log()->warning("Rejected passwordResetToken with invalid userID.", compact('token', 'userID'));
return NULL;
}

$matched = User::get(FALSE)
->addWhere('id', '=', $userID)
->addWhere('password_reset_token', '=', $token)
->addWhere('is_active', '=', 1)
->selectRowCount()
->execute()->countMatched() === 1;

if ($matched && $spend) {
$matched = User::update(FALSE)
->addWhere('id', '=', $userID)
->addValue('password_reset_token', NULL)
->execute();
}
Civi::log()->info(($matched ? 'Accepted' : 'Rejected') . " passwordResetToken for user $userID");
return $matched ? $userID : NULL;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@
// 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\Standalone\Security;
use Civi\Api4\Generic\AbstractAction;

/**
Expand All @@ -22,7 +20,7 @@
*
* @method static setIdentifier(string $identifier)
*/
class SendPasswordReset extends AbstractAction {
class RequestPasswordResetEmail extends AbstractAction {

/**
* Username or email of user to send email for.
Expand Down Expand Up @@ -66,23 +64,11 @@ public function _run(Result $result) {
}

if ($userID) {
// (Re)generate token and store on User.
$token = static::updateToken($userID);

$workflowMessage = Security::singleton()->preparePasswordResetWorkflow($user, $token);
if ($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]);
}
}
// we've got through all the guards - now use the
// internal API action to actually send the email
User::sendPasswordResetEmail(FALSE)
->addWhere('id', '=', $userID)
->execute();
}

// Ensure we took at least 0.25s. The assumption is that it takes
Expand All @@ -93,29 +79,4 @@ public function _run(Result $result) {
usleep(1000000 * max(0, $endNoSoonerThan - microtime(TRUE)));
}

/**
* Generate and store a token on the User record.
*
* @param int $userID
*
* @return string
* The token
*/
public static function updateToken(int $userID): string {
// Generate a JWT that expires in 1 hour.
// We'll store this on the User record, that way invalidating any previous token that may have been generated.
$expires = time() + 60 * 60;
$token = \Civi::service('crypto.jwt')->encode([
'exp' => $expires,
'sub' => "uid:$userID",
'scope' => Security::PASSWORD_RESET_SCOPE,
]);
User::update(FALSE)
->addValue('password_reset_token', $token)
->addWhere('id', '=', $userID)
->execute();

return $token;
}

}
106 changes: 106 additions & 0 deletions ext/standaloneusers/Civi/Api4/Action/User/SendPasswordResetEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php
namespace Civi\Api4\Action\User;

// @todo
// 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\BasicBatchAction;
use Civi\Api4\MessageTemplate;
use CRM_Standaloneusers_WorkflowMessage_PasswordReset;

/**
* @class API_Exception
*/

/**
* This is designed to be an internal API
*
* @method static setIdentifier(string $identifier)
* @method static setTimeout(int $minutes)
*/
class SendPasswordResetEmail extends BasicBatchAction {

/**
* Timeout for the reset token in minutes
*
* @var int
*/
protected $timeout = 60;

/**
* @inheritdoc
*
* Data we need from the User record
*/
protected function getSelect() {

return ['id', 'username', 'uf_name', 'contact_id'];
}

public function doTask($user) {
// (Re)generate token and store on User.
$token = PasswordReset::updateToken($user['id'], $this->timeout);

$workflowMessage = self::preparePasswordResetWorkflow($user, $token);
if ($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 {$user['id']} ({$user['username']}) to {$user['uf_name']}");
}
catch (\Exception $e) {
Civi::log()->error("Failed to send password reset to user {$user['id']} ({$user['username']}) to {$user['uf_name']}");
}
}
}

/**
* Prepare a password reset workflow email for a user
*
* Includes some checks that we have all the necessary pieces
* in place
*
* @internal (only public for use in SecurityTest)
*
* @return \CRM_Standaloneusers_WorkflowMessage_PasswordReset|null
*/
public static function preparePasswordResetWorkflow(array $user, string $token): ?CRM_Standaloneusers_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;
}

// get the required params from the user record
$username = $user['username'];
$email = $user['uf_name'];
$contactId = $user['contact_id'];

// The template_params are used in the template like {$resetUrlHtml} and {$resetUrlHtml} {$usernamePlaintext} {$usernameHtml}
[$domainFromName, $domainFromEmail] = \CRM_Core_BAO_Domain::getNameAndEmail(TRUE);
$workflowMessage = (new CRM_Standaloneusers_WorkflowMessage_PasswordReset())
->setRequiredParams($username, $email, $contactId, $token)
->setFrom("\"$domainFromName\" <$domainFromEmail>");

return $workflowMessage;
}

}
2 changes: 1 addition & 1 deletion ext/standaloneusers/Civi/Api4/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public static function permissions() {
'create' => ['cms:administer users'],
'delete' => ['cms:administer users'],
'passwordReset' => ['access password resets'],
'sendPasswordReset' => ['access password resets'],
'requestPasswordResetEmail' => ['access password resets'],
'login' => ['access password resets'],
];
}
Expand Down
Loading

0 comments on commit 56e3578

Please sign in to comment.