DbAuth forgotPassword

I’m thinking about the current implementation of forgot password and I’d like to change it, but I’m not sure that’s possible–might be a feature request on the actual Redwood package.

I want to give users the ability to reset their password in case they forget it, however currently all they need is their username/e-mail to trigger the forgot password page. When I use forgotPassword from useAuth() I’d like for it to check if a reset token already exists and only create a new one if the expiration time is in the past. This would prevent someone from just going through the forgot password flow over and over again. Sure, I have rate limiting in place and a captcha but wouldn’t it be better if the forgotPassword function could handle this?

I tried to use the handler in api/src/functions/auth.ts but that is triggered after the frontend calls forgotPassword from useAuth(). I’m also wondering what preventative measures I could take to prevent users from trying to reset each other’s password if they know someone username/e-mail? That’s what got me thinking about not just rate-limiting and captcha (against attacks) but more about mischievous users who want to fill up the e-mails of their colleagues.

Adding this code snippet to the DbAuthHandler at redwood/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts allows this logic to take place. I’m not sure if this PR would be accepted, but I’d love to hear from the Redwood team if this is something that you think forgot password should consider? In my mind this prevents unnecessary flooding the e-mails of users who might have some mischievous colleagues. I still recommend using a rate limiter on the server side for the forgot-password page since it’s publicly exposed.

async forgotPassword() {
...
    } catch {
      throw new DbAuthError.GenericError();
    }
    if (user) {
+      // check if user already has a reset token
+      if (user.resetToken) {
+        // check if token hasn't expired yet to prevent abuse
+        if (new Date(user.resetTokenExpiresAt) > new Date()) {
+          throw new DbAuthError.ResetTokenExpiredError(this.options.forgotPassword?.errors?.resetTokenInvalid || `A reset token has already been sent to this email`);
+        }
+      }

      const tokenExpires = new Date();
      tokenExpires.setSeconds(tokenExpires.getSeconds() + this.options.forgotPassword.expires);

      // generate a token
...

I edited this in my own auth-dbauth-api package and I don’t want to make a PR if this isn’t something the team feels is necessary?

Hello James! I’m the original author of dbAuth so most of what’s in there is my fault. :wink:

What’s the user experience with this code added? Once they enter their email to send the Forgot Password email, do they then see an error on that page if they try to send another email before their reset token has expired? Or just that a second email won’t send (but the user will think it did)?

I’ve seen sites that do this and I usually find it highly annoying. haha If I don’t get the email in 30 seconds I’ll immediately go back to the site and try it again (probably because it went to spam, but that doesn’t stop my instinct to assume it’s the site’s fault and try to send myself another one).

Could this just be a config option that you can set along with all the others in api/src/functions/auth.js? Something like allowMultipleForgotPasswords (defaulted to true so it mimics the current behavior)? And then the framework can check it before it gets to the forgotPassword handler in the config file. Since it’s handled in the framework we probably want to allow them to set the error message in the config file as well.

What do you think?

1 Like

Hey Rob, it’s an amazing package and I love it. Overall the biggest draw to Redwood for me was how similar it feels to Rails, thank you for everything you do!

The user experience is pretty simple, once a user initiates forgotPassword on the page it runs through the normal logic and creates the resetToken and expiration for the user. However, if they try to resubmit for the same e-mail they get a toast notification with the error I throw: “A reset token has already been sent to this email.” On the backend it just stops the execution so a new reset token and expiration time is not generated.

Once the expiration time is in the past, they can initiate another password reset. It just helps with flooding and abuse. I really like that even though a resetToken might be present on a user they can still login with their password, so users can’t just lock each other out. The expiration time is also great since it provides a level of security.

What this doesn’t do is allow them to resend the e-mail, since that lives in the api/src/functions/auth.ts handler after the reset token has been set. This is preferable in my opinion because again it prevents someone from abusing sending reset password e-mails to a user’s inbox. Problem is of course if they never get that first e-mail for whatever reason (didn’t check spam) they’d have to wait the expiration time before they can initialize that process again. :thinking:

I see your point though, this is definitely just a “quick fix” but it would be preferable if we could handle this in the auth.ts like we do with other things as you mentioned. That would definitely be great! Essentially extending the auth.ts forgotPassword handler with an extra error to configure and a boolean like that to prevent flooding like I showed in the code above. So if that flag is set we throw the error along with the custom error.

This is a preventative measure but I do get your point about how it could be annoying. What if you don’t get that first e-mail for whatever reason? Now you have to wait 30 minutes (or whatever you set) in order to try again for that e-mail. However, if you still allow the forgotPassword handler in auth.ts to run (without changing the valid reset token that’s been set) to send the e-mail you’re defeating the purpose of preventing this abuse. Maybe an internal cooldown where the e-mail can be sent out again every 5 minutes as long as the token hasn’t expired? Or perhaps handle it with a RedwoodJob and if it fails retry :wink:

1 Like