How do I deny a user that hasn't verified their email address

I need a pointer here… Some docs talk about denying a user that hasn’t verified their email address yet…

If you want to do something other than immediately let a user log in if their username/password is correct, you can add additional logic in login.handler(). For example, if a user’s credentials are correct, but they haven’t verified their email address yet, you can throw an error in this function with the appropriate message and then display it to the user. If the login should proceed, simply return the user that was passed as the only argument to the function: Authentication | RedwoodJS Docs

The code doesn’t seem to be part of the dbAuth…

I can add a verified boolean to the User record, but what is next?

I can send a welcome email, and include a verify link – I could generate a GUID for the round-trip and store that in the database

But how can I add the route to the dbAuth? I don’t understand where the routing in ./the api is – I’m thinking I can then go from there to logged in [home]

And how can I return a message to display that won’t be treated as an Error ?

Thanks!
Al;

import { nanoid } from 'nanoid'

const nodemailer = require('nodemailer');

import { db } from 'src/lib/db'
import { DbAuthHandler } from '@redwoodjs/api'
import { logger } from 'src/lib/logger'

export const handler = async (event, context) => {

  const forgotPasswordOptions = {
    // handler() is invoked after verifying that a user was found with the given
    // username. This is where you can send the user an email with a link to
    // reset their password. With the default dbAuth routes and field names, the
    // URL to reset the password will be:
    //
    // https://example.com/reset-password?resetToken=${user.resetToken}
    //
    // Whatever is returned from this function will be returned from
    // the `forgotPassword()` function that is destructured from `useAuth()`
    // You could use this return value to, for example, show the email
    // address in a toast message so the user will know it worked and where
    // to look for the email.
    handler: (user) => {

      logger.debug(`sending email via: ${process.env.SMTP_HOST}`)
      let transporter = nodemailer.createTransport({
             host: process.env.SMTP_HOST,
             port: 2525,
             auth: {
                 user: process.env.SMTP_USER,
                 pass: process.env.SMTP_PASS
             }
     })

      const resetLink = `${process.env.REDIRECT_URL}/reset-password?resetToken=${user.resetToken}`
      const message = {
        from: process.env.AUTH_EMAIL_FROM,
        to: user.email,
        subject: "Reset Forgotten Password",
        html: `Here is a link reset your password.  It will expire after 4hrs. <a href="${resetLink}">Reset my Password</>`
      }

      transporter.sendMail(message, (err, info) => {
        if (err) {
          console.log(err)
        } else {
          console.log(info);
        }
      })

      return user
    },

    // How long the resetToken is valid for, in seconds (default is 24 hours)
    expires: 60 * 60 * 4,

    errors: {
      // for security reasons you may want to be vague here rather than expose
      // the fact that the email address wasn't found (prevents fishing for
      // valid email addresses)
      usernameNotFound: 'Username not found',
      // if the user somehow gets around client validation
      usernameRequired: 'Username is required',
    },
  }

  const loginOptions = {
    // handler() is called after finding the user that matches the
    // username/password provided at login, but before actually considering them
    // logged in. The `user` argument will be the user in the database that
    // matched the username/password.
    //
    // If you want to allow this user to log in simply return the user.
    //
    // If you want to prevent someone logging in for another reason (maybe they
    // didn't validate their email yet), throw an error and it will be returned
    // by the `logIn()` function from `useAuth()` in the form of:
    // `{ message: 'Error message' }`
    handler: (user) => {
      if (!user.verified) {
        throw new Error('Please validate your email first!')
      } else {
        return user
      }
    },

    errors: {
      usernameOrPasswordMissing: 'Both username and password are required',
      usernameNotFound: 'Username ${username} not found',
      // For security reasons you may want to make this the same as the
      // usernameNotFound error so that a malicious user can't use the error
      // to narrow down if it's the username or password that's incorrect
      incorrectPassword: 'Incorrect password for ${username}',
    },

    // How long a user will remain logged in, in seconds
    expires: 60 * 60 * 24 * 365 * 10,
  }

  const resetPasswordOptions = {
    // handler() is invoked after the password has been successfully updated in
    // the database. Returning anything truthy will automatically logs the user
    // in. Return `false` otherwise, and in the Reset Password page redirect the
    // user to the login page.
    handler: (user) => {
      return user
    },

    // If `false` then the new password MUST be different than the current one
    allowReusedPassword: true,

    errors: {
      // the resetToken is valid, but expired
      resetTokenExpired: 'resetToken is expired',
      // no user was found with the given resetToken
      resetTokenInvalid: 'resetToken is invalid',
      // the resetToken was not present in the URL
      resetTokenRequired: 'resetToken is required',
      // new password is the same as the old password (apparently they did not forget it)
      reusedPassword: 'Must choose a new password',
    },
  }

  const signupOptions = {
    // Whatever you want to happen to your data on new user signup. Redwood will
    // check for duplicate usernames before calling this handler. At a minimum
    // you need to save the `username`, `hashedPassword` and `salt` to your
    // user table. `userAttributes` contains any additional object members that
    // were included in the object given to the `signUp()` function you got
    // from `useAuth()`.
    //
    // If you want the user to be immediately logged in, return the user that
    // was created.
    //
    // If this handler throws an error, it will be returned by the `signUp()`
    // function in the form of: `{ error: 'Error message' }`.
    //
    // If this returns anything else, it will be returned by the
    // `signUp()` function in the form of: `{ message: 'String here' }`.
    handler: async ({ username, hashedPassword, salt, userAttributes }) => {

      logger.debug(`sending email via: ${process.env.SMTP_HOST}`)
      let transporter = nodemailer.createTransport({
             host: process.env.SMTP_HOST,
             port: 2525,
             auth: {
                 user: process.env.SMTP_USER,
                 pass: process.env.SMTP_PASS
             }
     })

      const verifyLink = `${process.env.REDIRECT_URL}/verify-email?verifyToken=${user.verifyToken}`
      const message = {
        from: process.env.AUTH_EMAIL_FROM,
        to: username,
        subject: "Reset Forgotten Password",
        html: `Welcome, please click this link to <a href="${verifyLink}">Verify Your Email Address</>`
      }

      transporter.sendMail(message, (err, info) => {
        if (err) {
          console.log(err)
        } else {
          console.log(info);
        }
      })

      await db.user.create({
        data: {
          email: username,
          hashedPassword: hashedPassword,
          salt: salt,
          // name: userAttributes.name,
          id: nanoid()
        },
      })

      return { message: 'Thank you for creating your account.  Please go to your email and click the Verify Your Email Address link before logging in.'}
    },

    errors: {
      // `field` will be either "username" or "password"
      fieldMissing: '${field} is required',
      usernameTaken: 'Username `${username}` already in use',
    },
  }

  const authHandler = new DbAuthHandler(event, context, {
    // Provide prisma db client
    db: db,

    // The name of the property you'd call on `db` to access your user table.
    // ie. if your Prisma model is named `User` this value would be `user`, as in `db.user`
    authModelAccessor: 'user',

    // A map of what dbAuth calls a field to what your database calls it.
    // `id` is whatever column you use to uniquely identify a user (probably
    // something like `id` or `userId` or even `email`)
    authFields: {
      id: 'id',
      username: 'email',
      hashedPassword: 'hashedPassword',
      salt: 'salt',
      resetToken: 'resetToken',
      resetTokenExpiresAt: 'resetTokenExpiresAt',
    },

    forgotPassword: forgotPasswordOptions,
    login: loginOptions,
    resetPassword: resetPasswordOptions,
    signup: signupOptions,
  })

  return await authHandler.invoke()
}

