Skip to content

Commit

Permalink
[FEATURE] Added optional change password code for password change
Browse files Browse the repository at this point in the history
Refs #69
  • Loading branch information
derhansen committed Dec 2, 2023
1 parent b3de155 commit 9bd4541
Show file tree
Hide file tree
Showing 14 changed files with 249 additions and 3 deletions.
24 changes: 24 additions & 0 deletions Classes/Controller/PasswordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

use Derhansen\FeChangePwd\Domain\Model\Dto\ChangePassword;
use Derhansen\FeChangePwd\Event\AfterPasswordUpdatedEvent;
use Derhansen\FeChangePwd\Exception\InvalidEmailAddressException;
use Derhansen\FeChangePwd\Service\FrontendUserService;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Messaging\AbstractMessage;
use TYPO3\CMS\Extbase\Annotation as Extbase;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Security\Exception\InvalidHashException;
Expand Down Expand Up @@ -112,6 +114,28 @@ protected function setFeUserPasswordHashToArguments(array $changePasswordArray):
$this->request->setArguments($arguments);
}

/**
* Sends an email with the verification code to the current frontend user
*/
public function sendChangePasswordCodeAction(): ResponseInterface
{
try {
$this->frontendUserService->sendChangePasswordCodeEmail($this->settings, $this->request);
$this->addFlashMessage(
LocalizationUtility::translate('changePasswordCodeSent', 'FeChangePwd'),
LocalizationUtility::translate('changePasswordCodeSent.title', 'FeChangePwd')
);
} catch (InvalidEmailAddressException $exception) {
$this->addFlashMessage(
LocalizationUtility::translate('changePasswordCodeInvalidEmail', 'FeChangePwd'),
LocalizationUtility::translate('changePasswordCodeInvalidEmail.title', 'FeChangePwd'),
AbstractMessage::ERROR
);
}

return $this->redirect('edit');
}

/**
* Suppress default flash messages
*
Expand Down
11 changes: 11 additions & 0 deletions Classes/Domain/Model/Dto/ChangePassword.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class ChangePassword
protected string $password1 = '';
protected string $password2 = '';
protected string $currentPassword = '';
protected string $changePasswordCode = '';
protected string $feUserPasswordHash = '';
protected string $changeHmac = '';
protected bool $skipCurrentPasswordCheck = false;
Expand Down Expand Up @@ -53,6 +54,16 @@ public function setCurrentPassword(string $currentPassword): void
$this->currentPassword = $currentPassword;
}

public function getChangePasswordCode(): string
{
return $this->changePasswordCode;
}

public function setChangePasswordCode(string $changePasswordCode): void
{
$this->changePasswordCode = $changePasswordCode;
}

public function getChangeHmac(): string
{
return $this->changeHmac;
Expand Down
11 changes: 11 additions & 0 deletions Classes/Exception/InvalidEmailAddressException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Derhansen\FeChangePwd\Exception;

use Exception;

class InvalidEmailAddressException extends Exception
{
}
52 changes: 52 additions & 0 deletions Classes/Service/FrontendUserService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@

namespace Derhansen\FeChangePwd\Service;

use Derhansen\FeChangePwd\Exception\InvalidEmailAddressException;
use Derhansen\FeChangePwd\Exception\InvalidUserException;
use Derhansen\FeChangePwd\Exception\MissingPasswordHashServiceException;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Mail\FluidEmail;
use TYPO3\CMS\Core\Mail\Mailer;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;

/**
Expand Down Expand Up @@ -95,6 +99,8 @@ public function updatePassword(string $newPassword): void
$queryBuilder->update($userTable)
->set('password', $password)
->set('must_change_password', 0)
->set('change_password_code_hash', '')
->set('change_password_code_expiry_date', 0)
->set('password_expiry_date', $this->settingsService->getPasswordExpiryTimestamp())
->set('tstamp', (int)$GLOBALS['EXEC_TIME'])
->where(
Expand Down Expand Up @@ -149,6 +155,52 @@ public function validateChangeHmac(string $changeHmac): bool
return $changeHmac !== '' && hash_equals($this->getChangeHmac(), $changeHmac);
}

/**
* Generates the change password code, saves it to the current frontend user record and sends an email
* containing the change password code to the user
*/
public function sendChangePasswordCodeEmail(array $settings, RequestInterface $request): void
{
$recipientEmail = $this->getFrontendUser()->user['email'] ?? '';
if (!GeneralUtility::validEmail($recipientEmail)) {
throw new InvalidEmailAddressException('Email address of frontend user is not valid');
}

$changePasswordCode = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$validUntil = (new \DateTime())
->modify('+' . ($settings['requireChangePasswordCode']['validityInMinutes'] ?? 5) . ' minutes');

$userTable = $this->getFrontendUser()->user_table;
$userUid = $this->getFrontendUser()->user['uid'];
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($userTable);
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->update($userTable)
->set('change_password_code_hash', GeneralUtility::hmac($changePasswordCode, self::class))
->set('change_password_code_expiry_date', $validUntil->getTimestamp())
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($userUid, \PDO::PARAM_INT)
)
)
->executeStatement();

