diff --git a/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php b/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php index 2d5d78fbf7..147b593aa1 100644 --- a/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php +++ b/core/modules/serialization/src/EventSubscriber/UserRouteAlterSubscriber.php @@ -47,6 +47,7 @@ public function onRoutingAlterAddFormats(RouteBuildEvent $event) { 'user.login_status.http', 'user.login.http', 'user.logout.http', + 'user.pass.http', ]; $routes = $event->getRouteCollection(); foreach ($route_names as $route_name) { diff --git a/core/modules/user/src/Controller/UserAuthenticationController.php b/core/modules/user/src/Controller/UserAuthenticationController.php index 3aff75ff57..503e06ec6c 100644 --- a/core/modules/user/src/Controller/UserAuthenticationController.php +++ b/core/modules/user/src/Controller/UserAuthenticationController.php @@ -7,9 +7,11 @@ use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Flood\FloodInterface; use Drupal\Core\Routing\RouteProviderInterface; +use Drupal\Core\Session\AccountInterface; use Drupal\user\UserAuthInterface; use Drupal\user\UserInterface; use Drupal\user\UserStorageInterface; +use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -87,6 +89,13 @@ class UserAuthenticationController extends ControllerBase implements ContainerIn protected $serializerFormats = []; /** + * A logger instance. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** * Constructs a new UserAuthenticationController object. * * @param \Drupal\Core\Flood\FloodInterface $flood @@ -103,8 +112,10 @@ class UserAuthenticationController extends ControllerBase implements ContainerIn * The serializer. * @param array $serializer_formats * The available serialization formats. + * @param \Psr\Log\LoggerInterface $logger + * A logger instance. */ - public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, RouteProviderInterface $route_provider, Serializer $serializer, array $serializer_formats) { + public function __construct(FloodInterface $flood, UserStorageInterface $user_storage, CsrfTokenGenerator $csrf_token, UserAuthInterface $user_auth, RouteProviderInterface $route_provider, Serializer $serializer, array $serializer_formats, LoggerInterface $logger) { $this->flood = $flood; $this->userStorage = $user_storage; $this->csrfToken = $csrf_token; @@ -112,6 +123,7 @@ public function __construct(FloodInterface $flood, UserStorageInterface $user_st $this->serializer = $serializer; $this->serializerFormats = $serializer_formats; $this->routeProvider = $route_provider; + $this->logger = $logger; } /** @@ -135,7 +147,8 @@ public static function create(ContainerInterface $container) { $container->get('user.auth'), $container->get('router.route_provider'), $serializer, - $formats + $formats, + $container->get('logger.factory')->get('user') ); } @@ -208,6 +221,56 @@ public function login(Request $request) { } /** + * Resets a user password. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response object. + */ + public function resetPassword(Request $request) { + $format = $this->getRequestFormat($request); + + $content = $request->getContent(); + $credentials = $this->serializer->decode($content, $format); + + // Check if a name or mail is provided. + if (!isset($credentials['name']) && !isset($credentials['mail'])) { + throw new BadRequestHttpException('Missing credentials.name or credentials.mail'); + } + + // Load by name if provided. + if (isset($credentials['name'])) { + $users = $this->userStorage->loadByProperties(['name' => trim($credentials['name'])]); + } + elseif (isset($credentials['mail'])) { + $users = $this->userStorage->loadByProperties(['mail' => trim($credentials['mail'])]); + } + + /** @var \Drupal\Core\Session\AccountInterface $account */ + $account = reset($users); + if ($account && $account->id()) { + if ($this->userIsBlocked($account->getAccountName())) { + throw new BadRequestHttpException('The user has not been activated or is blocked.'); + } + + // Send the password reset email. + $mail = _user_mail_notify('password_reset', $account, $account->getPreferredLangcode()); + if (empty($mail)) { + throw new BadRequestHttpException('Unable to send email. Contact the site administrator if the problem persists.'); + } + else { + $this->logger->notice('Password reset instructions mailed to %name at %email.', ['%name' => $account->getAccountName(), '%email' => $account->getEmail()]); + return new Response(); + } + } + + // Error if no users found with provided name or mail. + throw new BadRequestHttpException('Unrecognized username or email address.'); + } + + /** * Verifies if the user is blocked. * * @param string $name diff --git a/core/modules/user/src/Tests/UserLoginTest.php b/core/modules/user/src/Tests/UserLoginTest.php index a83e0c62b8..02f8fde2a6 100644 --- a/core/modules/user/src/Tests/UserLoginTest.php +++ b/core/modules/user/src/Tests/UserLoginTest.php @@ -12,6 +12,8 @@ */ class UserLoginTest extends WebTestBase { + use UserResetEmailTestTrait; + /** * Tests login with destination. */ @@ -198,13 +200,7 @@ public function resetUserPassword($user) { $this->drupalGet('user/password'); $edit['name'] = $user->getUsername(); $this->drupalPostForm(NULL, $edit, 'Submit'); - $_emails = $this->drupalGetMails(); - $email = end($_emails); - $urls = []; - preg_match('#.+user/reset/.+#', $email['body'], $urls); - $resetURL = $urls[0]; - $this->drupalGet($resetURL); - $this->drupalPostForm(NULL, NULL, 'Log in'); + $this->loginFromResetEmail(); } } diff --git a/core/modules/user/src/Tests/UserResetEmailTestTrait.php b/core/modules/user/src/Tests/UserResetEmailTestTrait.php new file mode 100644 index 0000000000..2fbbbbf78d --- /dev/null +++ b/core/modules/user/src/Tests/UserResetEmailTestTrait.php @@ -0,0 +1,29 @@ +drupalGetMails(); + $email = end($_emails); + $urls = []; + preg_match('#.+user/reset/.+#', $email['body'], $urls); + $resetURL = $urls[0]; + $this->drupalGet($resetURL); + $this->drupalPostForm(NULL, NULL, 'Log in'); + } + +} diff --git a/core/modules/user/tests/src/Functional/UserLoginHttpTest.php b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php index 29b97277c0..90e729b763 100644 --- a/core/modules/user/tests/src/Functional/UserLoginHttpTest.php +++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php @@ -6,6 +6,7 @@ use Drupal\Core\Url; use Drupal\Tests\BrowserTestBase; use Drupal\user\Controller\UserAuthenticationController; +use Drupal\user\Tests\UserResetEmailTestTrait; use GuzzleHttp\Cookie\CookieJar; use Psr\Http\Message\ResponseInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; @@ -14,12 +15,14 @@ use Symfony\Component\Serializer\Serializer; /** - * Tests login via direct HTTP. + * Tests login and password reset via direct HTTP. * * @group user */ class UserLoginHttpTest extends BrowserTestBase { + use UserResetEmailTestTrait; + /** * Modules to install. * @@ -188,6 +191,92 @@ public function testLogin() { } /** + * Executes a password HTTP request. + * + * @param array $request_body + * The request body. + * @param string $format + * The format to use to make the request. + * + * @return \Psr\Http\Message\ResponseInterface + * The HTTP response. + */ + protected function passwordRequest(array $request_body, $format = 'json') { + $password_reset_url = Url::fromRoute('user.pass.http') + ->setRouteParameter('_format', $format) + ->setAbsolute(); + + $result = \Drupal::httpClient()->post($password_reset_url->toString(), [ + 'body' => $this->serializer->encode($request_body, $format), + 'headers' => [ + 'Accept' => "application/$format", + ], + 'http_errors' => FALSE, + 'cookies' => $this->cookies, + ]); + + return $result; + } + + /** + * Tests user password reset. + */ + public function testPasswordReset() { + // Create a user account. + $account = $this->drupalCreateUser(); + + foreach ([FALSE, TRUE] as $serialization_enabled_option) { + if ($serialization_enabled_option) { + /** @var \Drupal\Core\Extension\ModuleInstaller $module_installer */ + $module_installer = $this->container->get('module_installer'); + $module_installer->install(['serialization']); + $formats = ['json', 'xml', 'hal_json']; + } + else { + // Without the serialization module only JSON is supported. + $formats = ['json']; + } + + foreach ($formats as $format) { + $response = $this->passwordRequest([], $format); + $this->assertHttpResponseWithMessage($response, 400, 'Missing credentials.name or credentials.mail', $format); + + $response = $this->passwordRequest(['name' => 'dramallama'], $format); + $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format); + + $response = $this->passwordRequest(['mail' => 'llama@drupal.org'], $format); + $this->assertHttpResponseWithMessage($response, 400, 'Unrecognized username or email address.', $format); + + $account + ->block() + ->save(); + + $response = $this->passwordRequest(['name' => $account->getAccountName()], $format); + $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format); + + $response = $this->passwordRequest(['mail' => $account->getEmail()], $format); + $this->assertHttpResponseWithMessage($response, 400, 'The user has not been activated or is blocked.', $format); + + $account + ->activate() + ->save(); + + $account-> + + $response = $this->passwordRequest(['name' => $account->getAccountName()], $format); + $this->assertEquals(200, $response->getStatusCode()); + $this->loginFromResetEmail(); + $this->drupalLogout(); + + $response = $this->passwordRequest(['mail' => $account->getEmail()], $format); + $this->assertEquals(200, $response->getStatusCode()); + $this->loginFromResetEmail(); + $this->drupalLogout(); + } + } + } + + /** * Gets a value for a given key from the response. * * @param \Psr\Http\Message\ResponseInterface $response diff --git a/core/modules/user/user.routing.yml b/core/modules/user/user.routing.yml index 319f219e8c..e38bb7acad 100644 --- a/core/modules/user/user.routing.yml +++ b/core/modules/user/user.routing.yml @@ -111,6 +111,15 @@ user.pass: options: _maintenance_access: TRUE +user.pass.http: + path: '/user/password' + defaults: + _controller: \Drupal\user\Controller\UserAuthenticationController::resetPassword + methods: [POST] + requirements: + _access: 'TRUE' + _format: 'json' + user.page: path: '/user' defaults: