[FEATURE] Reset password for backend users 85/63385/16
authorBenni Mack <benni@typo3.org>
Sat, 22 Feb 2020 23:08:12 +0000 (00:08 +0100)
committerGeorg Ringer <georg.ringer@gmail.com>
Fri, 6 Mar 2020 20:45:44 +0000 (21:45 +0100)
This feature adds a link on TYPO3 Backend's login form
to reset a backend users' password if the user has forgotten the password.

Key changes:
* Only enabled for backend users with an email address and a password set
* Enabled by default but can be disabled completely
* Optionally only works for non-admins via TYPO3_CONF_VARS
* Only send out emails to users within the system, but no information disclosure
* If multiple valid users have the same email address, a different email is sent out
* TCA be_users.email is not set to eval=email (due to backwards-compatibility)
* Password resets are only valid for 2 hours (non-configurable)
* Not extensible for third-party authentication methods yet
* Rate limiting is enabled per email address for 3 attempts per 30mins (non-configurable)
* When logging in, all previous tokens are removed
* When requesting multiple resets, only the last email is valid
* A CLI command "backend:resetpassword $backendUrl $emailAddress" sends out an email as well from admins
* Admins can trigger a password reset for users in the BE User module

Resolves: #89513
Releases: master
Change-Id: I9a146d5a9db176d24f2223c5eafb0fb42861e93f
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/63385
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
32 files changed:
typo3/sysext/backend/Classes/Authentication/PasswordReset.php [new file with mode: 0644]
typo3/sysext/backend/Classes/Command/ResetPasswordCommand.php [new file with mode: 0644]
typo3/sysext/backend/Classes/Controller/LoginController.php
typo3/sysext/backend/Classes/LoginProvider/UsernamePasswordLoginProvider.php
typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php
typo3/sysext/backend/Configuration/Backend/Routes.php
typo3/sysext/backend/Configuration/Services.yaml
typo3/sysext/backend/Resources/Private/Language/locallang.xlf
typo3/sysext/backend/Resources/Private/Language/locallang_reset_password.xlf [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Layouts/Login.html
typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/AmbiguousResetRequested.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/AmbiguousResetRequested.txt [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/ResetRequested.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/ResetRequested.txt [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/Login/ForgetPasswordForm.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/Login/ResetPasswordForm.html [new file with mode: 0644]
typo3/sysext/backend/Resources/Private/Templates/UserPassLoginForm.html
typo3/sysext/backend/Tests/Functional/Authentication/Fixtures/be_users.xml [new file with mode: 0644]
typo3/sysext/backend/Tests/Functional/Authentication/Fixtures/be_users_only_admins.xml [new file with mode: 0644]
typo3/sysext/backend/Tests/Functional/Authentication/PasswordResetTest.php [new file with mode: 0644]
typo3/sysext/backend/ext_tables.sql [new file with mode: 0644]
typo3/sysext/beuser/Classes/Controller/BackendUserController.php
typo3/sysext/beuser/Classes/Domain/Model/BackendUser.php
typo3/sysext/beuser/Resources/Private/Language/locallang.xlf
typo3/sysext/beuser/Resources/Private/Partials/BackendUser/IndexListRow.html
typo3/sysext/beuser/ext_tables.php
typo3/sysext/core/Classes/Authentication/BackendUserAuthentication.php
typo3/sysext/core/Classes/Core/Environment.php
typo3/sysext/core/Classes/SysLog/Action/Login.php
typo3/sysext/core/Configuration/DefaultConfiguration.php
typo3/sysext/core/Configuration/DefaultConfigurationDescription.yaml
typo3/sysext/core/Documentation/Changelog/master/Feature-89513-PasswordResetForBackendUsers.rst [new file with mode: 0644]

diff --git a/typo3/sysext/backend/Classes/Authentication/PasswordReset.php b/typo3/sysext/backend/Classes/Authentication/PasswordReset.php
new file mode 100644 (file)
index 0000000..a6b2b14
--- /dev/null
@@ -0,0 +1,492 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Authentication;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Doctrine\DBAL\Platforms\MySqlPlatform;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\UriInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Symfony\Component\Mime\Address;
+use TYPO3\CMS\Backend\Routing\UriBuilder;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
+use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface;
+use TYPO3\CMS\Core\Crypto\Random;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\QueryBuilder;
+use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
+use TYPO3\CMS\Core\Http\NormalizedParams;
+use TYPO3\CMS\Core\Mail\FluidEmail;
+use TYPO3\CMS\Core\Mail\Mailer;
+use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
+use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
+use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * This class is responsible for
+ * - find the right user, sending out a reset email.
+ * - create a token for creating the link (not exposed outside of this class)
+ * - validate a hashed token
+ * - send out an email to initiate the password reset
+ * - update a password for a backend user if all parameters match
+ *
+ * @internal this is a concrete implementation for User/Password login and not part of public TYPO3 Core API.
+ */
+class PasswordReset implements LoggerAwareInterface
+{
+    use LoggerAwareTrait;
+
+    protected const TOKEN_VALID_UNTIL = '+2 hours';
+    protected const MAXIMUM_RESET_ATTEMPTS = 3;
+    protected const MAXIMUM_RESET_ATTEMPTS_SINCE = '-30 minutes';
+
+    /**
+     * Check if there are at least one in the system that contains a non-empty password AND an email address set.
+     */
+    public function isEnabled(): bool
+    {
+        // Option not explicitly enabled
+        if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
+            return false;
+        }
+        $queryBuilder = $this->getPreparedQueryBuilder();
+        $statement = $queryBuilder
+            ->select('uid')
+            ->from('be_users')
+            ->setMaxResults(1)
+            ->execute();
+        return (int)$statement->fetchColumn() > 0;
+    }
+
+    /**
+     * Check if a specific backend user can be used to trigger an email reset. Basically checks if the functionality
+     * is enabled in general, and if the user has email + password set.
+     *
+     * @param int $userId
+     * @return bool
+     */
+    public function isEnabledForUser(int $userId): bool
+    {
+        // Option not explicitly enabled
+        if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
+            return false;
+        }
+        $queryBuilder = $this->getPreparedQueryBuilder();
+        $statement = $queryBuilder
+            ->select('uid')
+            ->from('be_users')
+            ->andWhere(
+                $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, \PDO::PARAM_INT))
+            )
+            ->setMaxResults(1)
+            ->execute();
+        return $statement->fetchColumn() > 0;
+    }
+
+    /**
+     * Determine the right user and send out an email. If multiple users are found with the same email address
+     * an alternative email is sent.
+     *
+     * If no user is found, this is logged to the system (but not to sys_log).
+     *
+     * The method intentionally does not return anything to avoid any information disclosure or exposure.
+     *
+     * @param ServerRequestInterface $request
+     * @param Context $context
+     * @param string $emailAddress
+     */
+    public function initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress): void
+    {
+        if (!GeneralUtility::validEmail($emailAddress)) {
+            return;
+        }
+        if ($this->hasExceededMaximumAttemptsForReset($context, $emailAddress)) {
+            $this->logger->alert('Password reset requested for email "' . $emailAddress . '" . but was requested too many times.');
+            return;
+        }
+        $queryBuilder = $this->getPreparedQueryBuilder();
+        $users = $queryBuilder
+            ->select('uid', 'email', 'username', 'realName', 'uc', 'lang')
+            ->from('be_users')
+            ->andWhere(
+                $queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
+            )
+            ->execute()
+            ->fetchAll();
+        if (!is_array($users) || count($users) === 0) {
+            // No user found, do nothing, also no log to sys_log in order avoid log flooding
+            $this->logger->warning('Password reset requested for email but no valid users');
+        } elseif (count($users) > 1) {
+            // More than one user with the same email address found, send out the email that one cannot send out a reset link
+            $this->sendAmbiguousEmail($request, $context, $emailAddress);
+        } else {
+            $user = reset($users);
+            $this->sendResetEmail($request, $context, (array)$user, $emailAddress);
+        }
+    }
+
+    /**
+     * Send out an email to a given email address and note that a reset was triggered but email was used multiple times.
+     * Used when the database returned multiple users.
+     *
+     * @param ServerRequestInterface $request
+     * @param Context $context
+     * @param string $emailAddress
+     */
+    protected function sendAmbiguousEmail(ServerRequestInterface $request, Context $context, string $emailAddress): void
+    {
+        $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
+        $emailObject
+            ->to(new Address($emailAddress))
+            ->setRequest($request)
+            ->assign('email', $emailAddress)
+            ->setTemplate('PasswordReset/AmbiguousResetRequested');
+
+        GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
+        $this->logger->warning('Password reset sent to email address ' . $emailAddress . ' but multiple accounts found');
+        $this->log(
+            'Sent password reset email to email address %s but with multiple accounts attached.',
+            SystemLogLoginAction::PASSWORD_RESET_REQUEST,
+            SystemLogErrorClassification::WARNING,
+            0,
+            [
+                'email' => $emailAddress
+            ],
+            NormalizedParams::createFromRequest($request)->getRemoteAddress(),
+            $context
+        );
+    }
+
+    /**
+     * Send out an email to a user that does have an email address added to his account, containing a reset link.
+     *
+     * @param ServerRequestInterface $request
+     * @param Context $context
+     * @param array $user
+     * @param string $emailAddress
+     */
+    protected function sendResetEmail(ServerRequestInterface $request, Context $context, array $user, string $emailAddress): void
+    {
+        $uc = unserialize($user['uc'] ?? '', ['allowed_classes' => false]);
+        $resetLink = $this->generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
+        $emailObject = GeneralUtility::makeInstance(FluidEmail::class);
+        $emailObject
+            ->to(new Address((string)$user['email'], $user['realName']))
+            ->setRequest($request)
+            ->assign('name', $user['realName'])
+            ->assign('email', $user['email'])
+            ->assign('language', $uc['lang'] ?? $user['lang'] ?: 'default')
+            ->assign('resetLink', $resetLink)
+            ->setTemplate('PasswordReset/ResetRequested');
+
+        GeneralUtility::makeInstance(Mailer::class)->send($emailObject);
+        $this->logger->info('Sent password reset email to email address ' . $emailAddress . ' for user ' . $user['username']);
+        $this->log(
+            'Sent password reset email to email address %s',
+            SystemLogLoginAction::PASSWORD_RESET_REQUEST,
+            SystemLogErrorClassification::SECURITY_NOTICE,
+            (int)$user['uid'],
+            [
+                'email' => $user['email']
+            ],
+            NormalizedParams::createFromRequest($request)->getRemoteAddress(),
+            $context
+        );
+    }
+
+    /**
+     * Creates a token, stores it in the database, and then creates an absolute URL for resetting the password.
+     * This is all in one method so it is not exposed from the outside.
+     *
+     * This function requires:
+     * a) the user is allowed to do a password reset (no check is done anymore)
+     * b) a valid email address.
+     *
+     * @param Context $context
+     * @param int $userId the backend user uid
+     * @param string $emailAddress is part of the hash to ensure that the email address does not get reset.
+     * @return UriInterface
+     */
+    protected function generateResetLinkForUser(Context $context, int $userId, string $emailAddress): UriInterface
+    {
+        $token = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96);
+        $currentTime = $context->getAspect('date')->getDateTime();
+        $expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL);
+        // Create a hash ("one time password") out of the token including the timestamp of the expiration date
+        $hash = GeneralUtility::hmac($token . '|' . (string)$expiresOn->getTimestamp() . '|' . $emailAddress . '|' . (string)$userId, 'password-reset');
+
+        // Set the token in the database, which is hashed
+        GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getConnectionForTable('be_users')
+            ->update('be_users', ['password_reset_token' => $this->getHasher()->getHashedPassword($hash)], ['uid' => $userId]);
+
+        return GeneralUtility::makeInstance(UriBuilder::class)->buildUriFromRoute(
+            'password_reset_validate',
+            [
+                // "token"
+                't' => $token,
+                // "expiration date"
+                'e' => $expiresOn->getTimestamp(),
+                // "identity"
+                'i' => hash('sha1', $emailAddress . (string)$userId)
+            ],
+            UriBuilder::ABSOLUTE_URL
+        );
+    }
+
+    /**
+     * Validates all query parameters / GET parameters of the given request against the token.
+     *
+     * @param ServerRequestInterface $request
+     * @return bool
+     */
+    public function isValidResetTokenFromRequest(ServerRequestInterface $request): bool
+    {
+        $user = $this->findValidUserForToken(
+            (string)($request->getQueryParams()['t'] ?? ''),
+            (string)($request->getQueryParams()['i'] ?? ''),
+            (int)($request->getQueryParams()['e'] ?? 0)
+        );
+        return $user !== null;
+    }
+
+    /**
+     * Fetch the user record from the database if the token is valid, and has matched all criteria
+     *
+     * @param string $token
+     * @param string $identity
+     * @param int $expirationTimestamp
+     * @return array|null the BE User database record
+     */
+    protected function findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array
+    {
+        $user = null;
+        // Find the token in the database
+        $queryBuilder = $this->getPreparedQueryBuilder();
+
+        $queryBuilder
+            ->select('uid', 'email', 'password_reset_token')
+            ->from('be_users');
+        if ($queryBuilder->getConnection()->getDatabasePlatform() instanceof MySqlPlatform) {
+            $queryBuilder->andWhere(
+                $queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity))
+            );
+            $user = $queryBuilder->execute()->fetch();
+        } else {
+            // no native SHA1/ CONCAT functionality, has to be done in PHP
+            $stmt = $queryBuilder->execute();
+            while ($row = $stmt->fetch()) {
+                if (hash('sha1', $row['email'] . (string)$row['uid']) === $identity) {
+                    $user = $row;
+                    break;
+                }
+            }
+        }
+
+        if (!is_array($user) || empty($user)) {
+            return null;
+        }
+
+        // Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token
+        $hash = GeneralUtility::hmac($token . '|' . (string)$expirationTimestamp . '|' . $user['email'] . '|' . (string)$user['uid'], 'password-reset');
+        if (!$this->getHasher()->checkPassword($hash, $user['password_reset_token'] ?? '')) {
+            return null;
+        }
+        return $user;
+    }
+
+    /**
+     * Update the password in the database if the password matches and the token is valid.
+     *
+     * @param ServerRequestInterface $request
+     * @param Context $context current context
+     * @return bool whether the password was resetted or not
+     */
+    public function resetPassword(ServerRequestInterface $request, Context $context): bool
+    {
+        $expirationTimestamp = (int)($request->getQueryParams()['e'] ?? '');
+        $identityHash = (string)($request->getQueryParams()['i'] ?? '');
+        $token = (string)($request->getQueryParams()['t'] ?? '');
+        $newPassword = (string)$request->getParsedBody()['password'];
+        $newPasswordRepeat = (string)$request->getParsedBody()['passwordrepeat'];
+        if (strlen($newPassword) < 8 || $newPassword !== $newPasswordRepeat) {
+            $this->logger->debug('Password reset not possible due to weak password');
+            return false;
+        }
+        $user = $this->findValidUserForToken($token, $identityHash, $expirationTimestamp);
+        if ($user === null) {
+            $this->logger->warning('Password reset not possible. Valid user for token not found.');
+            return false;
+        }
+        $userId = (int)$user['uid'];
+
+        GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getConnectionForTable('be_users')
+            ->update('be_users', ['password_reset_token' => '', 'password' => $this->getHasher()->getHashedPassword($newPassword)], ['uid' => $userId]);
+
+        $this->logger->info('Password reset successful for user ' . $userId);
+        $this->log(
+            'Password reset successful for user %s',
+            SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED,
+            SystemLogErrorClassification::SECURITY_NOTICE,
+            $userId,
+            [
+                'email' => $user['email'],
+                'user' => $userId
+            ],
+            NormalizedParams::createFromRequest($request)->getRemoteAddress(),
+            $context
+        );
+        return true;
+    }
+
+    /**
+     * The querybuilder for finding the right user - and adds some restrictions:
+     * - No CLI users
+     * - No Admin users (with option)
+     * - No hidden/deleted users
+     * - Password must be set
+     * - Username must be set
+     * - Email address must be set
+     *
+     * @return QueryBuilder
+     */
+    protected function getPreparedQueryBuilder(): QueryBuilder
+    {
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
+        $queryBuilder->getRestrictions()
+            ->removeAll()
+            ->add(GeneralUtility::makeInstance(RootLevelRestriction::class))
+            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
+            ->add(GeneralUtility::makeInstance(StartTimeRestriction::class))
+            ->add(GeneralUtility::makeInstance(EndTimeRestriction::class))
+            ->add(GeneralUtility::makeInstance(HiddenRestriction::class));
+        $queryBuilder->where(
+            $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')),
+            $queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')),
+            $queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')),
+            $queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
+        );
+        if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) {
+            $queryBuilder->andWhere(
+                $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT))
+            );
+        }
+        return $queryBuilder;
+    }
+
+    protected function getHasher(): PasswordHashInterface
+    {
+        return GeneralUtility::makeInstance(PasswordHashFactory::class)->getDefaultHashInstance('BE');
+    }
+
+    /**
+     * Adds an entry to "sys_log", also used to track the maximum allowed attempts.
+     *
+     * @param string $message the information / message in english
+     * @param int $action see SystemLogLoginAction
+     * @param int $error see SystemLogErrorClassification
+     * @param int $userId
+     * @param array $data additional information, used for the message
+     * @param $ipAddress
+     * @param Context $context
+     */
+    protected function log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context): void
+    {
+        $fields = [
+            'userid' => $userId,
+            'type' => SystemLogType::LOGIN,
+            'action' => $action,
+            'error' => $error,
+            'details_nr' => 1,
+            'details' => $message,
+            'log_data' => serialize($data),
+            'tablename' => 'be_users',
+            'recuid' => $userId,
+            'IP' => (string)$ipAddress,
+            'tstamp' => $context->getAspect('date')->get('timestamp'),
+            'event_pid' => 0,
+            'NEWid' => '',
+            'workspace' => 0
+        ];
+
+        $connection = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_log');
+        $connection->insert(
+            'sys_log',
+            $fields,
+            [
+                \PDO::PARAM_INT,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_STR,
+                \PDO::PARAM_STR,
+                \PDO::PARAM_STR,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_STR,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_INT,
+                \PDO::PARAM_STR,
+                \PDO::PARAM_STR,
+            ]
+        );
+    }
+
+    /**
+     * Checks if an email reset link has been requested more than 3 times in the last 30mins.
+     * If a password was successfully reset more than three times in 30 minutes, it would still fail.
+     *
+     * @param Context $context
+     * @param string $email
+     * @return bool
+     */
+    protected function hasExceededMaximumAttemptsForReset(Context $context, string $email): bool
+    {
+        $now = $context->getAspect('date')->getDateTime();
+        $numberOfAttempts = $this->getNumberOfInitiatedResetsForEmail($now->modify(self::MAXIMUM_RESET_ATTEMPTS_SINCE), $email);
+        return $numberOfAttempts > self::MAXIMUM_RESET_ATTEMPTS;
+    }
+
+    /**
+     * SQL query to find the amount of initiated resets from a given time.
+     *
+     * @param \DateTimeInterface $since
+     * @param string $email
+     * @return int
+     */
+    protected function getNumberOfInitiatedResetsForEmail(\DateTimeInterface $since, string $email): int
+    {
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_log');
+        return (int)$queryBuilder
+            ->count('uid')
+            ->from('sys_log')
+            ->where(
+                $queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter(SystemLogType::LOGIN)),
+                $queryBuilder->expr()->eq('action', $queryBuilder->createNamedParameter(SystemLogLoginAction::PASSWORD_RESET_REQUEST)),
+                $queryBuilder->expr()->eq('log_data', $queryBuilder->createNamedParameter(serialize(['email' => $email]))),
+                $queryBuilder->expr()->gte('tstamp', $queryBuilder->createNamedParameter($since->getTimestamp(), \PDO::PARAM_INT))
+            )
+            ->execute()
+            ->fetchColumn(0);
+    }
+}
diff --git a/typo3/sysext/backend/Classes/Command/ResetPasswordCommand.php b/typo3/sysext/backend/Classes/Command/ResetPasswordCommand.php
new file mode 100644 (file)
index 0000000..dd92251
--- /dev/null
@@ -0,0 +1,147 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Command;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ServerRequestInterface;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use TYPO3\CMS\Backend\Authentication\PasswordReset;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Http\NormalizedParams;
+use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Http\Uri;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * Triggers the workflow to request a new password for a user.
+ */
+class ResetPasswordCommand extends Command
+{
+    /**
+     * Configure the command by defining the name, options and arguments
+     */
+    protected function configure()
+    {
+        $this
+            ->setDescription('Trigger a password reset for a backend user.')
+            ->addArgument(
+                'backendurl',
+                InputArgument::REQUIRED,
+                'The URL of the TYPO3 Backend, e.g. https://www.example.com/typo3/'
+            )->addArgument(
+                'email',
+                InputArgument::REQUIRED,
+                'The email address of a valid backend user'
+            );
+    }
+    /**
+     * Executes the command for sending out an email to reset the password.
+     *
+     * @inheritDoc
+     */
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $io = new SymfonyStyle($input, $output);
+        $email = $input->getArgument('email');
+        if (!GeneralUtility::validEmail($email)) {
+            $io->error('The given email "' . $email . '" is not a valid email address.');
+            return 1;
+        }
+        $backendUrl = $input->getArgument('backendurl');
+        if (!GeneralUtility::isValidUrl($backendUrl)) {
+            $io->error('The given backend URL "' . $backendUrl . '" is not a valid URL.');
+            return 1;
+        }
+        $reset = GeneralUtility::makeInstance(PasswordReset::class);
+        if (!$reset->isEnabled()) {
+            $io->error('Password reset functionality is disabled');
+            return 1;
+        }
+        $context = GeneralUtility::makeInstance(Context::class);
+        $request = $this->createFakeWebRequest($backendUrl);
+        $GLOBALS['TYPO3_REQUEST'] = $request;
+        $reset->initiateReset($request, $context, $email);
+        $io->success('Sent out an email to "' . $email . '" requesting to set a new password.');
+        return 0;
+    }
+
+    /**
+     * This is needed to create a link to the backend properly.
+     *
+     * @param string $backendUrl
+     * @return ServerRequestInterface
+     */
+    protected function createFakeWebRequest(string $backendUrl): ServerRequestInterface
+    {
+        $uri = new Uri($backendUrl);
+        $request = new ServerRequest(
+            $uri,
+            'GET',
+            'php://input',
+            [],
+            [
+                'HTTP_HOST' => $uri->getHost(),
+                'SERVER_NAME' => $uri->getHost(),
+                'HTTPS' => $uri->getScheme() === 'https',
+                'SCRIPT_FILENAME' => __FILE__,
+                'SCRIPT_NAME' => rtrim($uri->getPath(), '/') . '/'
+            ]
+        );
+        $backedUpEnvironment = $this->simulateEnvironmentForBackendEntryPoint();
+        $normalizedParams = NormalizedParams::createFromRequest($request);
+
+        // Restore the environment
+        Environment::initialize(
+            Environment::getContext(),
+            Environment::isCli(),
+            Environment::isComposerMode(),
+            Environment::getProjectPath(),
+            Environment::getPublicPath(),
+            Environment::getVarPath(),
+            Environment::getConfigPath(),
+            $backedUpEnvironment['currentScript'],
+            Environment::isWindows() ? 'WINDOWS' : 'UNIX'
+        );
+
+        return $request->withAttribute('normalizedParams', $normalizedParams);
+    }
+
+    /**
+     * This is a workaround to use "PublicPath . /typo3/index.php" instead of "publicPath . /typo3/sysext/core/bin/typo3"
+     * so the the web root is detected properly in normalizedParams.
+     */
+    protected function simulateEnvironmentForBackendEntryPoint(): array
+    {
+        $currentEnvironment = Environment::toArray();
+        Environment::initialize(
+            Environment::getContext(),
+            Environment::isCli(),
+            Environment::isComposerMode(),
+            Environment::getProjectPath(),
+            Environment::getPublicPath(),
+            Environment::getVarPath(),
+            Environment::getConfigPath(),
+            // This is ugly, as this change fakes the directory
+            dirname(Environment::getCurrentScript(), 4) . DIRECTORY_SEPARATOR . 'index.php',
+            Environment::isWindows() ? 'WINDOWS' : 'UNIX'
+        );
+        return $currentEnvironment;
+    }
+}
index 7c28003..466f25c 100644 (file)
@@ -21,12 +21,14 @@ use Psr\Http\Message\ServerRequestInterface;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
 use Symfony\Component\HttpFoundation\Cookie;