$userData = $this->getFrontendUser()->user;
unset($userData['password']);

$email = GeneralUtility::makeInstance(FluidEmail::class);
$email->setRequest($request);
$email->setTemplate('ChangePasswordCode');
$email->to($recipientEmail);
$email->format(FluidEmail::FORMAT_HTML);
$email->assignMultiple([
'userData' => $userData,
'changePasswordCode' => $changePasswordCode,
'validUntil' => $validUntil,
]);
GeneralUtility::makeInstance(Mailer::class)->send($email);
}

/**
* Returns a password hash
*
Expand Down
53 changes: 53 additions & 0 deletions Classes/Validation/Validator/ChangePasswordValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@
namespace Derhansen\FeChangePwd\Validation\Validator;

use Derhansen\FeChangePwd\Domain\Model\Dto\ChangePassword;
use Derhansen\FeChangePwd\Service\FrontendUserService;
use Derhansen\FeChangePwd\Service\LocalizationService;
use Derhansen\FeChangePwd\Service\OldPasswordService;
use Derhansen\FeChangePwd\Service\PwnedPasswordsService;
use Derhansen\FeChangePwd\Service\SettingsService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Validation\Validator\AbstractValidator;
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;

/**
* Class ChangePasswordValidator
Expand Down Expand Up @@ -68,6 +71,14 @@ protected function isValid($value): bool
}
}

// Early return if change password code is required, but either empty or not valid
if (isset($settings['requireChangePasswordCode']['enabled']) &&
(bool)$settings['requireChangePasswordCode']['enabled'] &&
$this->evaluateChangePasswordCode($value) === false
) {
return false;
}

// Early return if no passwords are given
if ($value->getPassword1() === '' || $value->getPassword2() === '') {
$this->addError(
Expand Down Expand Up @@ -221,4 +232,46 @@ protected function evaluateRequireCurrentPassword(ChangePassword $changePassword
}
return $result;
}

/**
* Evaluates the change password code
*/
protected function evaluateChangePasswordCode(ChangePassword $changePassword): bool
{
$currentHash = $this->getFrontendUser()->user['change_password_code_hash'] ?? '';
$calculatedHash = GeneralUtility::hmac($changePassword->getChangePasswordCode(), FrontendUserService::class);
$expirationTime = (int)($this->getFrontendUser()->user['change_password_code_expiry_date'] ?? 0);

if (empty($changePassword->getChangePasswordCode())) {
$this->addError(
$this->localizationService->translate('changePasswordCode.empty'),
1701451678
);
return false;
}

if ($currentHash === '' ||
$expirationTime === 0 ||
$expirationTime < time() ||
!hash_equals($currentHash, $calculatedHash)
) {
$this->addError(
$this->localizationService->translate('changePasswordCode.invalidOrExpired'),
1701451180
);
return false;
}

return true;
}

protected function getFrontendUser(): FrontendUserAuthentication
{
return $this->getTypoScriptFrontendController()->fe_user;
}

protected function getTypoScriptFrontendController(): ?TypoScriptFrontendController
{
return $GLOBALS['TSFE'] ?? null;
}
}
6 changes: 6 additions & 0 deletions Configuration/TypoScript/setup.typoscript
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ plugin.tx_fechangepwd {
enabled = 0
}

# If enabled, it is required to enter an change password code, which the user can request by email using the plugin
requireChangePasswordCode {
enabled = 0
validityInMinutes = 5
}