1 Like

Hi there again Al!

There isn’t actually a place on dbAuth for the verification of the account, but you can incorporate it as a service - which I’d assume is the unofficial-recommended way of doing so. You’ll just need to make the service open (@skipAuth). But you could just as easily create your own function and access the database through it.

Every other part of how you’re describing the flow is exactly how I accomplished confirmation as well :slight_smile:

Hello my Friend,

Thanks for the tip, I expect I’ll end up making my own service too.

1 Like

As you noted, there’s a number of things needed to implement a confirm email:

  • generate confirmation token and sent at in user’s profile record
  • send an email
  • accept an incoming request to verify the token and confirm the user
  • apply some auth check to not deny the user access if their account isn’t confirmed

You could create a serverless function for the verifyConfirmationToken and then use that link in your email went sent.

You could in that function do the work to verify th request, check the token for expiration confirm the token is valid, lookup the user by token and then set the confirmedAt or or confirmation flag.

But, you would want to make sure that only the current auth’d user could update the token and confirmation flags.

It doesn’t necessarily need to be part of dbAuth – but that would be a good enhancement:

  • on signup create the confirmation tokens in User
  • provider a hook to send email confirmation
  • provider a verifyConfirmationToken handler
  • update requireAuth check to ensure user account is confirmed
1 Like