+use TYPO3\CMS\Backend\Authentication\PasswordReset;
 use TYPO3\CMS\Backend\LoginProvider\Event\ModifyPageLayoutOnLoginProviderSelectionEvent;
 use TYPO3\CMS\Backend\LoginProvider\LoginProviderInterface;
 use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Backend\Template\ModuleTemplate;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
+use TYPO3\CMS\Core\Context\Context;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
 use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
@@ -123,8 +125,11 @@ class LoginController implements LoggerAwareInterface
      */
     protected $pageRenderer;
 
-    public function __construct(Typo3Information $typo3Information, EventDispatcherInterface $eventDispatcher, UriBuilder $uriBuilder)
-    {
+    public function __construct(
+        Typo3Information $typo3Information,
+        EventDispatcherInterface $eventDispatcher,
+        UriBuilder $uriBuilder
+    ) {
         $this->typo3Information = $typo3Information;
         $this->eventDispatcher = $eventDispatcher;
         $this->uriBuilder = $uriBuilder;
@@ -157,6 +162,117 @@ class LoginController implements LoggerAwareInterface
     }
 
     /**
+     * Show a form to enter an email address to request an email.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function forgetPasswordFormAction(ServerRequestInterface $request): ResponseInterface
+    {
+        // Only allow to execute this if not logged in as a user right now
+        $context = GeneralUtility::makeInstance(Context::class);
+        if ($context->getAspect('backend.user')->isLoggedIn()) {
+            return $this->formAction($request);
+        }
+        $this->init($request);
+        // Enable the switch in the template
+        $this->view->assign('enablePasswordReset', GeneralUtility::makeInstance(PasswordReset::class)->isEnabled());
+        $this->view->setTemplate('Login/ForgetPasswordForm');
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Validate the email address.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function initiatePasswordResetAction(ServerRequestInterface $request): ResponseInterface
+    {
+        // Only allow to execute this if not logged in as a user right now
+        $context = GeneralUtility::makeInstance(Context::class);
+        if ($context->getAspect('backend.user')->isLoggedIn()) {
+            return $this->formAction($request);
+        }
+        $this->init($request);
+        $passwordReset = GeneralUtility::makeInstance(PasswordReset::class);
+        $this->view->assign('enablePasswordReset', $passwordReset->isEnabled());
+        $this->view->setTemplate('Login/ForgetPasswordForm');
+
+        $emailAddress = $request->getParsedBody()['email'] ?? '';
+        $this->view->assign('email', $emailAddress);
+        if (!GeneralUtility::validEmail($emailAddress)) {
+            $this->view->assign('invalidEmail', true);
+        } else {
+            $passwordReset->initiateReset($request, $context, $emailAddress);
+            $this->view->assign('resetInitiated', true);
+        }
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Validates the link and show a form to enter the new password.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function passwordResetAction(ServerRequestInterface $request): ResponseInterface
+    {
+        // Only allow to execute this if not logged in as a user right now
+        $context = GeneralUtility::makeInstance(Context::class);
+        if ($context->getAspect('backend.user')->isLoggedIn()) {
+            return $this->formAction($request);
+        }
+        $this->init($request);
+        $passwordReset = GeneralUtility::makeInstance(PasswordReset::class);
+        $this->view->setTemplate('Login/ResetPasswordForm');
+        $this->view->assign('enablePasswordReset', $passwordReset->isEnabled());
+        if (!$passwordReset->isValidResetTokenFromRequest($request)) {
+            $this->view->assign('invalidToken', true);
+        }
+        $this->view->assign('token', $request->getQueryParams()['t'] ?? '');
+        $this->view->assign('identity', $request->getQueryParams()['i'] ?? '');
+        $this->view->assign('expirationDate', $request->getQueryParams()['e'] ?? '');
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
+     * Updates the password in the database.
+     *
+     * @param ServerRequestInterface $request
+     * @return ResponseInterface
+     */
+    public function passwordResetFinishAction(ServerRequestInterface $request): ResponseInterface
+    {
+        // Only allow to execute this if not logged in as a user right now
+        $context = GeneralUtility::makeInstance(Context::class);
+        if ($context->getAspect('backend.user')->isLoggedIn()) {
+            return $this->formAction($request);
+        }
+        $passwordReset = GeneralUtility::makeInstance(PasswordReset::class);
+        // Token is invalid
+        if ($request->getMethod() !== 'POST' || !$passwordReset->isValidResetTokenFromRequest($request)) {
+            return $this->passwordResetAction($request);
+        }
+        $this->init($request);
+        $this->view->setTemplate('Login/ResetPasswordForm');
+        $this->view->assign('enablePasswordReset', $passwordReset->isEnabled());
+        $this->view->assign('token', $request->getQueryParams()['t'] ?? '');
+        $this->view->assign('identity', $request->getQueryParams()['i'] ?? '');
+        $this->view->assign('expirationDate', $request->getQueryParams()['e'] ?? '');
+        if ($passwordReset->resetPassword($request, $context)) {
+            $this->view->assign('resetExecuted', true);
+        } else {
+            $this->view->assign('error', true);
+        }
+        $this->moduleTemplate->setContent($this->view->render());
+        return new HtmlResponse($this->moduleTemplate->renderContent());
+    }
+
+    /**
      * This can be called by single login providers, they receive an instance of $this
      *
      * @return string
@@ -214,6 +330,9 @@ class LoginController implements LoggerAwareInterface
 
         $this->view = $this->moduleTemplate->getView();
         $this->view->getRequest()->setControllerExtensionName('Backend');
+        $this->provideCustomLoginStyling();
+        $this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Login');
+        $this->view->assign('loginProviderIdentifier', $this->loginProviderIdentifier);
     }
 
     protected function provideCustomLoginStyling()
@@ -287,7 +406,6 @@ class LoginController implements LoggerAwareInterface
                 'typo3' => $this->getUriForFileName('EXT:backend/Resources/Public/Images/typo3_orange.svg'),
             ],
             'copyright' => $this->typo3Information->getCopyrightNotice(),
-            'loginNewsItems' => $this->getSystemNews(),
         ]);
     }
 
@@ -299,14 +417,10 @@ class LoginController implements LoggerAwareInterface
      */
     protected function createLoginLogoutForm(ServerRequestInterface $request): string
     {
-        $this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Login');
-
         // Checking, if we should make a redirect.
         // Might set JavaScript in the header to close window.
         $this->checkRedirect($request);
 
-        $this->provideCustomLoginStyling();
-
         // Start form
         $formType = empty($this->getBackendUserAuthentication()->user['uid']) ? 'LoginForm' : 'LogoutForm';
         $this->view->assignMultiple([
@@ -315,8 +429,8 @@ class LoginController implements LoggerAwareInterface
             'formType' => $formType,
             'redirectUrl' => $this->redirectUrl,
             'loginRefresh' => $this->loginRefresh,
-            'loginProviderIdentifier' => $this->loginProviderIdentifier,
-            'loginProviders' => $this->loginProviders
+            'loginProviders' => $this->loginProviders,
+            'loginNewsItems' => $this->getSystemNews(),
         ]);
 
         // Initialize interface selectors:
index c965b39..4e0bdc6 100644 (file)
@@ -14,6 +14,7 @@ namespace TYPO3\CMS\Backend\LoginProvider;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Backend\Authentication\PasswordReset;
 use TYPO3\CMS\Backend\Controller\LoginController;
 use TYPO3\CMS\Core\Page\PageRenderer;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -40,5 +41,7 @@ class UsernamePasswordLoginProvider implements LoginProviderInterface
             $view->assign('presetUsername', GeneralUtility::_GP('u'));
             $view->assign('presetPassword', GeneralUtility::_GP('p'));
         }
+
+        $view->assign('enablePasswordReset', GeneralUtility::makeInstance(PasswordReset::class)->isEnabled());
     }
 }
