Ready to get started?

Join Adocasts Plus for $8.00/mo, or sign into your account to get access to all of our lessons.

robot mascot smiling

Forgot Password & Password Reset

In this lesson, we'll walk through setting up the complete forgot password flow including, creating a password reset token with time-expiry, sending an email notification with a password reset link, verifying the token, and resetting the users password.

Published
Oct 21, 24
Duration
29m 42s

Developer & dog lover. I teach AdonisJS, a full-featured Node.js framework, at Adocasts where I publish weekly lessons. Professionally, I work with JavaScript, .NET, and SQL Server.

Adocasts

Burlington, KY

Get the Code

Download or explore the source code for this lesson on GitHub

Repository

Chapters

00:00 - Creating Our Forgot Password Page & Form
03:32 - Defining Our Forgot Password Routes
04:50 - Password Reset Send Validator
05:40 - Try Send Password Reset Email Action
08:36 - Expire Password Reset Tokens Action
09:54 - Continuing the Try Send Action
12:30 - Installing & Configuring AdonisJS Mail
14:38 - Sending Our Password Reset Email
17:14 - Forgot Password Send Route Handler
17:39 - Forgot Password Index Route Handler
19:13 - Testing Our Forgot Password Flow
19:46 - Creating Our Reset Password Page & Form
22:18 - Reset Password Route Handler
22:44 - Verify Password Reset Token Action
24:35 - Continuing Our Reset Password Handler
25:28 - Password Reset Validator
26:30 - Reset Password Action
27:27 - Reset Password Update Route Handler
28:38 - Testing Our Reset Password Flow

Forgot Password Email

You can find the complete forgot password email from this lesson, on GitHub. This email was created using maily.to.

Ready to get started?

Join Adocasts Plus for $8.00/mo, or sign into your account to get access to all of our lessons.

Join The Discussion! (20 Comments)