This would be a great addition to dbAuth

We have taken a crack at solving this as part of our template. We didn’t use a separate function, but included the flow as part of the graphql endpoint.

This is a first pass at a solution, but maybe it might help give you some direction.

API:

/api/db/schema.prisma

We added a verifyToken to the user schema. Which, if it is set, they need to verify. We thought about adding an expiration but decided against it for now

model User {
  ...
  verifyToken         String?
}

/api/src/graphql/users.sdl.ts

Added a couple of mutations to the users graphql endpoints to handle frontend requests. verifyUser is what handles the verification process. verifyReset resends the email to the user.

  type Mutation {
    ...
    verifyUser(token: String!): Boolean! @skipAuth
    verifyReset(email: String!): String! @skipAuth
  }

api/src/services/users/users.ts

For verifyUser, we are doing the verification before authentication. So anyone with a valid verify token could verify any user, but we thought this was low risk.

verifyReset could reset the token and send a new one, but I didn’t see the reason to do that at this time since we aren’t expiring these tokens.

export const verifyReset: MutationResolvers['verifyReset'] = async ({ email }) => {
  const user = await db.user.findUnique({ where: { email } })
  if (user?.verifyToken) {
    sendEmail({
      to: user.email,
      subject: verificationEmail.subject(),
      html: verificationEmail.htmlBody(user),
    })
  }
  return email
}

export const verifyUser: MutationResolvers['verifyUser'] = async ({ token }) => {
  if (token === null) return true
  const user = await db.user.findFirst({ where: { verifyToken: token } })
  if (user) {
    await db.user.update({ where: { id: user.id }, data: { verifyToken: null } })
    return true
  } else {
    return false
  }
}

/api/src/functions/auth.ts

At sign-up we send the email. There could be some optimization here (like async the email call), but this is our initial stab. We set the verifyToken with a random UUID. Send the email, which we have abstracted out (see next code block).

import { randomUUID } from 'node:crypto'
import { email as verificationEmail } from 'src/emails/user-verification'
import { sendEmail } from 'src/lib/mailer'

...

  const signupOptions = {
    handler: async ({ username, hashedPassword, salt, userAttributes }) => {
      const user = await db.user.create({
        data: {
          email: username,
          hashedPassword: hashedPassword,
          salt: salt,
          verifyToken: randomUUID(),
        },
      })

      sendEmail({
        to: user.email,
        subject: verificationEmail.subject(),
        html: verificationEmail.htmlBody(user),
      })
      return user
    },

    errors: {
      fieldMissing: '${field} is required',
      usernameTaken: 'Username `${username}` already in use',
    },
  }

...

api/src/emails/user-verification.ts

File the holds the actual email message we compose and send.

import type { User } from '@prisma/client'

import { userNameWithFallback } from 'src/lib/username'

const email = {
  subject: () => 'Verify Email',
  htmlBody: (user: User) => {
    const link = `${process.env.DOMAIN}/verification?verifyToken=${user.verifyToken}`
    const appName = process.env.APP_NAME

    return `
        <div> Hi ${userNameWithFallback(user)}, </div>
        <p>Please find below a link to verify your email for the ${appName}:</p>
        <a href="${link}">${link}</a>
        <p>If you did not request an account, please ignore this email.</p>
      `
  },
}

export { email }

Client:

On the client side we handle verification before login. This might change in the future, but that was our starting point.

/web/src/Routes.tsx

Both these routes are at the root, and not surrounded by any authenticate sets.

...
      <Route path="/verification" page={VerificationPage} name="verification" />
      <Route path="/verification-reset" page={VerificationResetPage} name="verificationReset" />
...

/web/src/pages/VerificationPage/VerificationPage.tsx

On page load we capture the verification token and make an attempt to verify the user without them having to do anything besides click the link in the email. This introduced an interesting testing issue with mocks that we had to handle, but you can see that in the template.

If successful it redirects to the login page and toasts a message. Failure spits out a failure toast message.

/web/src/pages/VerificationResetPage/VerificationResetPage.tsx

This has a form which takes an email. Currently the actual response returns a true/false if we sent the email. However it always spits out a success message whether or not it was actually successful so we don’t leak account emails, but someone could figure it out.

5 Likes