This is a pretty standard construction, and I see no significant issues with the design. For a low-to-moderate-sensitivity site, this should be fine, and the main thing I'd look for in a higher-security site would be multi-factor authentication being involved in the process.
A few considerations you might have missed:
- IMPORTANT: GUIDs/UUIDs are not a great choice for security tokens. They are guaranteed (to extremely high probability) to be unique, but not necessarily to be unpredictable (which is the far more important consideration)! Type 4 UUIDs are generated using a secure PRNG, and those are the most likely type for any modern UUID library, but most libraries do not guarantee that this type will be generated. Even if your library does offer such a guarantee, it's still not the intended use of the tool.
- Use the output of a CSPRNG directly, either hex- or base64-encoded (if the latter, make sure it's either a URL-friendly base64 variant or that the token is URL-encoded). 16 bytes (128 bits of entropy, actually slightly more than a type 4 UUID) should be plenty.
- Depending on how the third step (entering and submitting the new password) works, and how you do session management in general, there might be a window for a CSRF attack to control the password the server sees. Make sure the process is not vulnerable to CSRF.
- Since you're using access to the email address as equivalent to control over the password, you need to make sure that a user's email address can't be changed without the user being fully authenticated (including MFA if relevant).
- Similarly, when a email change occurs, you should probably notify the old email address as well as verifying the new one, same as you notify the user when they reset (or, ideally, change) their password.
- You probably want to limit how often a user can request a password reset email (perhaps only once every two hours or so). This is primarily to avoid a malicious actor spamming your legitimate users with reset emails they didn't request.
- Relatedly to the previous point, you don't want to allow an attacker to constantly replace the user's password reset token in the database, or else the user will never be able to complete the process. You could avoid that specific risk by having multiple valid tokens for a user, but that adds complexity and has its own risks. Better to just not allow requesting a new token for some reasonable window.
- You might consider adding a CAPTCHA or similar to the reset form, as further mitigation against the risk of a bad actor submitting an enormous number of requests. Possibly only require the CAPTCHA if the page is getting unusually high traffic.
- You don't specify why you're hashing the token in the DB. It's a good idea, both to avoid leaking the tokens if somebody gets read access to the DB (before a token expires), and to avoid a timing attack on the tokens. Make sure to consider both risks if you change the scheme.
- The best validity period for the tokens will depend on things like how often they can be requested and how sensitive the account is, but as a general rule I'd keep it under a day. It might occasionally be necessary for the period to be more than a few hours, though; I've seen (temporary) situations where email systems are overloaded, buggy, or poorly integrating with external components such as filters, and emails end up taking hours to arrive. On the other hand, if you consider the highest security more important, it's fine to say that users of such malfunctioning email can cope without password reset functionality for until their email is fixed.
- When the user enters their new password (via reset, or change, or creating their first one), make sure that you apply all the usual password quality checks (not too short, not a common password, no part of the user's or site's name, etc.). I recommend checking the submitted password against a large list of passwords known to have been breached, such as from HaveIBeenPwned's "pwned passwords" list.
Multi-Factor Authentication (MFA)
The most common place I'd see a serious mistake in a system like what you've described is when MFA (also known as 2FA) is in use. Access to an email account is not really a second factor - it's just a (hopefully different) password - and the ability to demonstrate access to an email account should NOT allow bypassing MFA. Specifically, I recommend the following (for all accounts where MFA is enabled):
- Do not allow logging in without one of the configured MFA approaches used successfully. In other words, if the user is logged in directly after resetting their password - which is a reasonable approach - don't allow the password reset to complete without supplying the other factor(s).
- Although not strictly required if the process doesn't directly log the user in, I recommend requiring the MFA verification when resetting the password.
- Don't allow removing MFA without proving the user has access to it.
- Don't allow an emailed token as an alternative to MFA.