Skip to content

Reuse password reset tokens until they expire #25280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0c29150
Recycle valid password reset token
fredden Oct 24, 2019
bea17dc
Merge branch '2.4-develop' into password-reset/keep-valid-token
fredden Jan 22, 2020
5bc55a9
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Apr 26, 2020
b789e3a
Do not send password reset email if last is valid
fredden Apr 26, 2020
8827c0f
Merge branch '2.4-develop' into password-reset/keep-valid-token
fredden Sep 30, 2020
095751b
Fix out-of-scope code linter complaints
fredden Sep 30, 2020
8775749
Change method visibility per code review request
fredden Sep 30, 2020
b951375
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Oct 27, 2020
143a90e
Catch a more specific error
fredden Oct 27, 2020
083579e
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Oct 28, 2020
9de6b10
Add comment to justify catching LocalizedException
fredden Oct 28, 2020
64d35e8
Merge branch '2.4-develop' into password-reset/keep-valid-token
fredden Oct 31, 2020
ebbfe27
Enforce coding standard
fredden Oct 31, 2020
c68f090
Add tests to cover changes
fredden Oct 31, 2020
07535b4
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Oct 31, 2020
3f82563
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Nov 6, 2020
6e518ff
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Nov 7, 2020
0509521
Fix tests
fredden Nov 7, 2020
de5cde1
Fix tests
fredden Nov 7, 2020
d99672a
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Nov 13, 2020
36d6e40
Fix tests
fredden Nov 13, 2020
6be8612
Fix tests
fredden Nov 13, 2020
b2414fc
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Nov 16, 2020
f1a2b62
Fix tests
fredden Nov 16, 2020
fecd2ea
Reduce visibility of methods and properties
fredden Nov 19, 2020
1606814
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
fredden Nov 19, 2020
0928ccb
Merge remote-tracking branch 'upstream/2.4-develop' into password-res…
Nov 23, 2020
ff1996e
Rename mock variables/properties
Nov 23, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/code/Magento/Customer/Model/AccountManagement.php
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,13 @@ public function initiatePasswordReset($email, $template, $websiteId = null)
// load customer by email
$customer = $this->customerRepository->get($email, $websiteId);

if ($this->customerHasValidPasswordResetToken($customer)) {
// No need to send an email as the password reset token that has
// already been sent is still valid. No message is shown to avoid
// leaking information about existence of account.
return false;
}

// No need to validate customer address while saving customer reset password token
$this->disableAddressValidation($customer);

Expand Down Expand Up @@ -1583,6 +1590,28 @@ public function getPasswordHash($password)
return $this->encryptor->getHash($password, true);
}

/**
* Retrieve and validate existing password reset token
*
* @param CustomerInterface $customer
* @return bool
*/
private function customerHasValidPasswordResetToken(CustomerInterface $customer): bool
{
try {
$customerId = $customer->getId();
$customerSecureData = $this->customerRegistry->retrieveSecureData($customerId);
$token = $customerSecureData->getRpToken();
return $this->validateResetPasswordToken($customerId, $token);
} catch (NoSuchEntityException $exception) {
return false;
} catch (InputException $exception) {
return false;
} catch (ExpiredException $exception) {
return false;
}
}

/**
* Disable Customer Address Validation
*
Expand Down
125 changes: 65 additions & 60 deletions app/code/Magento/Customer/Test/Unit/Model/AccountManagementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ class AccountManagementTest extends TestCase
private $allowedCountriesReader;

/**
* @var SessionCleanerInterface|\PHPUnit_Framework_MockObject_MockObject
* @var SessionCleanerInterface|MockObject
*/
private $sessionCleanerMock;

Expand Down Expand Up @@ -256,7 +256,14 @@ protected function setUp(): void
->getMockForAbstractClass();

$this->customerSecure = $this->getMockBuilder(CustomerSecure::class)
->setMethods(['setRpToken', 'addData', 'setRpTokenCreatedAt', 'setData'])
->setMethods([
'addData',
'getRpToken',
'getRpTokenCreatedAt',
'setData',
'setRpToken',
'setRpTokenCreatedAt',
])
->disableOriginalConstructor()
->getMock();

Expand Down Expand Up @@ -1179,29 +1186,28 @@ public function testSendPasswordReminderEmail()
$this->assertEquals($this->accountManagement, $this->accountManagement->sendPasswordReminderEmail($customer));
}