index c57499f..a1e7e66 100644 (file)
@@ -37,6 +37,10 @@ class BackendUserAuthenticator extends \TYPO3\CMS\Core\Middleware\BackendUserAut
     protected $publicRoutes = [
         '/login',
         '/login/frame',
+        '/login/password-reset/forget',
+        '/login/password-reset/initiate-reset',
+        '/login/password-reset/validate',
+        '/login/password-reset/finish',
         '/ajax/login',
         '/ajax/logout',
         '/ajax/login/refresh',
index 3f02d1b..23aa29c 100644 (file)
@@ -31,6 +31,28 @@ return [
         'path' => '/logout',
         'target' => Controller\LogoutController::class . '::logoutAction'
     ],
+    // Show the password forgotten form for entering the email
+    'password_forget' => [
+        'path' => '/login/password-reset/forget',
+        'access' => 'public',
+        'target' => Controller\LoginController::class . '::forgetPasswordFormAction'
+    ],
+    // Send out the password reset email
+    'password_forget_initiate_reset' => [
+        'path' => '/login/password-reset/initiate-reset',
+        'access' => 'public',
+        'target' => Controller\LoginController::class . '::initiatePasswordResetAction'
+    ],
+    'password_reset_validate' => [
+        'path' => '/login/password-reset/validate',
+        'access' => 'public',
+        'target' => Controller\LoginController::class . '::passwordResetAction'
+    ],
+    'password_reset_finish' => [
+        'path' => '/login/password-reset/finish',
+        'access' => 'public',
+        'target' => Controller\LoginController::class . '::passwordResetFinishAction'
+    ],
 
     // Register login frameset
     'login_frameset' => [
index 14d7049..fde9a30 100644 (file)
@@ -19,6 +19,10 @@ services:
     tags:
       - { name: 'console.command', command: 'referenceindex:update' }
 
+  TYPO3\CMS\Backend\Command\ResetPasswordCommand:
+    tags:
+      - { name: 'console.command', command: 'backend:resetpassword', schedulable: false }
+
   # Temporary workaround until testing framework loads EXT:fluid in functional tests
   # @todo: Fix typo3/testing-framework and remove this
   TYPO3\CMS\Backend\View\BackendTemplateView:
index a4e402a..13fe0d6 100644 (file)
@@ -58,6 +58,9 @@ Have a nice day.</source>
                        <trans-unit id="login.password" resname="login.password">
                                <source>Password</source>
                        </trans-unit>
+                       <trans-unit id="login.password_forget" resname="login.password_forget">
+                               <source>Forgot your password?</source>
+                       </trans-unit>
                        <trans-unit id="login.error.capslock" resname="login.error.capslock">
                                <source>Attention: Caps lock enabled!</source>
                        </trans-unit>
diff --git a/typo3/sysext/backend/Resources/Private/Language/locallang_reset_password.xlf b/typo3/sysext/backend/Resources/Private/Language/locallang_reset_password.xlf
new file mode 100644 (file)
index 0000000..1c36298
--- /dev/null
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
+       <file t3:id="1583500515" source-language="en" datatype="plaintext" original="EXT:backend/Resources/Private/Language/locallang_reset_password.xlf" date="2020-03-06T11:16:11Z" product-name="backend">
+               <header/>
+               <body>
+                       <trans-unit id="headline" resname="headline">
+                               <source>Reset Your Password</source>
+                       </trans-unit>
+                       <trans-unit id="instructions.email" resname="instructions.email">
+                               <source>To reset your password, enter the email address you use to sign in to TYPO3 Backend.</source>
+                       </trans-unit>
+                       <trans-unit id="instructions.password" resname="instructions.password">
+                               <source>To reset your password, enter a new password with at least 8 characters, so you use to sign in to TYPO3 Backend.</source>
+                       </trans-unit>
+                       <trans-unit id="note.other_providers" resname="note.other_providers">
+                               <source>Note: If you're using a centralized user management system such as LDAP, OAuth or Active Directory, you might not receive an email.</source>
+                       </trans-unit>
+                       <trans-unit id="email_sent.headline" resname="email_sent.headline">
+                               <source>Check your inbox!</source>
+                       </trans-unit>
+                       <trans-unit id="email_sent.message" resname="email_sent.message">
+                               <source><![CDATA[We've sent an email to "%s" if we found the email in this TYPO3 installation. Please check your inbox and possibly a spam folder.]]></source>
+                       </trans-unit>
+                       <trans-unit id="reset_success.headline" resname="reset_success.headline">
+                               <source>You've successfully reset your password!</source>
+                       </trans-unit>
+                       <trans-unit id="reset_success.message" resname="reset_success.message">
+                               <source><![CDATA[You can now log in to the TYPO3 Backend with your new credentials <a href="%s">here</a>.]]></source>
+                       </trans-unit>
+                       <trans-unit id="input.password" resname="input.password">
+                               <source>Enter your new password</source>
+                       </trans-unit>
+                       <trans-unit id="input.passwordrepeat" resname="input.passwordrepeat">
+                               <source>Enter your new password again</source>
+                       </trans-unit>
+                       <trans-unit id="button.initiate" resname="email_sent.initiate">
+                               <source>Get Reset Link</source>
+                       </trans-unit>
+                       <trans-unit id="button.reset" resname="button.reset">
+                               <source>Reset Your Password Now</source>
+                       </trans-unit>
+                       <trans-unit id="button.restart" resname="button.restart">
+                               <source>Start over</source>
+                       </trans-unit>
+                       <trans-unit id="button.back_to_login" resname="button.back_to_login">
+                               <source>Go back to login page</source>
+                       </trans-unit>
+                       <trans-unit id="error.invalid_email" resname="error.invalid_email">
+                               <source>The entered email address is invalid. Please try again.</source>
+                       </trans-unit>
+                       <trans-unit id="error.token_expired" resname="error.token_expired">
+                               <source>Your link is invalid or has expired.</source>
+                       </trans-unit>
+                       <trans-unit id="error.password" resname="error.password">
+                               <source>The entered password is invalid or the passwords do not match. Please try again.</source>
+                       </trans-unit>
+               </body>
+       </file>
+</xliff>
index 8fde4ee..aca675f 100644 (file)
@@ -39,6 +39,9 @@
                                                 <f:translate key="login.submit" />
                                             </button>
                                         </div>
+                                        <f:if condition="{enablePasswordReset}">
+                                            <f:render section="ResetPassword" arguments="{_all}" optional="true" />
+                                        </f:if>
                                         <ul class="list-unstyled typo3-login-links">
                                             <f:for each="{loginProviders}" as="provider" key="providerKey">
                                                 <f:if condition="{provider.label}">
@@ -51,6 +54,9 @@
                                     </form>
                                 </div>
                             </f:then>
+                            <f:else if="{enablePasswordReset}">
+                                <f:render section="ResetPassword" arguments="{_all}" />
+                            </f:else>
                             <f:else>
                                 <form action="index.php" method="post" name="loginform">
                                     <input type="hidden" name="login_status" value="logout" />
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/AmbiguousResetRequested.html b/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/AmbiguousResetRequested.html
new file mode 100644 (file)
index 0000000..ca7ee8f
--- /dev/null
@@ -0,0 +1,12 @@
+<f:layout name="SystemEmail" />
+<f:section name="Subject">Password reset requested at "{typo3.sitename}"</f:section>
+<f:section name="Title">Your email was used to trigger a password reset</f:section>
+<f:section name="Main">
+    <h4>Your email address is used multiple times</h4>
+    <p>It seems like your email address "{email}" is used for multiple Backend accounts in this TYPO3 installation.</p>
+    <p>We cannot reset your account for this reason. Reach out to your TYPO3 administrator to change this.</p>
+    <f:if condition="{normalizedParams.remoteAddress}">
+        <p>The email was requested by IP address <strong>{normalizedParams.remoteAddress}</strong>.</p>
+    </f:if>
+    <p>If you did not initiate a password reset, reach out to your administrator.</p>
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/AmbiguousResetRequested.txt b/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/AmbiguousResetRequested.txt
new file mode 100644 (file)
index 0000000..d30173f
--- /dev/null
@@ -0,0 +1,13 @@
+<f:layout name="SystemEmail" />
+<f:section name="Subject">Password reset requested at "{typo3.sitename}"</f:section>
+<f:section name="Title">Your email "{email}" was used to trigger a password reset.</f:section>
+<f:section name="Main">
+However, it seems like your email address "{email}" is used for multiple Backend accounts in this TYPO3 installation.
+
+We cannot reset your account for this reason. Reach out to your TYPO3 administrator to change this.
+<f:if condition="{normalizedParams.remoteAddress}">
+The email was requested by IP address "{normalizedParams.remoteAddress}".
+</f:if>
+
+If you did not initiate a password reset, reach out to your administrator.
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/ResetRequested.html b/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/ResetRequested.html
new file mode 100644 (file)
index 0000000..1a6e01b
--- /dev/null
@@ -0,0 +1,12 @@
+<f:layout name="SystemEmail" />
+<f:section name="Subject">Password reset requested at "{typo3.sitename}"</f:section>
+<f:section name="Title">Your email was used to trigger a password reset</f:section>
+<f:section name="Main">
+    <h4>Click on the link below to reset your password</h4>
+    <p><a href="{resetLink}">Reset your password now</a></p>
+    <p>The link is valid for 2 hours.</p>
+    <f:if condition="{normalizedParams.remoteAddress}">
+        <p>The email was requested by IP address <strong>{normalizedParams.remoteAddress}</strong>.</p>
+    </f:if>
+    <p>If you did not initiate a password reset, reach out to your administrator.</p>
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/ResetRequested.txt b/typo3/sysext/backend/Resources/Private/Templates/Email/PasswordReset/ResetRequested.txt
new file mode 100644 (file)
index 0000000..53016b4
--- /dev/null
@@ -0,0 +1,15 @@
+<f:layout name="SystemEmail" />
+<f:section name="Subject">Password reset requested at "{typo3.sitename}"</f:section>
+<f:section name="Title">Your email was used to trigger a password reset</f:section>
+<f:section name="Main">
+
+Click on the link below to reset your password:
+{resetLink}
+
+Please note: The link is valid for 2 hours.
+<f:if condition="{normalizedParams.remoteAddress}">
+The email was requested by IP address "{normalizedParams.remoteAddress}".
+</f:if>
+
+If you did not initiate a password reset, reach out to your administrator.
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Login/ForgetPasswordForm.html b/typo3/sysext/backend/Resources/Private/Templates/Login/ForgetPasswordForm.html
new file mode 100644 (file)
index 0000000..e12a8bc
--- /dev/null
@@ -0,0 +1,47 @@
+<f:layout name="Login" />
+
+<f:section name="ResetPassword">
+    <div class="typo3-login-form t3js-login-formfields">
+        <h2><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:headline" /></h2>
+        <f:if condition="{resetInitiated}">
+            <f:then>
+                <div class="callout callout-success">
+                    <h4 class="callout-title"><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:email_sent.headline" /></h4>
+                    <div class="callout-body"><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:email_sent.message" arguments="{0: email}" /></div>
+                </div>
+                <p class="pull-right">
+                    <f:be.link route="login"><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:button.back_to_login" /></f:be.link>.
+                </p>
+            </f:then>
+            <f:else>
+                <f:if condition="{invalidEmail}">
+                    <f:be.infobox message="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:error.invalid_email')}" state="1" />
+                </f:if>
+                <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:instructions.email" /></p>
+                <form action="{f:be.uri(route: 'password_forget_initiate_reset')}" method="post" name="forget-password-form" id="typo3-forget-password-form">
+                    <f:form.hidden name="loginProvider" value="{loginProviderIdentifier}" />
+                    <div class="form-group">
+                        <div class="form-control-wrap">
+                            <div class="form-control-holder">
+                                <input type="email" name="email" value="{email}" placeholder="you@example.com" class="form-control input-login t3js-clearable" autofocus="autofocus" required="required" />
+                            </div>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <button class="btn btn-block btn-login" type="submit" name="forgetPasswordSubmit">
+                            <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:button.initiate" />
+                        </button>
+                    </div>
+                </form>
+                <p>
+                    <small>
+                        <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:note.other_providers" />
+                    </small>
+                </p>
+                <p class="pull-right">
+                    <f:be.link route="login"><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:button.back_to_login" /></f:be.link>.
+                </p>
+            </f:else>
+        </f:if>
+    </div>
+</f:section>
diff --git a/typo3/sysext/backend/Resources/Private/Templates/Login/ResetPasswordForm.html b/typo3/sysext/backend/Resources/Private/Templates/Login/ResetPasswordForm.html
new file mode 100644 (file)
index 0000000..31f9ad2
--- /dev/null
@@ -0,0 +1,53 @@
+<f:layout name="Login" />
+
+<f:section name="ResetPassword">
+    <div class="typo3-login-form t3js-login-formfields">
+        <h2><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:headline" /></h2>
+        <f:if condition="{invalidToken}">
+            <f:then>
+                <f:be.infobox message="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:error.token_expired')}" title="" state="1" />
+                <p>
+                    <f:be.link class="btn btn-block btn-login" route="password_forget" parameters="{loginProvider: loginProviderIdentifier}"><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:button.restart" /></f:be.link>
+                </p>
+            </f:then>
+            <f:else if="{resetExecuted}">
+                <div class="callout callout-success">
+                    <div class="media">
+                        <div class="media-body">
+                            <h4 class="callout-title"><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:reset_success.headline" /></h4>
+                            <div class="callout-body"><f:format.raw><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:reset_success.message" arguments="{0: '{f:be.uri(route: \'login\')}'}"/></f:format.raw></div>
+                        </div>
+                    </div>
+                </div>
+            </f:else>
+            <f:else>
+                <f:if condition="{error}">
+                    <f:be.infobox message="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:error.password')}" state="1" />
+                </f:if>
+                <p><f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:instructions.password" /></p>
+                <form action="{f:be.uri(route: 'password_reset_finish', parameters: '{i: identity, t: token, e: expirationDate}')}" method="post" name="forget-password-form" id="typo3-forget-password-form">
+                    <f:form.hidden name="loginProvider" value="{loginProviderIdentifier}" />
+                    <div class="form-group">
+                        <div class="form-control-wrap">
+                            <div class="form-control-holder">
+                                <input type="password" name="password" placeholder="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:input.password')}" value="" class="form-control input-login t3js-clearable" autocomplete="off" autofocus="autofocus" required="required" />
+                            </div>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <div class="form-control-wrap">
+                            <div class="form-control-holder">
+                                <input type="password" name="passwordrepeat" placeholder="{f:translate(key: 'LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:input.passwordrepeat')}" value="" autocomplete="off" class="form-control input-login t3js-clearable" required="required" />
+                            </div>
+                        </div>
+                    </div>
+                    <div class="form-group">
+                        <button class="btn btn-block btn-login" type="submit" name="forgotPasswordSubmit">
+                            <f:translate key="LLL:EXT:backend/Resources/Private/Language/locallang_reset_password.xlf:button.reset" />
+                        </button>
+                    </div>
+                </form>
+            </f:else>
+        </f:if>
+    </div>
+</f:section>
index 0eda042..a3603ce 100644 (file)
         </div>
     </div>
 </f:section>
+<f:section name="ResetPassword">
+    <div class="form-group forgot-password">
+        <div class="form-control-wrap">
+            <div class="form-control-holder">
+                <div class="pull-right">
+                    <f:be.link route="password_forget" parameters="{loginProvider: loginProviderIdentifier}">{f:translate(key: 'login.password_forget')}</f:be.link>
+                </div>
+            </div>
+        </div>
+    </div>
+</f:section>
diff --git a/typo3/sysext/backend/Tests/Functional/Authentication/Fixtures/be_users.xml b/typo3/sysext/backend/Tests/Functional/Authentication/Fixtures/be_users.xml
new file mode 100644 (file)
index 0000000..26d1e1d
--- /dev/null
@@ -0,0 +1,86 @@
+<?xml version="1.0" encoding="utf-8"?>
+<dataset>
+    <be_users>
+        <uid>1</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>admin</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>admin@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>2</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>0</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+    </be_users>
+    <be_users>
+        <uid>3</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-with-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>0</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>editor-with-email@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>4</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-with-email-but-no-password</username>
+        <password></password>
+        <admin>0</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>editor-with-email-but-no-password@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>5</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-one-with-duplicate-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>0</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>duplicate@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>6</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-two-with-duplicate-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>0</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>duplicate@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>7</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>disabled-editor-with-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>0</admin>
+        <disable>1</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>editor-with-email@example.com</email>
+    </be_users>
+</dataset>
diff --git a/typo3/sysext/backend/Tests/Functional/Authentication/Fixtures/be_users_only_admins.xml b/typo3/sysext/backend/Tests/Functional/Authentication/Fixtures/be_users_only_admins.xml
new file mode 100644 (file)
index 0000000..dc004a7
--- /dev/null
@@ -0,0 +1,87 @@
+<?xml version="1.0" encoding="utf-8"?>
+<dataset>
+    <be_users>
+        <uid>1</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>admin</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>admin@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>2</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email></email>
+    </be_users>
+    <be_users>
+        <uid>3</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-with-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>editor-with-email@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>4</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-with-email-but-no-password</username>
+        <password></password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>editor-with-email-but-no-password@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>5</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-one-with-duplicate-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>duplicate@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>6</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>editor-two-with-duplicate-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>0</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>duplicate@example.com</email>
+    </be_users>
+    <be_users>
+        <uid>7</uid>
+        <pid>0</pid>
+        <tstamp>1366642540</tstamp>
+        <username>disabled-editor-with-email</username>
+        <password>$1$tCrlLajZ$C0sikFQQ3SWaFAZ1Me0Z/1</password>
+        <admin>1</admin>
+        <disable>1</disable>
+        <starttime>0</starttime>
+        <endtime>0</endtime>
+        <email>editor-with-email@example.com</email>
+    </be_users>
+</dataset>
diff --git a/typo3/sysext/backend/Tests/Functional/Authentication/PasswordResetTest.php b/typo3/sysext/backend/Tests/Functional/Authentication/PasswordResetTest.php
new file mode 100644 (file)
index 0000000..a519412
--- /dev/null
@@ -0,0 +1,155 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Backend\Tests\Functional\Authentication;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Log\LoggerInterface;
+use TYPO3\CMS\Backend\Authentication\PasswordReset;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+class PasswordResetTest extends FunctionalTestCase
+{
+    /**
+     * @test
+     */
+    public function isNotEnabledWorks(): void
+    {
+        $subject = new PasswordReset();
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = false;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = false;
+        self::assertFalse($subject->isEnabled());
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        self::assertFalse($subject->isEnabled());
+    }
+
+    /**
+     * @test
+     */
+    public function isNotEnabledWithNoUsers(): void
+    {
+        $subject = new PasswordReset();
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = false;
+        self::assertFalse($subject->isEnabled());
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        self::assertFalse($subject->isEnabled());
+    }
+
+    /**
+     * @test
+     */
+    public function isEnabledExcludesAdministrators(): void
+    {
+        $this->importDataSet(__DIR__ . '/Fixtures/be_users_only_admins.xml');
+        $subject = new PasswordReset();
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = false;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = false;
+        self::assertFalse($subject->isEnabled());
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = false;
+        self::assertFalse($subject->isEnabled());
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        self::assertTrue($subject->isEnabled());
+    }
+
+    /**
+     * @test
+     */
+    public function noEmailIsFound(): void
+    {
+        $this->importDataSet(__DIR__ . '/Fixtures/be_users.xml');
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport'] = 'null';
+        $emailAddress = 'does-not-exist@example.com';
+        $subject = new PasswordReset();
+        $loggerProphecy = $this->prophesize(LoggerInterface::class);
+        $loggerProphecy->warning()->withArguments(['Password reset requested for email but no valid users'])->shouldBeCalled();
+        $subject->setLogger($loggerProphecy->reveal());
+        $context = new Context();
+        $request = new ServerRequest();
+        $subject->initiateReset($request, $context, $emailAddress);
+    }
+
+    /**
+     * @test
+     */
+    public function ambiguousEmailIsTriggeredForMultipleValidUsers(): void
+    {
+        $this->importDataSet(__DIR__ . '/Fixtures/be_users.xml');
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport'] = 'null';
+        $emailAddress = 'duplicate@example.com';
+        $subject = new PasswordReset();
+        $loggerProphecy = $this->prophesize(LoggerInterface::class);
+        $loggerProphecy->warning()->withArguments(['Password reset sent to email address ' . $emailAddress . ' but multiple accounts found'])->shouldBeCalled();
+        $subject->setLogger($loggerProphecy->reveal());
+        $context = new Context();
+        $request = new ServerRequest();
+        $subject->initiateReset($request, $context, $emailAddress);
+    }
+
+    /**
+     * @test
+     */
+    public function passwordResetEmailIsTriggeredForValidUser(): void
+    {
+        $this->importDataSet(__DIR__ . '/Fixtures/be_users.xml');
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport'] = 'null';
+        $emailAddress = 'editor-with-email@example.com';
+        $username = 'editor-with-email';
+        $subject = new PasswordReset();
+        $loggerProphecy = $this->prophesize(LoggerInterface::class);
+        $loggerProphecy->info()->withArguments(['Sent password reset email to email address ' . $emailAddress . ' for user ' . $username])->shouldBeCalled();
+        $subject->setLogger($loggerProphecy->reveal());
+        $context = new Context();
+        $request = new ServerRequest();
+        $subject->initiateReset($request, $context, $emailAddress);
+    }
+
+    /**
+     * @test
+     */
+    public function invalidTokenCannotResetPassword(): void
+    {
+        $this->importDataSet(__DIR__ . '/Fixtures/be_users.xml');
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] = true;
+        $GLOBALS['TYPO3_CONF_VARS']['MAIL']['transport'] = 'null';
+        $subject = new PasswordReset();
+        $loggerProphecy = $this->prophesize(LoggerInterface::class);
+        $loggerProphecy->debug()->withArguments(['Password reset not possible due to weak password'])->shouldBeCalled();
+        $subject->setLogger($loggerProphecy->reveal());
+
+        $context = new Context();
+        $request = new ServerRequest();
+        $request = $request->withQueryParams(['t' => 'token', 'i' => 'identity', 'e' => 13465444]);
+        $subject->resetPassword($request, $context);
+
+        // Now with a password
+        $request = $request->withParsedBody(['password' => 'str0NGpassw0RD!', 'passwordrepeat' => 'str0NGpassw0RD!']);
+        $loggerProphecy = $this->prophesize(LoggerInterface::class);
+        $loggerProphecy->warning()->withArguments(['Password reset not possible. Valid user for token not found.'])->shouldBeCalled();
+        $subject->setLogger($loggerProphecy->reveal());
+        $subject->resetPassword($request, $context);
+    }
+}
diff --git a/typo3/sysext/backend/ext_tables.sql b/typo3/sysext/backend/ext_tables.sql
new file mode 100644 (file)
index 0000000..338e552
--- /dev/null
@@ -0,0 +1,6 @@
+#
+# Table structure for table 'be_users'
+#
+CREATE TABLE be_users (
+       password_reset_token varchar(100) DEFAULT '' NOT NULL
+);
index 126bf8b..629a75e 100644 (file)
@@ -16,12 +16,16 @@ namespace TYPO3\CMS\Beuser\Controller;
  */
 
 use TYPO3\CMS\Backend\Authentication\Event\SwitchUserEvent;
+use TYPO3\CMS\Backend\Authentication\PasswordReset;
+use TYPO3\CMS\Beuser\Domain\Model\BackendUser;
 use TYPO3\CMS\Beuser\Domain\Repository\BackendUserGroupRepository;
 use TYPO3\CMS\Beuser\Domain\Repository\BackendUserRepository;
 use TYPO3\CMS\Beuser\Domain\Repository\BackendUserSessionRepository;
 use TYPO3\CMS\Beuser\Service\ModuleDataStorageService;
 use TYPO3\CMS\Beuser\Service\UserInformationService;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Messaging\FlashMessage;
 use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
 use TYPO3\CMS\Core\Session\SessionManager;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -205,6 +209,38 @@ class BackendUserController extends ActionController
     }
 
     /**
+     * Starts the password reset process for a selected user.
+     *
+     * @param int $user
+     */
+    public function initiatePasswordResetAction(int $user): void
+    {
+        $context = GeneralUtility::makeInstance(Context::class);
+        /** @var BackendUser $user */
+        $user = $this->backendUserRepository->findByUid($user);
+        if (!$user || !$user->isPasswordResetEnabled() || !$context->getAspect('backend.user')->isAdmin()) {
+            // Add an error message
+            $this->addFlashMessage(
+                LocalizationUtility::translate('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:flashMessage.resetPassword.error.text', 'beuser'),
+                LocalizationUtility::translate('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:flashMessage.resetPassword.error.title', 'beuser'),
+                FlashMessage::ERROR
+            );
+        } else {
+            GeneralUtility::makeInstance(PasswordReset::class)->initiateReset(
+                $GLOBALS['TYPO3_REQUEST'],
+                $context,
+                $user->getEmail()
+            );
+            $this->addFlashMessage(
+                LocalizationUtility::translate('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:flashMessage.resetPassword.success.text', 'beuser', [$user->getEmail()]),
+                LocalizationUtility::translate('LLL:EXT:beuser/Resources/Private/Language/locallang.xlf:flashMessage.resetPassword.success.title', 'beuser'),
+                FlashMessage::OK
+            );
+        }
+        $this->forward('index');
+    }
+
+    /**
      * Attaches one backend user to the compare list
      *
      * @param int $uid
index 428905a..3bf683e 100644 (file)
@@ -14,6 +14,9 @@ namespace TYPO3\CMS\Beuser\Domain\Model;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Backend\Authentication\PasswordReset;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
 /**
  * Model for backend user
  * @internal This class is a TYPO3 Backend implementation and is not considered part of the Public TYPO3 API.
@@ -137,6 +140,16 @@ class BackendUser extends \TYPO3\CMS\Extbase\Domain\Model\BackendUser
     }
 
     /**
+     * Check if the user (not the currently logged in user) is allowed to trigger a password reset
+     *
+     * @return bool
+     */
+    public function isPasswordResetEnabled(): bool
+    {
+        return !$this->isCurrentlyLoggedIn() && GeneralUtility::makeInstance(PasswordReset::class)->isEnabledForUser((int)$this->getUid());
+    }
+
+    /**
      * Gets the currently logged in backend user
      *
      * @return \TYPO3\CMS\Core\Authentication\BackendUserAuthentication
index f82380e..8536579 100644 (file)
                        <trans-unit id="backendUser" resname="backendUser">
                                <source>Backend User</source>
                        </trans-unit>
+                       <trans-unit id="flashMessage.resetPassword.error.title" resname="flashMessage.resetPassword.error.title">
+                               <source>Password reset not triggered</source>
+                       </trans-unit>
+                       <trans-unit id="flashMessage.resetPassword.error.text" resname="flashMessage.resetPassword.error.text">
+                               <source>Sorry, the password of this user cannot be reset.</source>
+                       </trans-unit>
+                       <trans-unit id="flashMessage.resetPassword.success.title" resname="flashMessage.resetPassword.success.title">
+                               <source>Password reset triggered</source>
+                       </trans-unit>
+                       <trans-unit id="flashMessage.resetPassword.success.text" resname="flashMessage.resetPassword.success.text">
+                               <source>Password reset for email address %s initiated.</source>
+                       </trans-unit>
+                       <trans-unit id="resetPassword.label" resname="resetPassword.label">
+                               <source>Reset Password</source>
+                       </trans-unit>
+                       <trans-unit id="resetPassword.confirmation.header" resname="resetPassword.confirmation.header">
+                               <source>Reset Password</source>
+                       </trans-unit>
+                       <trans-unit id="resetPassword.confirmation.text" resname="resetPassword.confirmation.text">
+                               <source>Are you sure to reset the password for %s?</source>
+                       </trans-unit>
                </body>
        </file>
 </xliff>
index 934b0a3..28505ca 100644 (file)
             </f:if>
         </div>
         <div class="btn-group" role="group">
+            <f:if condition="{backendUser.passwordResetEnabled}">
+                <f:then>
+                    <f:link.action
+                        class="btn btn-default t3js-modal-trigger"
+                        action="initiatePasswordReset"
+                        arguments="{user: backendUser.uid}"
+                        title="{f:translate(key: 'resetPassword.label')}"
+                        data="{severity: 'warning', title: '{f:translate(key: \'resetPassword.confirmation.header\')}', content: '{f:translate(key: \'resetPassword.confirmation.text\', arguments: {0: \'{backendUser.email}\'})}', button-close-text: '{f:translate(key: \'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel\')}'}">
+                        <core:icon identifier="actions-edit-undo" />
+                    </f:link.action>
+                </f:then>
+                <f:else>
+                    <span class="btn btn-default disabled"><core:icon identifier="empty-empty" /></span>
+                </f:else>
+            </f:if>
             <f:link.action action="show" arguments="{uid: backendUser.uid}" class="btn btn-default" title="{f:translate(key: 'details')}">
                 <core:icon identifier="actions-system-options-view" size="small"/>
             </f:link.action>
index b665971..1d820c7 100644 (file)
@@ -8,7 +8,7 @@ defined('TYPO3_MODE') or die();
     'tx_Beuser',
     'top',
     [
-        \TYPO3\CMS\Beuser\Controller\BackendUserController::class => 'index, show, addToCompareList, removeFromCompareList, compare, online, terminateBackendUserSession',
+        \TYPO3\CMS\Beuser\Controller\BackendUserController::class => 'index, show, addToCompareList, removeFromCompareList, compare, online, terminateBackendUserSession, initiatePasswordReset',
         \TYPO3\CMS\Beuser\Controller\BackendUserGroupController::class => 'index'
     ],
     [
index 0c63d25..b6c8408 100644 (file)
@@ -2495,6 +2495,14 @@ TCAdefaults.sys_note.email = ' . $this->user['email'];
                     // Setting the UC array. It's needed with fetchGroupData first, due to default/overriding of values.
                     $this->backendSetUC();
                     if ($this->loginSessionStarted) {
+                        // Also, if there is a recovery link set, unset it now
+                        // this will be moved into its own Event at a later stage.
+                        // If a token was set previously, this is now unset, as it was now possible to log-in
+                        if ($this->user['password_reset_token'] ?? '') {
+                            GeneralUtility::makeInstance(ConnectionPool::class)
+                                ->getConnectionForTable($this->user_table)
+                                ->update($this->user_table, ['password_reset_token' => ''], ['uid' => $this->user['uid']]);
+                        }
                         // Process hooks
                         $hooks = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_userauthgroup.php']['backendUserLogin'];
                         foreach ($hooks ?? [] as $_funcRef) {
index 43de5a7..bb1ca96 100644 (file)
@@ -264,4 +264,23 @@ class Environment
     {
         return self::$os === 'UNIX';
     }
+
+    /**
+     * Returns the currently configured Environment information as array.
+     *
+     * @return array
+     */
+    public static function toArray(): array
+    {
+        return [
+            'context' => (string)self::getContext(),
+            'cli' => self::isCli(),
+            'projectPath' => self::getProjectPath(),
+            'publicPath' => self::getPublicPath(),
+            'varPath' => self::getVarPath(),
+            'configPath' => self::getConfigPath(),
+            'currentScript' => self::getCurrentScript(),
+            'os' => self::isWindows() ? 'WINDOWS' : 'UNIX'
+        ];
+    }
 }
index fcfcb1f..728b59c 100644 (file)
@@ -24,4 +24,6 @@ class Login
     public const LOGOUT = 2;
     public const ATTEMPT = 3;
     public const SEND_FAILURE_WARNING_EMAIL = 4;
+    public const PASSWORD_RESET_REQUEST = 5;
+    public const PASSWORD_RESET_ACCOMPLISHED = 6;
 }