# If enabled, the password for password breaches using the haveibeenpwned.com API
pwnedpasswordsCheck {
enabled = 1
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Password changes for frontend users can be enforced and passwords can expire aft
* Password expiration after a configurable amount of days
* Optional check if password has been part of a data breach using the [haveibeenpwned.com](https://haveibeenpwned.com/) API and the k-Anonymity model
* Optional require the current password in order to change it
* Optional require a change password code, which can be sent to the users email address, in order to change the password

## Screenshot

Expand Down Expand Up @@ -79,6 +80,11 @@ page with the Plugin of the extension

* `enabled` *(bool)* If set to `1`, the user must enter the current password in order to set a new password.

**plugin.tx_fechangepwd.settings.requireChangePasswordCode**

* `enabled` *(bool)* If set to `1`, the user must enter a change password code, which will be sent to the users email address, in order to set a new password. Default setting is `0`.
* `validityInMinutes` *(integer)* The time in minutes the change password code is valid, when it has been requested by the user.

**plugin.tx_fechangepwd.settings.pwnedpasswordsCheck**

* `enabled` *(bool)* If set to `1`, the new password is checked using the haveibeenpwned.com API to verify, that the
Expand Down Expand Up @@ -116,6 +122,12 @@ if you e.g. want to exclude a page and all subpages for the redirect
The extension output is completely unstyled. Feel free to [override](https://stackoverflow.com/questions/39724833/best-way-to-overwrite-a-extension-template)
the fluid templates to your needs.

## Overriding Fluid email templates

If the email template used for the "change password code" email need to be overridden, this can
be changed in `$GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths'][750]` or by adding e template
override for the `ChangePasswordCode` template.

## Possible Errors

### No password hashing service
Expand Down
42 changes: 42 additions & 0 deletions Resources/Private/Language/locallang.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@
<trans-unit id="passwordUpdated.title">
<source>Password updated</source>
</trans-unit>
<trans-unit id="changePasswordCode">
<source>Change password code</source>
</trans-unit>
<trans-unit id="link.sendChangePasswordCode">
<source>Send change password code</source>
</trans-unit>
<trans-unit id="currentPassword">
<source>Current Password</source>
</trans-unit>
Expand Down Expand Up @@ -66,6 +72,42 @@
<trans-unit id="currentPasswordFailure">
<source>The current password you entered is wrong.</source>
</trans-unit>
<trans-unit id="changePasswordCode.empty">
<source>You must enter the change password code.</source>
</trans-unit>
<trans-unit id="changePasswordCode.invalidOrExpired">
<source>The given change password code is invalid or has expired.</source>
</trans-unit>
<trans-unit id="changePasswordCodeSent">
<source>The change password code has been sent to your email address.</source>
</trans-unit>
<trans-unit id="changePasswordCodeSent.title">
<source>Change password code sent</source>
</trans-unit>
<trans-unit id="changePasswordCodeInvalidEmail">
<source>Change password code could not be sent, because no valid email address is configured for your acccount.</source>
</trans-unit>
<trans-unit id="changePasswordCodeInvalidEmail.title">
<source>Invalid email address</source>
</trans-unit>
<trans-unit id="changePasswordCode.email.subject">
<source>Your change password code</source>
</trans-unit>
<trans-unit id="changePasswordCode.email.title">
<source>Change password code</source>
</trans-unit>
<trans-unit id="changePasswordCode.email.introduction">
<source>Please find below the change password code you requested.</source>
</trans-unit>
<trans-unit id="changePasswordCode.email.code">
<source>Code</source>
</trans-unit>
<trans-unit id="changePasswordCode.email.validUntil">
<source>Valid until</source>
</trans-unit>
<trans-unit id="changePasswordCode.email.notice">
<source>The change password code can be used multiple times either until the validity date is reached or until the password has been successfully changed.</source>
</trans-unit>
</body>
</file>
</xliff>
13 changes: 13 additions & 0 deletions Resources/Private/Templates/Email/ChangePasswordCode.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<f:layout name="SystemEmail" />
<f:section name="Title">
<f:translate key="changePasswordCode.email.title" extensionName="fe_change_pwd" />
</f:section>
<f:section name="Subject">
<f:translate key="changePasswordCode.email.subject" extensionName="fe_change_pwd" />
</f:section>
<f:section name="Main">
<f:translate key="changePasswordCode.email.introduction" extensionName="fe_change_pwd" />
<p><f:translate key="changePasswordCode.email.code" extensionName="fe_change_pwd" />: <strong>{changePasswordCode}</strong></p>
<p><f:translate key="changePasswordCode.email.validUntil" extensionName="fe_change_pwd" />: {validUntil -> f:format.date(format: 'd.m.Y H:i:s')}</p>
<f:translate key="changePasswordCode.email.notice" extensionName="fe_change_pwd" />
</f:section>
9 changes: 9 additions & 0 deletions Resources/Private/Templates/Password/Edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ <h3><f:translate key="validationErrorHeader" /> </h3>
<f:form.password id="currentPassword" property="currentPassword" />
</div>
</f:if>
<f:if condition="{settings.requireChangePasswordCode.enabled} == 1">
<div>
<label for="changePasswordCode">
<f:translate key="changePasswordCode" />
</label>
<f:form.textfield type="number" id="changePasswordCode" property="changePasswordCode" />
<f:link.action action="sendChangePasswordCode"><f:translate key="link.sendChangePasswordCode" /></f:link.action>
</div>
</f:if>
<div>
<label for="password1">
<f:translate key="password" />
Expand Down
2 changes: 1 addition & 1 deletion ext_emconf.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
'author' => 'Torben Hansen',
'author_email' => 'torben@derhansen.com',
'state' => 'stable',
'version' => '3.0.3',
'version' => '3.1.0',
'constraints' => [
'depends' => [
'typo3' => '11.5.0-11.5.99'
Expand Down
9 changes: 7 additions & 2 deletions ext_localconf.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
'fe_change_pwd',
'Pi1',
[
\Derhansen\FeChangePwd\Controller\PasswordController::class => 'edit,update',
\Derhansen\FeChangePwd\Controller\PasswordController::class => 'edit,update,sendChangePasswordCode',
],
// non-cacheable actions
[
\Derhansen\FeChangePwd\Controller\PasswordController::class => 'edit,update',
\Derhansen\FeChangePwd\Controller\PasswordController::class => 'edit,update,sendChangePasswordCode',
]
);

// Define template override for Fluid email
if (!isset($GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths'][750])) {
$GLOBALS['TYPO3_CONF_VARS']['MAIL']['templateRootPaths'][750] = 'EXT:fe_change_pwd/Resources/Private/Templates/Email';
}
});
Loading

0 comments on commit 9bd4541

Please sign in to comment.