Problem/Motivation

There are already two public (functional) methods for generating a URL that performs an action on a user in user.module:

  • \user_pass_reset_url()
  • \user_cancel_url()

#85494: Use email verification when changing user email addresses at the time of writing threatens to introduce another one, as noted by @alexpott (part 5) and by @larowlan.

Proliferation of public APIs should be avoided, and the user module needs some OO love.

Steps to reproduce

Not applicable.

Proposed resolution

Create an injectable service which can fill the needs of these existing and new methods and refine it to fulfil any related tasks which might appear in future, or leave room for that to occur.

Remaining tasks

  • Write the service
  • Create relevant tests
  • Write change record to deprecate \user_pass_reset_url() and \user_cancel_url()
  • Review

User interface changes

None

API changes

  • Create new service to respond to all user action URL generation and check requests
  • Deprecate \user_cancel_url and \user_pass_rehash as a follow-up task.

Data model changes

None

Release notes snippet

TBC

Issue fork drupal-3229038

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

Darvanen created an issue. See original summary.

darvanen’s picture

Assigned: Unassigned » darvanen

Have made a start on this, local testing is showing a few fails, waiting for completion to see what they were.

larowlan’s picture

I thought we were just moving the procedural stuff to a service?

darvanen’s picture

Well yes, but there are challenges with that, for instance, the route parameters aren't constant so I've ended up with this mess:

  /**
   * Get a user action url using the related route.
   *
   * @param string $route
   * @param \Drupal\user\UserInterface|null $user
   * @param array $options
   *
   * @return \Drupal\Core\Url
   */
  public function fromRoute(string $route, UserInterface $user, array $options = []): Url {
    $timestamp = \Drupal::time()->getRequestTime();
    $langcode = isset($options['langcode']) ? $options['langcode'] : $user->getPreferredLangcode();
    $hash = $this->hashUserPassword($user, $timestamp);

    $url_options = [
      'absolute' => TRUE,
      'language' => \Drupal::languageManager()->getLanguage($langcode),
    ];

    // @todo: Standardize parameter names for user action routes.
    return Url::fromRoute($route, [
      'uid' => $user->id(), // for password route
      'user' => $user->id(), // for cancel route
      'timestamp' => $timestamp,
      'hash' => $hash, // for password route
      'hashed_pass' => $hash, // for cancel route
    ], $url_options);
  }

darvanen’s picture

Status: Active » Needs review
darvanen’s picture

Issue tags: +Needs tests

I realised the service as-is would not work with the route described in #85494: Use email verification when changing user email addresses.

Updated with options to include additional route parameters and a payload to add to the hash. Could tightly couple the two but I thought I'd start with the more flexible option. I suppose one could argue for embedding better security practices in the API, I'd prefer to cover that in documentation in the class (yet to be done properly) but I don't know if there's a policy around this kind of thing already.

Gauravmahlawat made their first commit to this issue’s fork.

darvanen’s picture

Assigned: darvanen » Unassigned
darvanen’s picture

Issue summary: View changes

I've come to the conclusion the leaving \user_cancel_url and \user_pass_rehash alone in this change is the best course of action for a few reasons:

  1. The two existing routes use different parameter names and create exceptions to the "rule" that would be introduced by this change.
  2. The Controllers on the existing routes use \user_pass_rehash to check if their hash is correct, so it should really be used to generate the hash.
  3. I want to remove the login time from the hash (see below), and I really don't want to mess with \user_pass_rehash which drives home the point for me that the two existing routes should use *that* to generate hashes.

Why do I want to remove the user login time from the hash?

I just tried using this new service to create a user email verification link that gets sent in the welcome email (no admin auth required) because I want the following login flow:

  • Set username and password
  • Get logged in immediately
  • Receive welcome email and verify email address from link within

This is quite a common pattern in SaaS offerings.

The issue is that the welcome email is generated before the user is automatically logged in so the hash differs.

Why was user login time specifically used? Can we replace it with anything better? I'm loath to just remove it, it would reduce the randomness of the hash.

larowlan’s picture

So you're trying to consolidate them into one method?

Could they remain separate methods?

darvanen’s picture

They could.

What then of new uses for this service like those surfaced in #85494: Use email verification when changing user email addresses? Do we keep adding new methods? Or do we create a general-use method and treat 'reset' and 'cancel' paths as special cases? I'm not familiar enough with the policy/procedure/standard for handling BC here.

and... if I did put them in as special cases, and assuming I am successful in campaigning for a change to the hash contents, they would need their own special case checkHash methods (or a gate within that method) which really muddies the water if we ever want to standardise away from the current global \user_pass_rehash function which in my opinion should be private/internal only to the service.

boiled down: I think it best to let people use the global functions as pairs in their current state until they are deprecated and removed - and for the new service to implement the best possible logic (with an unchanging API) for core, modules, and builders to swap over to gradually.

Separately:

I did a little testing and I think $account->getChangedTime() might be a suitable replacement for $account->getLastLoginTime(), at least, it doesn't break the two existing core uses of user_pass_rehash, and it changes from day to day or week to week like the lastLoginTime does. Thoughts?

darvanen’s picture

I'm embedding this change into my application to keep me accountable for keeping it moving, and because it's just really useful. So with that in mind I've changed the loginTime element to changedTime just until there's a direction/decision because loginTime doesn't work for my use-case.

Honestly I suspect there will be use-cases where changedTime is similarly unsuitable.

This does still need tests but it's marked 'needs review' to get some direction.