index 7ace0f4..e4a42b6 100644 (file)
@@ -1113,6 +1113,8 @@ return [
         'userUploadDir' => '',
         'warning_email_addr' => '',
         'warning_mode' => 0,
+        'passwordReset' => true,
+        'passwordResetForAdmins' => true,
         'lockIP' => 0,
         'lockIPv6' => 0,
         'sessionTimeout' => 28800,  // a backend user logged in for 8 hours
index c8328a7..150fb6b 100644 (file)
@@ -271,6 +271,12 @@ BE:
               '1': 'Send a notification-email every time a backend user logs in'
               '2': 'Send a notification-email every time an ADMIN backend user logs in'
             description: 'Send emails to <code>warning_email_addr</code> upon backend-login'
+        passwordReset:
+          type: bool
+          description: 'Enable password reset functionality for TYPO3 Backend users. Can be disabled for systems where only e.g. LDAP / OAuth login is allowed.'
+        passwordResetForAdmins:
+          type: bool
+          description: 'Enable password reset functionality on the backend login for TYPO3 Administrators as well. Disable this option for increased security.'
         lockIP:
             type: int
             allowedValues:
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-89513-PasswordResetForBackendUsers.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-89513-PasswordResetForBackendUsers.rst
new file mode 100644 (file)
index 0000000..6893e4b
--- /dev/null
@@ -0,0 +1,83 @@
+.. include:: ../../Includes.txt
+
+================================================================
+Feature: #89513 - Password Reset Functionality For Backend Users
+================================================================
+
+See :issue:`89513`
+
+Description
+===========
+
+It is now possible for TYPO3 Backend users that use the default username / password
+mechanism to log in, to reset their password via triggering an email through the
+Login form.
+
+The reset link is only shown if there is at least one user that matches the
+following criteria:
+* The user has a password entered previously (used to indicate that no third-party login was used)
+* The user has a valid email added to his user record
+* The user is neither deleted nor disabled
+* The email address is only used once within Backend users
+
+Once the user has entered their email address, an email is sent out with a
+link to set a new password which needs to have a least 8 characters.
+
+The link is valid for 2 hours, and a token is added to the link.
+
+If the password was provided correctly, it is updated for the user and can log-in.
+
+Some notes on security:
++ When having multiple users with the same email address
++ No information disclosure is built-in, so if the email address is not in the system, it is not known to the outside
++ Rate limiting is activated for allowing three emails to be sent within 30 minutes per email address
++ Tokens are stored for the backend users in the database but hashed again just like the password
++ When a user has logged in successfully (e.g. because he/she remembered the password) the token is removed from the database, effectively invalidating all existing email links
+
+The feature is active by default and can be deactivated completely via the system-wide
+configuration option:
+
+:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset']`
+
+Optionally it is possible to restrict this feature to non-admins only, by setting
+the following system-wide option to "false".
+
+:php:`$GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins']`
+
+Both options are available to be configured within the Maintenance Area
+=> Setings module, or in the Install Tool, but can be set manually via
+:php:`typo3conf/LocalConfiguration.php` or :php:`typo3conf/AdditionalConfiguration.php`.
+
+In addition, it is possible for administrators to reset a users' password.
+This is especially useful for security purposes so an administrator does not
+need to send a password over the wire in plaintext (e.g. email) to a user.
+
+The administrator can use the CLI command:
+
+   ./typo3/sysext/core/bin/typo3 backend:resetpassword https://www.example.com/typo3/ editor@example.com
+
+Alternatively it is possible for administrators to use the "Backend users" module
+and select the password reset button to initiate the password reset process for
+a specific user.
+
+Both options are only available for users that have an email address and a password
+set.
+
+Impact
+======
+
+Administrators do not have additional overhead to re-set passwords for editors,
+and they do not need to add the passwords for editors themselves.
+
+In addition, the email can be styled completely for HTML and plain-text only
+versions through the Fluid-based templated email feature.
+
+Further improvements on the horizon:
+* Trigger a password-reset via CLI or the Backend users module
+* Trigger a password-set email on creation of a new user, so the admin has no
+  involvement in needing to know or share the password
+* Require an email address when adding backend users to enable this feature for everybody
+* Implement ways to allow the password reset functionality via different ways than email
+* Find solutions for handling third-party authentication system
+
+.. index:: LocalConfiguration, ext:backend