Problem/Motivation

We need an endpoint to allow users to trigger the reset password process.

Proposed resolution

Create a router method within Drupal\user\Controller\UserAuthenticationController

Remaining tasks

  1. Create the endpoint.
  2. Create tests.

User interface changes

None.

API changes

None.

Data model changes

None.

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

ruloweb created an issue. See original summary.

shadcn’s picture

Title: Endpoint retrieve password » RPC endpoints for retrieve user password
Status: Active » Needs review
FileSize
12.77 KB

OK let's get this going.

shadcn’s picture

Title: RPC endpoints for retrieve user password » RPC endpoints to retrieve user password
dawehner’s picture

Assigned: ruloweb » Unassigned

One thing I a wondering about ... should there be some kind of flood protection on this route as well?

shadcn’s picture

@dawehner I'd say yes. We'll also need it in \Drupal\user\Form\UserPasswordForm.

Wim Leers’s picture

Title: RPC endpoints to retrieve user password » RPC endpoint to retrieve user password
  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    --- /dev/null
    +++ b/core/modules/user/tests/src/Functional/UserPasswordHttpTest.php
    

    Why should this not be added to UserLoginHttpTest?

  2. +++ b/core/modules/user/tests/src/Functional/UserPasswordHttpTest.php
    @@ -0,0 +1,203 @@
    +  protected function getResultValue(ResponseInterface $response, $key, $format) {
    ...
    +  protected function assertHttpResponse(ResponseInterface $response, $expected_code, $expected_body) {
    ...
    +  protected function assertHttpResponseWithMessage(ResponseInterface $response, $expected_code, $expected_message, $format = 'json') {
    

    We're duplicating these helpers from UserLoginHttpTest.

shadcn’s picture

Assigned: Unassigned » shadcn
Status: Needs review » Needs work

@Wim Leers we could move the tests to UserLoginHttpTest.

I was not sure if password reset would be qualify under user login.

I'll make the change.

shadcn’s picture

Status: Needs work » Needs review
FileSize
10.06 KB
10.66 KB

Updated patch as per #6.

Status: Needs review » Needs work

The last submitted patch, 8: rpc_endpoint_to-2847708-8.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Needs review
00:00:26.585 Error response from daemon: Get https://registry-1.docker.io/v2/drupalci/php-5.6-apache/manifests/production: Get https://auth.docker.io/token?scope=repository%3Adrupalci%2Fphp-5.6-apache%3Apull&service=registry.docker.io: net/http: request canceled (Client.Timeout exceeded while awaiting headers)
00:00:26.591 Build step 'Execute shell' marked build as failure
00:00:26.629 [CHECKSTYLE] Skipping publisher since build result is FAILURE
00:00:26.629 Archiving artifacts
00:00:26.632 Checking console output
00:00:26.632 Recording test results
00:00:26.758 ERROR: Step ‘Publish JUnit test result report’ failed: No test report files were found. Configuration error?
00:00:26.769 Finished: FAILURE

Retesting.

dawehner’s picture

@Wim Leers
Do you have an opinion regarding some flooding protection?

Wim Leers’s picture

I was going to say "do the same as the form", assuming that the form had flood protection. But apparently it does not!

\Drupal\user\Form\UserPasswordForm::buildForm()
nor \Drupal\user\Controller\UserController::getResetPassForm() contain flood protection.

Therefore I don't think we need to do it here either. Nothing is preventing you from mass-triggering the password reset form today. So then there's no need to do so in the RPC endpoint either. If we ever add it, we should add it to both. But that should not block this issue.

Wim Leers’s picture

Status: Needs review » Needs work

Thanks, this is looking great already!

  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,64 @@ public function login(Request $request) {
    +      throw new BadRequestHttpException('Missing credentials.name or credential.mail');
    

    s/credential/credentials/

  2. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,64 @@ public function login(Request $request) {
    +    $name_or_mail = isset($credentials['name']) ? $credentials['name'] : $credentials['mail'];
    +
    +    $users = $this->userStorage->loadByProperties(['name' => trim($name_or_mail)]);
    +    if (!count($users) && isset($credentials['mail'])) {
    +      $users = $this->userStorage->loadByProperties(['mail' => trim($name_or_mail)]);
    +    }
    

    This looks strange…

  3. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,64 @@ public function login(Request $request) {
    +      // Check if user is blocked.
    

    Pointless comment.

  4. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,64 @@ public function login(Request $request) {
    +      if ($this->userIsBlocked($account->getAccountName())) {
    +        throw new BadRequestHttpException('The user is blocked or has not been activated yet.');
    

    Let's use exactly the same message as \Drupal\user\Controller\UserAuthenticationController::login().

  5. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,64 @@ public function login(Request $request) {
    +      $mail = _user_mail_notify('password_reset', $account);
    ...
    +      if (empty($mail)) {
    ...
    +      $this->logger->notice('Password reset instructions mailed to %name at %email.', ['%name' => $account->getAccountName(), '%email' => $account->getEmail()]);
    

    This all matches what's in \Drupal\user\Form\UserPasswordForm::submitForm(), great!

  6. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,64 @@ public function login(Request $request) {
    +      // Respond with message.
    +      $message = $this->t('Further instructions have been sent to the provided email address.');
    +      $response_data = [
    +        'message' => $message->render(),
    +      ];
    +      $encoded_response_data = $this->serializer->encode($response_data, $format);
    +      return new Response($encoded_response_data);
    

    The message seems rather pointless. Especially translating it seems pointless. I think a simple 200 response is sufficient.

  7. +++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
    @@ -188,6 +188,96 @@ public function testLogin() {
    +        // 400 Bad Request for empty body.
    ...
    +        // 400 Bad Request for unrecognized username.
    ...
    +        // 400 Bad Request for unrecognized email.
    ...
    +        // Block the account.
    ...
    +        // 400 Bad Request for blocked user with username.
    ...
    +        // 400 Bad Request for blocked user with mail.
    ...
    +        // Activate account.
    ...
    +        // 200 for correct username.
    ...
    +        // 200 for correct email.
    

    These are all pointless comments TBH… they don't add any value. The test code is very clear already without them.

  8. +++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
    @@ -188,6 +188,96 @@ public function testLogin() {
    +        $response = $this->passwordRequest(['name' => 'dramallama'], $format);
    ...
    +        $response = $this->passwordRequest(['mail' => 'llama@drupal.org'], $format);
    

    :D

shadcn’s picture

Thanks @Wim Leers. I'll make the changes.

Do we need a separate issue to eventually add flood protection to password reset?

Wim Leers’s picture

Do we need a separate issue to eventually add flood protection to password reset?

Yes, that'd be splendid :)

shadcn’s picture

Status: Needs work » Needs review
FileSize
9.21 KB
5.16 KB

Updated patch as per #13.

  • 13.1: Fixed.
  • 13.2: This is mostly from what \Drupal\user\Form\UserPasswordForm::validateForm is doing. I reversed what we had to match it better. i.e use email if provided, fallback to username.
  • 13.3: Comment removed.
  • 13.4: Message updated.
  • 13.5: -
  • 13.6: Done. Response with 200.
  • 13.7: Comments removed.
  • 13.8: :D
Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
1.99 KB
9.2 KB

Just fixing a few nits. Other than that, I think this is good to go! Thanks for rerolling so quickly!

Wim Leers’s picture

Title: RPC endpoint to retrieve user password » RPC endpoint to reset user password
Issue summary: View changes
alexpott’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,55 @@ public function login(Request $request) {
    +    // Try to load by mail.
    +    $name = isset($credentials['mail']) ? $credentials['mail'] : $credentials['name'];
    +    $users = $this->userStorage->loadByProperties(['mail' => trim($name)]);
    +    if (!count($users) && isset($credentials['name'])) {
    +      // No success, try to load by name.
    +      $users = $this->userStorage->loadByProperties(['name' => trim($name)]);
    +    }
    

    This is an odd construction - I think we should load by mail or by name depending on what is provided. The form only has a single name field but here we have $credentials['name'] and $credentials['mail']. And the name will accept either a name of an email but mail will only accept a mail. And what happens when both are provided? Maybe we should entirely duplicate the form functionality and just call the input name_or_mail - @Wim Leers - maybe you have some thoughts on this?

  2. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,55 @@ public function login(Request $request) {
    +    throw new BadRequestHttpException('Sorry, unrecognized username or email address.');
    

    I don't think we should be saying "Sorry" here - it's kind of like the please thing. I think the message should be We could improve it and say which of the provided way is wrong but that might be unnecessary.

  3. The test coverage looks good
  4. The followup suggested by #14 has been created #2856929: Add flood protections to password reset
dawehner’s picture

+++ b/core/modules/user/src/Controller/UserAuthenticationController.php
@@ -208,6 +220,55 @@ public function login(Request $request) {
+    // Check if a name or mail is provided.
+    if (!isset($credentials['name']) && !isset($credentials['mail'])) {
+      throw new BadRequestHttpException('Missing credentials.name or credentials.mail');
+    }
+
+    // Try to load by mail.
+    $name = isset($credentials['mail']) ? $credentials['mail'] : $credentials['name'];
+    $users = $this->userStorage->loadByProperties(['mail' => trim($name)]);
+    if (!count($users) && isset($credentials['name'])) {

One thing I am wondering, could these information be sent via query parameters instead? Do they have to be part of the request body? Maybe i'm overthinking things a lot, but for me its a bit like you reset the account of a specific user

shadcn’s picture

Status: Needs work » Needs review
FileSize
9.12 KB
2.45 KB

Here's an updated patch as per #19 that loads by name or by mail depending on what is provided.

I've also updated the error message.

@alexpott, the patch in #8 had $name_or_mail like \Drupal\user\Form\UserPasswordForm::validateForm.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

#19.1: I shared your concerns, but this is literally what the existing code in \Drupal\user\Form\UserPasswordForm::validateForm() was doing, so I let it pass.

#20: I don't see what the advantage of that would be? This must be a POST request anyway. If we the need arises, we could add that capability in the future. For now, I think this makes sense.

Concerns in #19 are addressed.

dawehner’s picture

#20: I don't see what the advantage of that would be? This must be a POST request anyway. If we the need arises, we could add that capability in the future. For now, I think this makes sense.

Well, maybe its more concrete as you would kinda act on the resource with name "X".

ruloweb’s picture

This is awesome! attached is a patch which adds the language code in the _user_mail_notify function, just as \Drupal\user\Form\UserPasswordForm::submitForm() does.

Also, this is the first step on the Password Reset workflow, what about processing the token and allow the user edit endpoint to bypass the Current password field? Sooner I will upload a patch with this proposal that I used for one project.

shadcn’s picture

Assigned: shadcn » Unassigned

Thanks for your work on this @ruloweb. +1

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 24: rpc_endpoint_to_reset-2847708-24.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 24: rpc_endpoint_to_reset-2847708-24.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community

Sigh, still random CI fails.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 24: rpc_endpoint_to_reset-2847708-24.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 24: rpc_endpoint_to_reset-2847708-24.patch, failed testing.

Wim Leers’s picture

Status: Needs work » Reviewed & tested by the community
Gábor Hojtsy’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/user/src/Controller/UserAuthenticationController.php
@@ -208,6 +220,57 @@ public function login(Request $request) {
+      $langcode = $this->languageManager()->getCurrentLanguage()->getId();
+
+      // Send the password reset email.
+      $mail = _user_mail_notify('password_reset', $account, $langcode);
+      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();
+      }

Discussing with @alexpott, this same logic is used in UserPasswordForm but there the user initated the request I believe so using the page language is fine, so since the user could interact in that language, they could receive mail in that language. But here, there is no guarantee the endpoint was requested in a language the user can interact in, so the logic should ensure the user preferred language is used.

Wim Leers’s picture

So you're saying that the REST request should specify the language?

Gábor Hojtsy’s picture

I think pulling the user preferred language from the user profile would be the most solid solution here. Since the REST request may or may not be initiated by the user themselves, I think that would be the only stable way to always send email in a language the user should understand. AccountInterface::getPreferredLangcode() gets you the right langcode to use.

Wim Leers’s picture

AccountInterface::getPreferredLangcode() gets you the right langcode to use.

Alright, that makes this issue actionable :)

tedbow’s picture

Status: Needs work » Needs review
FileSize
11.77 KB
4.62 KB
  • Ok adding get langcode for email from AccountInterface::getPreferredLangcode() as per #36

    So looking at \Drupal\Core\Session\UserSession::getPreferredLangcode it looks like if there is not a preferred language the langcode will be from
    \Drupal::languageManager()->getDefaultLanguage()->getId()
    where has before we were doing
    $this->languageManager()->getCurrentLanguage()->getId();
    That is probably fine but just want to point that out.

  • I also noticed that in UserLoginHttpTest::testPasswordReset we were actually testing that email was sent.

    This is the same logic that is already in UserLoginTest so I made a new trait UserResetEmailTestTrait that both now use.

Status: Needs review » Needs work

The last submitted patch, 38: 2847708-38.patch, failed testing.

tedbow’s picture

Status: Needs work » Needs review
FileSize
1.25 KB
11.56 KB
+++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
@@ -258,11 +261,17 @@ public function testPasswordReset() {
+        $account->
+

What happened here?

Fixing and removing unused use statement.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

#40: hah, I wondered the same!

Thanks for bringing this one over the finish line!

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 40: 2847708-40.patch, failed testing. View results

tedbow’s picture

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

Manually compared #40 and #43. The only change is the removal of the hunk to update core/modules/user/src/Tests/UserLoginTest.php, because the revert of #992540: Nothing clears the "5 failed login attempts" security message when a user resets their own password makes that change unnecessary. Other than that, it's identical.

Therefore, back to RTBC.

Wim Leers’s picture

larowlan’s picture

  1. +++ b/core/modules/user/src/Controller/UserAuthenticationController.php
    @@ -208,6 +220,56 @@ public function login(Request $request) {
    +        throw new BadRequestHttpException('The user has not been activated or is blocked.');
    ...
    +    throw new BadRequestHttpException('Unrecognized username or email address.');
    

    Whilst this feels like username/email enumeration - it already exists in the existing form-based approach and is consistent with security team policy - see https://www.drupal.org/node/1004778 Disclosure of usernames and user ids is not considered a weakness

  2. +++ b/core/modules/user/tests/src/Functional/UserLoginHttpTest.php
    @@ -188,6 +191,90 @@ public function testLogin() {
    +    foreach ([FALSE, TRUE] as $serialization_enabled_option) {
    ...
    +      foreach ($formats as $format) {
    

    Are we avoiding using a @dataProvider here because of setup cost? Feels like that is what we actually want...going to ask others their thoughts

Wim Leers’s picture

  1. Good :)
  2. This is following the example set in \Drupal\Tests\user\Functional\UserLoginHttpTest::testLogin(). Refactoring this seems like an out-of-scope nice-to-have to me.
larowlan’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
4.81 KB
10.84 KB

Refactoring this seems like an out-of-scope nice-to-have to me.

I respectfully disagree, see #1847540: [META] Clean up comment module tests and decouple from node for example of where technical debt in tests is hard to clean up later.

I'll meet you half-way, I'll refactor it here before it goes in (see attached patch) if you open a novice follow-up to refactor \Drupal\Tests\user\Functional\UserLoginHttpTest::testLogin, linking to this comment for an example? Should be plenty of takers for a simple task.

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

  • catch committed 4342ca2 on 8.4.x
    Issue #2847708 by arshadcn, tedbow, Wim Leers, ruloweb, larowlan: RPC...
catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.4.x, thanks!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

hanoii’s picture

@ruloweb have you progressed with the other endpoint to complete the actual password reset through rest?

hanoii’s picture

I just added #2904451: Allow to change password through RPC endpoints through the reset password workflow to follow up discussion on what was missing here.