/**
* @param string $email
* @param string $templateIdentifier
* @param string $sender
* @param int $storeId
* @param int $customerId
* @param string $hash
*/
protected function prepareInitiatePasswordReset($email, $templateIdentifier, $sender, $storeId, $customerId, $hash)
{
private function prepareInitiatePasswordReset(
string $email,
string $templateIdentifier,
string $sender,
int $storeId,
int $customerId,
string $hash,
int $callCount = 1
): void {
$websiteId = 1;
$addressId = 5;
$datetime = $this->prepareDateTimeFactory();
$customerData = ['key' => 'value'];
$customerName = 'Customer Name';

$this->store->expects($this->once())
$this->store->expects($this->exactly($callCount))
->method('getWebsiteId')
->willReturn($websiteId);
$this->store->expects($this->any())
->method('getId')
->willReturn($storeId);
$this->storeManager->expects($this->any())
$this->storeManager->expects($this->exactly($callCount))
->method('getStore')
->willReturn($this->store);

Expand Down Expand Up @@ -1232,17 +1238,14 @@ protected function prepareInitiatePasswordReset($email, $templateIdentifier, $se
$customer->expects($this->any())
->method('getAddresses')
->willReturn([$address]);
$this->customerRepository->expects($this->once())
->method('get')
->willReturn($customer);
$this->addressRegistryMock->expects($this->once())
->method('retrieve')
->with($addressId)
->willReturn($addressModel);
$addressModel->expects($this->once())
->method('setShouldIgnoreValidation')
->with(true);
$this->customerRepository->expects($this->once())
$this->customerRepository->expects($this->exactly($callCount))
->method('get')
->with($email, $websiteId)
->willReturn($customer);
Expand All @@ -1265,6 +1268,9 @@ protected function prepareInitiatePasswordReset($email, $templateIdentifier, $se
->method('setRpTokenCreatedAt')
->with($datetime)
->willReturnSelf();
$this->customerSecure->expects($this->any())
->method('getRpTokenCreatedAt')
->willReturn($datetime);
$this->customerSecure->expects($this->any())
->method('addData')
->with($customerData)
Expand All @@ -1273,76 +1279,61 @@ protected function prepareInitiatePasswordReset($email, $templateIdentifier, $se
->method('setData')
->with('name', $customerName)
->willReturnSelf();
$this->customerRegistry->expects($this->any())
$this->customerRegistry->expects($this->exactly($callCount * 2))
->method('retrieveSecureData')
->with($customerId)
->willReturn($this->customerSecure);
$this->dataObjectProcessor->expects($this->any())
->method('buildOutputDataArray')
->with($customer, Customer::class)
->willReturn($customerData);

$this->prepareEmailSend($email, $templateIdentifier, $sender, $storeId, $customerName);
$this->customer->expects($this->any())
->method('getResetPasswordLinkExpirationPeriod')
->willReturn(100000);
}

/**
* @param string $email
* @param int $templateIdentifier
* @param string $sender
* @param int $storeId
* @param string $customerName
* @throws \Magento\Framework\Exception\LocalizedException
*/
protected function prepareEmailSend($email, $templateIdentifier, $sender, $storeId, $customerName)
public function testInitiatePasswordResetEmailReminder()
{
$transport = $this->getMockBuilder(TransportInterface::class)
->getMock();
$customerId = 1;

$this->transportBuilder->expects($this->any())
->method('setTemplateIdentifier')
->with($templateIdentifier)
->willReturnSelf();
$this->transportBuilder->expects($this->any())
->method('setTemplateOptions')
->with(['area' => Area::AREA_FRONTEND, 'store' => $storeId])
->willReturnSelf();
$this->transportBuilder->expects($this->any())
->method('setTemplateVars')
->with(['customer' => $this->customerSecure, 'store' => $this->store])
->willReturnSelf();
$this->transportBuilder->expects($this->any())
->method('setFrom')
->with($sender)
->willReturnSelf();
$this->transportBuilder->expects($this->any())
->method('addTo')
->with($email, $customerName)
$email = 'test@example.com';
$template = AccountManagement::EMAIL_REMINDER;
$templateIdentifier = 'Template Identifier';
$sender = 'Sender';

$storeId = 1;

$hash = hash("sha256", uniqid(microtime() . random_int(0, PHP_INT_MAX), true));

$this->emailNotificationMock->expects($this->once())
->method('passwordReminder')
->willReturnSelf();
$this->transportBuilder->expects($this->any())
->method('getTransport')
->willReturn($transport);

$transport->expects($this->any())
->method('sendMessage');
$this->prepareInitiatePasswordReset($email, $templateIdentifier, $sender, $storeId, $customerId, $hash);

$this->assertTrue($this->accountManagement->initiatePasswordReset($email, $template));
}

/**
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function testInitiatePasswordResetEmailReminder()
public function testInitiatePasswordResetEmailReset()
{
$storeId = 1;
$customerId = 1;

$email = 'test@example.com';
$template = AccountManagement::EMAIL_REMINDER;
$template = AccountManagement::EMAIL_RESET;
$templateIdentifier = 'Template Identifier';
$sender = 'Sender';

$storeId = 1;

$hash = hash("sha256", uniqid(microtime() . random_int(0, PHP_INT_MAX), true));

$this->emailNotificationMock->expects($this->once())
->method('passwordReminder')
->method('passwordResetConfirmation')
->willReturnSelf();

$this->prepareInitiatePasswordReset($email, $templateIdentifier, $sender, $storeId, $customerId, $hash);
Expand All @@ -1353,7 +1344,7 @@ public function testInitiatePasswordResetEmailReminder()
/**
* @throws \Magento\Framework\Exception\LocalizedException
*/
public function testInitiatePasswordResetEmailReset()
public function testInitiatePasswordResetEmailResetNoDuplicateEmail()
{
$storeId = 1;
$customerId = 1;
Expand All @@ -1369,9 +1360,23 @@ public function testInitiatePasswordResetEmailReset()
->method('passwordResetConfirmation')
->willReturnSelf();

$this->prepareInitiatePasswordReset($email, $templateIdentifier, $sender, $storeId, $customerId, $hash);
$this->prepareInitiatePasswordReset(
$email,
$templateIdentifier,
$sender,
$storeId,
$customerId,
$hash,
2 // call count
);

$this->assertTrue($this->accountManagement->initiatePasswordReset($email, $template));

$this->customerSecure->expects($this->any())
->method('getRpToken')
->willReturn($hash);

$this->assertFalse($this->accountManagement->initiatePasswordReset($email, $template));
}

/**
Expand Down
45 changes: 33 additions & 12 deletions app/code/Magento/User/Controller/Adminhtml/Auth/Forgotpassword.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@
*/
namespace Magento\User\Controller\Adminhtml\Auth;

use Magento\Backend\App\Action\Context;
use Magento\Backend\Helper\Data;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\ObjectManager;
use Magento\Security\Model\SecurityManager;
use Magento\Backend\App\Action\Context;
use Magento\User\Model\UserFactory;
use Magento\User\Model\ResourceModel\User\CollectionFactory;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Exception\SecurityViolationException;
use Magento\Framework\Validator\EmailAddress;
use Magento\Security\Model\PasswordResetRequestEvent;
use Magento\Framework\Exception\SecurityViolationException;
use Magento\Security\Model\SecurityManager;
use Magento\User\Controller\Adminhtml\Auth;
use Magento\Backend\Helper\Data;
use Magento\User\Model\ResourceModel\User\CollectionFactory;
use Magento\User\Model\Spi\NotificatorInterface;
use Magento\User\Model\User;
use Magento\User\Model\UserFactory;

/**
* Initiate forgot-password process.
Expand Down Expand Up @@ -63,7 +65,7 @@ public function __construct(
SecurityManager $securityManager,
CollectionFactory $userCollectionFactory = null,
Data $backendDataHelper = null,
?NotificatorInterface $notificator = null
NotificatorInterface $notificator = null
) {
parent::__construct($context, $userFactory);
$this->securityManager = $securityManager;
Expand Down Expand Up @@ -108,9 +110,9 @@ public function execute()
try {
if ($collection->getSize() > 0) {
foreach ($collection as $item) {
/** @var \Magento\User\Model\User $user */
/** @var User $user */
$user = $this->_userFactory->create()->load($item->getId());
if ($user->getId()) {
if ($user->getId() && !$this->userHasValidPasswordResetToken($user)) {
$newPassResetToken = $this->backendDataHelper->generateResetPasswordLinkToken();
$user->changeResetPasswordLinkToken($newPassResetToken);
$user->save();
Expand All @@ -126,9 +128,9 @@ public function execute()
);
return $resultRedirect->setPath('admin');
}
// @codingStandardsIgnoreStart
$this->messageManager->addSuccess(__('We\'ll email you a link to reset your password.'));
// @codingStandardsIgnoreEnd
$this->messageManager->addSuccess(
__('We\'ll email you a link to reset your password.')
);
$this->getResponse()->setRedirect(
$this->backendDataHelper->getHomePageUrl()
);
Expand All @@ -142,4 +144,23 @@ public function execute()
$this->_view->loadLayout();
$this->_view->renderLayout();
}

/**
* Retrieve and validate existing password reset token
*
* @param User $user
* @return bool
*/
private function userHasValidPasswordResetToken(User $user): bool
{
try {
$newPassResetToken = $user->getRpToken();
$this->_validateResetPasswordLinkToken((int) $user->getId(), $newPassResetToken);
return true;
} catch (LocalizedException $exception) {
// Catching LocalizedException as this is the exception that is thrown
// to indicate failure within Auth::_validateResetPasswordLinkToken()
return false;
}
}
}
Loading