Please sign in or sign up for free to join in on the dicussion.

  1. Commented 9 months ago

    Hello Tom,
    First of all, thank you for all this amazing work.
    I have an warning that I can't fix; in my console, I see the warning message below, even though I do have an authenticated user.
    Warning message:
    [Vue warn]: Invalid prop: type check failed for prop "user". Expected Object, got Undefined
    at <AppLayout user=undefined errors= {} exceptions= {} ... >
    at <Inertia initialPage= {
    component: 'home',
    version: '1',
    props: {
    user: undefined,
    errors: {},
    exceptions: {},
    messages: {},
    version: 6
    },
    If I do a console log of user, I indeed get undefined:
    sharedData: {
    user: async (ctx) => {
    const user = ctx.auth.use('web').user
    console.log(user)
    return user && new UserDto(user)
    },
    However, if I retrieve the user this way:
    const user = await ctx.auth.use('web').authenticate()
    I do have my user connected. On the other hand, if I log out and return to the login page, I get the message: "The page isn't redirecting properly."
    Do you have any idea what the problem is?

    1

    Please sign in or sign up for free to reply

    1. Commented 9 months ago

      For the authenticated user to be populated you must inform AdonisJS to check for it. This saves the roundtrip to populate the user in cases where it isn't needed.

      To populate the user, you have two options

      1. authenticate - Requires an authenticated user. If an authenticated user is not found, an exception is thrown.

      2. check - Will check to see if a user is authenticated, and populate that user if so. The request goes on as usual if an authenticated user is not found.

      In terms of middleware, you have three options

      1. auth - Will call authenticate and redirect the user to the login page by default if an authenticated user is not found.

      2. guest - Will call check and redirect the user, I believe to the home page, by default if an authenticated user is found.

      3. silent_auth - Will call check and progress with the request as usual.

      So, you're most likely getting "the page isn't redirecting properly" because your authenticate on the login page is attempting to redirect the user to the login page, resulting in an infinite redirect loop.

      You most likely will want to replace your authenticate on the login page with the guest middleware and that should fix the redirect loop. Then, for internal pages, you can either use authenticate if the user must be authenticated to access the page, check if the user may or may not be authenticated to access the page, or one of the corresponding middleware.

      1

      Please sign in or sign up for free to reply

      1. Commented 9 months ago

        Thank you for this quick response. So if I understand correctly, at the current stage of the course (5.6), it's normal to receive this warning message since our 'home' route doesn't have any middleware.

        1

        Please sign in or sign up for free to reply

        1. Commented 9 months ago

          Anytime! Yes, sorry, we'll move our home page into our group protected by the auth middleware in the next lesson (6.0). So, that warning specifically on the home page will go away in the next lesson.

          1

          Please sign in or sign up for free to reply

  2. Commented 7 months ago

    When you say we might want to add a referrer policy to this page, how do we do that?

    1

    Please sign in or sign up for free to reply

    1. Commented 7 months ago

      Hi Arron!

      Great question, I now wish I would've injected this into the lesson; I think I'll make a note to do that.

      The Referrer-Policy mentioned is a response header. So, in the controller rendering this page you'd add it using the HttpContext's response, like below. This is a step recommended by OWASP.

      async reset({ params, inertia, response }: HttpContext) {
        const { isValid, user } = await VerifyPasswordResetToken.handle({ 
          encryptedHash: params.hash 
        })
      
        response.header('Referrer-Policy', 'no-referrer') // 👈
      
        return inertia.render('auth/forgot_password/reset', {
          hash: params.hash,
          email: user?.email,
          isValid,
        })
      }
      Copied!
      2

      Please sign in or sign up for free to reply

      1. Commented 7 months ago

        Thanks!

        1

        Please sign in or sign up for free to reply

        1. Commented 7 months ago

          Anytime, Aaron!!

          0

          Please sign in or sign up for free to reply

  3. Commented 3 months ago

    I just find out that when trying to reset our password with a valid token and providing a payload not satisfying the validator we don't have a form error message but we are redirected instead. I tried many times and I don't think that I missed something, I also tried on the PlotMyCourse deployed version but you disabled the mailer so I can't reproduce the error to see if you also have it or not…

    1

    Please sign in or sign up for free to reply

    1. Commented 3 months ago

      Hi memsbdm!

      The mailer at plotmycourse.app is indeed still up and running, you might need to check your spam for it's email. However, I can confirm I'm having the same issue. I'm not immediately sure why this would be happening, it looks like it isn't properly returning an Inertia response from the validation handling.

      I'll be able to dig into it further after work and will let you know if I find anything!

      0

      Please sign in or sign up for free to reply

      1. Commented 3 months ago

        Thanks! I will also keep trying to investigate :)

        1

        Please sign in or sign up for free to reply

        1. Commented 3 months ago

          Alrighty, was able to track down the issue! So this was happening as a side-effect to the Referrer-Policy: no-referrer within reset method of the forgot password controller.

          When we call response.redirect().back(), which happens on our behalf during request validation handling, it reads from the referer header and redirects the user back to that page. With our Referrer-Policy on this particular form set to no-referrer we're telling the browser not to share that referrer with anyone. This is a security step to prevent the token from being leaked via the referrer.

          To properly correct this and also allow any password errors to show, we want to keep the no-referrer designation and instead update how we're handling our validation errors.

          First, let's move the password reset token into a flash message, rather than passing through the form. This will allow us to keep it out of our redirect url should we get any validation errors on submit.

          async reset({ params, inertia, response, session }: HttpContext) {
            // will be in the params if coming from email
            // will be in flash messages if coming from errored validation
            const token = params.value ?? session.flashMessages.get('password_reset_token')
            const { isValid, user } = await VerifyPasswordResetToken.handle({
              encryptedValue: token,
            })
          
            // flash it to the session store
            session.flash('password_reset_token', token)
            // keep this to keep things secure
            response.header('Referrer-Policy', 'no-referrer')
          
            return inertia.render('auth/forgot_password/reset', {
              email: user?.email,
              isValid,
            })
          }
          Copied!

          Then, configure the update method to read the token from the flash message store instead of our form. We'll also want to manually capture and handle the validation error so we can specify where the user should be redirected to, reflashing the token so it continues to be available for us to use.

          @inject()
          async update({ request, response, session, auth }: HttpContext, webLogin: WebLogin) {
            let data: Infer<typeof passwordResetValidator>
          
            try {
              data = await request.validateUsing(passwordResetValidator)
            } catch (error) {
              // manually catch any validation errors that were thrown
              if (error.code === 'E_VALIDATION_ERROR' && 'messages' in error) {
                // reflash the user's password reset token so it is available to use again
                session.reflashOnly(['password_reset_token'])
                // flash the validation errors so they display to the user
                session.flashValidationErrors(error)
                // redirect to the correct page
                return response.redirect(`/forgot-password/reset`)
              }
              throw error
            }
          
            // grab the token from the flash message store to use
            const token = session.flashMessages.get('password_reset_token')
            const user = await ResetPassword.handle({ data, token })
          
            await auth.use('web').login(user)
            await webLogin.clearRateLimits(user.email)
          
            session.flash('success', 'Your password has been updated')
          
            return response.redirect().toRoute('courses.index')
          }
          Copied!

          Next, since our token is no longer in our data, we'll update the ResetPassword action to accept it separately.

          type Params = {
            data: Infer<typeof passwordResetValidator>
            token: string
          }
          
          export default class ResetPassword {
            static async handle({ data, token }: Params) {
              const { isValid, user } = await VerifyPasswordResetToken.handle({ encryptedValue: token })
          
              if (!isValid) {
                throw new Exception('The password reset token provided is invalid or expired', {
                  status: 403,
                  code: 'E_UNAUTHORIZED',
                })
              }
          
              await user!.merge({ password: data.password }).save()
              await ExpirePasswordResetTokens.handle({ user: user! })
          
              return user!
            }
          }
          Copied!

          Then, since the token will come from our flash message store on any validation redirects and not the route params, we'll make that route parameter optional.

          router.get('/reset/:value?', [ForgotPasswordsController, 'reset']).as('reset')
          Copied!

          Lastly, since we're no longer passing the token to our page or form, we can remove it from our validator.

          export const passwordResetValidator = vine.compile(
            vine.object({
              value: vine.string(),
              password: vine.string().minLength(8),
            })
          )
          Copied!

          With all that, things should be behaving properly! Terribly sorry I missed accounting for that. The no-referrer was something I added in after the fact and failed to recognize it'd have that kind of impact. However, it is definitely something we want to keep.

          You can find the full diff of my commit for this here:
          https://github.com/adocasts/building-with-adonisjs-and-inertia/commit/2f87a54cd84886e6dfffad01568153e2f8fe24a1

          1

          Please sign in or sign up for free to reply

      2. Commented 3 months ago

        Well well well, I removed response.header('Referrer-Policy', 'no-referrer') and it works now, it's seems like inertia is not receiving the token after the redirect().back()

        We probably could send a cookie before the redirect to keep the no-referrer good practice but seems a bit overkill..

        1

        Please sign in or sign up for free to reply

        1. Commented 3 months ago

          Oh hey, haha! Seems we both found the culprit around the same time! 😄

          I chose to take the overkill approach in my commit & other comment, though using flash messages instead of a cookie.

          1

          Please sign in or sign up for free to reply

          1. Commented 3 months ago

            Haha nice what a clean way to solve the issue, I'll replicate these changes it is way better than my cookie idea! Thanks for your time :)

            1

            Please sign in or sign up for free to reply

            1. Commented 3 months ago

              Sure thing, memsbdm! Thank you very much for catching this!!
              Just noticed I missed the validation update in my prior comment, will get that edited in now 😊

              1

              Please sign in or sign up for free to reply

  4. Commented 1 day ago

    Hey @tomgobich

    I was going through Adocast’s source code for learning, and I noticed that you used signed URLs for password resets(https://github.com/adocasts/adocasts/blob/7e7ded237372a222da7e6767d838467332891d4f/app/controllers/auth/password_reset_controller.ts#L43). Which approach would you consider the better option?

    I also have a question about using sendLater versus https://github.com/poppinss/defer. What’s your take on that? Personally, I feel like using something heavy like BullMQ is overkill if the use case is just simple tasks, like sending emails, especially when traffic isn’t that high. I’m asking because I’m looking for a minimal yet efficient way to handle background tasks. Thanks!

    1

    Please sign in or sign up for free to reply

    1. Commented 1 day ago

      Hey Tresor!

      Password Reset

      The differences between using signed URLs and a database-stored token for password resets mostly come down to weighing the pros and cons, and how important those are for your site's use case.

      Signed URLs are reliable and quick to implement. However, since they aren't stored in the database, we need a way to attach them to a user, like adding the user's email or ID to the signed URL. Additionally, once generated, we have no way to invalidate the token until its expiry time is reached. This means we can't expire a token after it's been used. This will suffice for most basic apps and is a fine option if you're just starting out with a small user base.

      Database-stored tokens have a bit more overhead to implement, and we need to manage the tokens ourselves. However, since the token is stored in the database we don't need to expose any of our users' information via the URL. Additionally, we have the ability to update the database record at any time, so it can easily be expired once used. This is a great option if protecting your users info, even from their own inbox, is a top priority. It's also a must-use if you need to invalidate a token after use (for example, say you need to use an expiry longer than you'd feel comfortable leaving lingering).

      The third option here is to use both, generate a token then sign that token into the URL. This would be the most secure option. It takes away the cons of just using a Signed URL and adds an extra obfuscation layer to the database-stored token. Might be seen as a bit overkill depending on the app though.

      So, none of the above are bad options unless the type of site you're building or its audience dictates a higher level of security. I actually plan on updating Adocasts password reset flow to use database-stored tokens here in the future. I almost did it with a redesign I'm working on, but opted to save it till after.

      Deferred Mail Sending

      If we actually dig into sendLater and poppinss/defer, both are in-memory queues using fastq under the hood to actually do the queuing. So, I'd argue there's no real difference between the two unless one has a specific feature you're looking for.

      The downside to an in-memory queue is that if your server crashes or reboots (like when you deploy) then anything in that queue has been lost. If you don't send many emails and don't deploy all that often then the likelihood of a lost email is pretty slim.

      If you send emails frequently or deploy frequently then your chances of a lost email increase. You might also be sending mission-critical emails that must reach their destination (think appointment confirmation or order receipt emails). That's where an alternative like BullMQ can help because it'll hold the queued item until it's processed, even if the app reboots. You can also swap sendLater's in-memory queue with BullMQ as well. So, you can start with in-memory and upgrade the queue system very easily down the road without having to rewrite your sendLater code.

      I'm still using an in-memory queue for Adocasts emails, but I don't deploy all that often and Stripe takes care of our purchase-based emails.

      Hope this helps!!

      1

      Please sign in or sign up for free to reply

      1. Commented 1 day ago

        Thank you very much for your detailed explanation, I have a good understanding now.

        1

        Please sign in or sign up for free to reply

        1. Commented 22 hours ago

          Awesome, I'm glad to hear that helped! Anytime!!

          0

          Please sign in or sign up for free to reply