darvanen’s picture

Another issue with the contents of the hash has been reported at comment #347 on #85494: Use email verification when changing user email addresses:

If the user attempts to change their password and email address at the same time, they cannot verify their email address using the verify email link.

Which comes down to the hash being generated before the new password is saved to the account.


Obviously we can't remove all of the changeable values from the hash or it will become static and far easier to use in an attack.

Thinking out loud: perhaps we could pass an ignore parameter to fromRoute and checkHash in this service that asks it to leave one and only one of the values out of the hash? (PHP 8.1 enums would make this that much easier, sigh)


Looking at the service again I realised I was asking for an associative array but not using the keys in the hash, have fixed that.

dww’s picture

Status: Needs review » Needs work
Issue tags: +Needs issue rescope, +Needs issue summary update

Here as part of a review swap. Thanks for opening this and moving it forward! From the title alone, this seems like a good move towards less procedural code.

Haven't closely looked at the MR (yet), but some initial thoughts after reading the summary, comments, and a quick glance at the MR.

The code and issue discussion are now dealing with a whole other question of changing the hashing for the login links. That's not mentioned anywhere in the summary. I think that's pretty major scope creep for what I thought I was going to be looking at. 😉 We probably want to split that off into another issue, perhaps postpone this one on that, and focus on the hashing stuff first. Once that works how we want, we can come back in here and do what this issue is saying to do. But doing the hashing as a side effect of moving these things into a service seems like it's destined to run into trouble and make this hard/impossible to commit. I don't really want to weigh in on the hash questions until we're sure where we should be discussing those.

What then of new uses for this service like those surfaced in #85494: Use email verification when changing user email addresses? Do we keep adding new methods? Or do we create a general-use method and treat 'reset' and 'cancel' paths as special cases? I'm not familiar enough with the policy/procedure/standard for handling BC here.

It's a new service, there is no BC to worry about. As such, I'd say it's important to get the API "Right", as best we can, now. Once we ship this thing, then we have to worry about BC.

I haven't been deep in any of this, and haven't even opened the related issues, so take this with a grain of salt. But if we only have 2 use cases now, and a 3rd on the way, and they each have different needs, sounds like we haven't hit the Rule of 3 yet, so trying to prematurely generalize this is likely to result in YAGNI. If we need 3 kinds of links, and they all are a little different, we should have 3 methods on the service. If there are parts of those methods that could be shared, we can have protected helper methods as appropriate. But the entryway into the service shouldn't be theOnePublicMethod($tons, $of, $crap, $to, $handle, $anything);. If we start with separate functions, and find we can later generalize, it's easy to add deprecations to the specific ones and call out to the cleanly generalized thing. If we start from a clumsy general method, it seems harder to deprecate that if we decide it's not really working out. That's how I see it, although I'd defer to framework or release managers who disagree.

I don't think I should do a close review of the MR until the scope is sorted out here, so I'll stop for now.

Thanks,
-Derek

darvanen’s picture

Thanks so much for the review @dww :) you've raised some great points.

  1. The code and issue discussion are now dealing with a whole other question of changing the hashing for the login links. That's not mentioned anywhere in the summary. I think that's pretty major scope creep for what I thought I was going to be looking at. 😉 We probably want to split that off into another issue, perhaps postpone this one on that, and focus on the hashing stuff first.

    You're right, that's a huge scope creep, they should be separate. I would argue however that if we do this issue first, the altering of the hash for the existing links becomes *much* easier because it will be self-contained and will allow us to change the hash method's signature if we need to, since it shouldn't be exposed by the service the way it currently is.

    Thoughts?

  2. If we need 3 kinds of links, and they all are a little different, we should have 3 methods on the service.

    @larowlan questioned this too, ok.

    it's easy to add deprecations to the specific ones and call out to the cleanly generalized thing

    I wasn't under this impression but I have no particular reason I can point to so I will recalibrate my thinking based on that and put the three links in.

    What would we prefer?:

    • Public method to generate each URL type
    • Public method to check each URL type

    or

    • Single public method to generate URL keyed by type
    • Single public method to check URL keyed by type

    The latter could farm out the hash generation to a protected method based on the type key.

    I'm not particularly wedded to either idea, but left to my own devices I'd probably go with the latter.

  3. I agree a close review is premature.

Version: 9.3.x-dev » 9.4.x-dev

Drupal 9.3.0-rc1 was released on November 26, 2021, which means new developments and disruptive changes should now be targeted for the 9.4.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.4.x-dev » 9.5.x-dev

Drupal 9.4.0-alpha1 was released on May 6, 2022, which means new developments and disruptive changes should now be targeted for the 9.5.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Version: 9.5.x-dev » 10.1.x-dev

Drupal 9.5.0-beta2 and Drupal 10.0.0-beta2 were released on September 29, 2022, which means new developments and disruptive changes should now be targeted for the 10.1.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

brad.bulger’s picture

I'm not sure if this is moot at this point, but in regards to comment #13 above, why include either login or changed time? To use the email change example, why shouldn't I be able to change my email, then later change my picture or timezone or password, and then subsequently click on the verification link in my email to confirm the email change request?

If there is going to be a general rule like "You can't log in before using this" or "You can't make any other changes" I'd think we'd want to communicate that to users right away. It would never occur to me to assume either of those conditions, at least.

darvanen’s picture

#20 I don't think it's moot, I'm just no cryptography or cybersecurity expert so the idea of reducing the number of variables in the hash gives me pause.

I wholeheartedly agree with your points.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.