In this article, you’ll learn how to implement forget/reset passwords with Node.js, Prisma, PostgreSQL, Nodemailer, Redis, Docker-compose, and Pug.

CRUD API with Node.js and PostgreSQL Series:

  1. API Node.js, TypeScript, Prisma, PostgreSQL: Project Setup
  2. Node.js + Prisma + PostgreSQL: Access & Refresh Tokens
  3. CRUD API with Node.js and PostgreSQL: Send HTML Emails
  4. API with Node.js, Prisma & PostgreSQL: Forget/Reset Password
API with Node.js, Prisma & PostgreSQL Forget-Reset Password

Forget/Reset Password with Node.js, Prisma, & PostgreSQL

HTTP METHODROUTEDESCRIPTION
POST/api/auth/forgotpasswordTo request a reset token
PATCH/api/auth/resetpassword/:resetTokenTo reset the password
Node.js, Prisma & PostgreSQL Fo...
Node.js, Prisma & PostgreSQL Forget/Reset Passwords

To reset the password, the user will make a POST request with his email address to the Node.js server.

node.js, prisma, postgresql forgot password page

The server then validates the email, generates the reset token, and sends the password reset token to the user’s email.

Also, the server returns a success message to the frontend app indicating that the email has been sent.

node.js, prisma, postgresql forgot password success message

The user then clicks on the “Reset Password” button upon receiving the reset token email.

node.js, prisma, postgresql reset password email

The user is then taken to the password reset page where he is required to enter his new password before making a PATCH request to the server.

node.js, prisma, postgresql reset password page

The server then validates the reset token and updates the user’s password in the database before returning a success message to the frontend app.

The frontend app receives the success message and redirects the user to the login page.

node.js, prisma, postgresql reset password success message

Update Prisma User Model

The forget/reset password mechanism requires the users table to have some specific columns. To do so edit and add the following fields to the user Prisma schema.

  • provider : This column stores a value if the user registered with a Google, GitHub, or Facebook OAuth provider. This step is needed for the upcoming series.
  • passwordResetToken : This column stores the hashed password reset token.
  • passwordResetAt : This column stores the timestamp within which the user has to change the password. I decided to give the user 10 minutes to change the password but you can change the time to suit your application.

Lastly, I added a unique constraint and an index on the passwordResetToken column since we’ll be querying the database by it.

prisma/schema.prisma


model User{
  @@map(name: "users")

  id String  @id @default(uuid())
  name String  
  email String @unique
  photo String? @default("default.png")
  verified Boolean? @default(false) 
  
  password String
  role RoleEnumType? @default(user)

  verificationCode String? @db.Text @unique

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  provider String?
  passwordResetToken String?
  passwordResetAt DateTime?

  @@unique([email, verificationCode, passwordResetToken])
  @@index([email, verificationCode,passwordResetToken])
}

enum RoleEnumType {
  user
  admin
}

Create Migration and Update the PostgreSQL Database

Once you’ve updated the user schema you need to create a new Prisma migration to reflect the changes before pushing it to the PostgreSQL database.

The PostgreSQL database docker container must be running for this to work.

package.json


{
"scripts": {
    "start": "ts-node-dev --respawn --transpile-only --exit-child src/app.ts",
    "db:migrate": "npx prisma migrate dev --name user-entity --create-only && yarn prisma generate",
    "db:push": "npx prisma db push",
    "build": "tsc . -p"
  },
}

Remember to change the migration name --name user-entity to help you distinguish between the different migrations in the future.

Run the following command to create and push the Prisma migration to the PostgreSQL database.


yarn db:migrate && yarn db:push

Update the User Schema

In every backend application, it’s always recommended to validate the request body in a middleware before passing it to the controllers or handlers.

You need to validate the request body and send the appropriate error messages when any of the schema rules have been violated.

There’re many validation libraries like Joi, Zod, Yup, etc but I decided to use Zod since it’s a Typescript-first schema validation library with static type inference and I feel comfortable using it in my React and Node.js projects.

Add the following schemas to the user.schema.ts file.

src/schemas/user.schema.ts


export const forgotPasswordSchema = object({
  body: object({
    email: string({
      required_error: 'Email is required',
    }).email('Email is invalid'),
  }),
});

export const resetPasswordSchema = object({
  params: object({
    resetToken: string(),
  }),
  body: object({
    password: string({
      required_error: 'Password is required',
    }).min(8, 'Password must be more than 8 characters'),
    passwordConfirm: string({
      required_error: 'Please confirm your password',
    }),
  }).refine((data) => data.password === data.passwordConfirm, {
    message: 'Passwords do not match',
    path: ['passwordConfirm'],
  }),
});

export type ForgotPasswordInput = TypeOf<typeof forgotPasswordSchema>['body'];
export type ResetPasswordInput = TypeOf<typeof resetPasswordSchema>;

The user.schema.ts file should now look like this:

src/schemas/user.schema.ts


import { object, string, TypeOf, z } from 'zod';

enum RoleEnumType {
  ADMIN = 'admin',
  USER = 'user',
}

export const registerUserSchema = object({
  body: object({
    name: string({
      required_error: 'Name is required',
    }),
    email: string({
      required_error: 'Email address is required',
    }).email('Invalid email address'),
    password: string({
      required_error: 'Password is required',
    })
      .min(8, 'Password must be more than 8 characters')
      .max(32, 'Password must be less than 32 characters'),
    passwordConfirm: string({
      required_error: 'Please confirm your password',
    }),
    role: z.optional(z.nativeEnum(RoleEnumType)),
  }).refine((data) => data.password === data.passwordConfirm, {
    path: ['passwordConfirm'],
    message: 'Passwords do not match',
  }),
});

export const loginUserSchema = object({
  body: object({
    email: string({
      required_error: 'Email address is required',
    }).email('Invalid email address'),
    password: string({
      required_error: 'Password is required',
    }).min(8, 'Invalid email or password'),
  }),
});

export const verifyEmailSchema = object({
  params: object({
    verificationCode: string(),
  }),
});

export const updateUserSchema = object({
  body: object({
    name: string({}),
    email: string({}).email('Invalid email address'),
    password: string({})
      .min(8, 'Password must be more than 8 characters')
      .max(32, 'Password must be less than 32 characters'),
    passwordConfirm: string({}),
    role: z.optional(z.nativeEnum(RoleEnumType)),
  })
    .partial()
    .refine((data) => data.password === data.passwordConfirm, {
      path: ['passwordConfirm'],
      message: 'Passwords do not match',
    }),
});

export const forgotPasswordSchema = object({
  body: object({
    email: string({
      required_error: 'Email is required',
    }).email('Email is invalid'),
  }),
});

export const resetPasswordSchema = object({
  params: object({
    resetToken: string(),
  }),
  body: object({
    password: string({
      required_error: 'Password is required',
    }).min(8, 'Password must be more than 8 characters'),
    passwordConfirm: string({
      required_error: 'Please confirm your password',
    }),
  }).refine((data) => data.password === data.passwordConfirm, {
    message: 'Passwords do not match',
    path: ['passwordConfirm'],
  }),
});

export type RegisterUserInput = Omit<
  TypeOf<typeof registerUserSchema>['body'],
  'passwordConfirm'
>;

export type LoginUserInput = TypeOf<typeof loginUserSchema>['body'];
export type VerifyEmailInput = TypeOf<typeof verifyEmailSchema>['params'];
export type UpdateUserInput = TypeOf<typeof updateUserSchema>['body'];

export type ForgotPasswordInput = TypeOf<typeof forgotPasswordSchema>['body'];
export type ResetPasswordInput = TypeOf<typeof resetPasswordSchema>;


Services to Query and Mutate the PostgreSQL Database

Here I defined some services that will be called by the controllers to query and mutate the database state.

In Node.js architecture, it’s recommended to separate the business and application logic.

Most of the business logic should be implemented in the models or services and you should end up with fat models or services and thin controllers.

src/services/user.service.ts


import { PrismaClient, Prisma, User } from '@prisma/client';
import { omit } from 'lodash';
import config from 'config';
import redisClient from '../utils/connectRedis';
import { signJwt } from '../utils/jwt';

export const excludedFields = [
  "password",
  "verified",
  "verificationCode",
  "passwordResetAt",
  "passwordResetToken",
];

const prisma = new PrismaClient();

export const createUser = async (input: Prisma.UserCreateInput) => {
  return (await prisma.user.create({
    data: input,
  })) as User;
};

export const findUser = async (
  where: Partial<Prisma.UserCreateInput>,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.findFirst({
    where,
    select,
  })) as User;
};

export const findUniqueUser = async (
  where: Prisma.UserWhereUniqueInput,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.findUnique({
    where,
    select,
  })) as User;
};

export const updateUser = async (
  where: Partial<Prisma.UserWhereUniqueInput>,
  data: Prisma.UserUpdateInput,
  select?: Prisma.UserSelect
) => {
  return (await prisma.user.update({ where, data, select })) as User;
};

export const signTokens = async (user: Prisma.UserCreateInput) => {
  // 1. Create Session
  redisClient.set(`${user.id}`, JSON.stringify(omit(user, excludedFields)), {
    EX: config.get<number>('redisCacheExpiresIn') * 60,
  });

  // 2. Create Access and Refresh tokens
  const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
    expiresIn: `${config.get<number>('accessTokenExpiresIn')}m`,
  });

  const refresh_token = signJwt({ sub: user.id }, 'refreshTokenPrivateKey', {
    expiresIn: `${config.get<number>('refreshTokenExpiresIn')}m`,
  });

  return { access_token, refresh_token };
};


Create a Utility Class to Send Emails

Below is the utility class to send the password reset token to the user’s email address.

Please read CRUD API with Node.js and PostgreSQL: Send HTML Emails for a detailed explanation.


import nodemailer from 'nodemailer';
import config from 'config';
import pug from 'pug';
import { convert } from 'html-to-text';
import { Prisma } from '@prisma/client';

const smtp = config.get<{
  host: string;
  port: number;
  user: string;
  pass: string;
}>('smtp');

export default class Email {
  #firstName: string;
  #to: string;
  #from: string;
  constructor(private user: Prisma.UserCreateInput, private url: string) {
    this.#firstName = user.name.split(' ')[0];
    this.#to = user.email;
    this.#from = `Codevo <admin@admin.com>`;
  }

  private newTransport() {
    // if (process.env.NODE_ENV === 'production') {
    // }

    return nodemailer.createTransport({
      ...smtp,
      auth: {
        user: smtp.user,
        pass: smtp.pass,
      },
    });
  }

  private async send(template: string, subject: string) {
    // Generate HTML template based on the template string
    const html = pug.renderFile(`${__dirname}/../views/${template}.pug`, {
      firstName: this.#firstName,
      subject,
      url: this.url,
    });
    // Create mailOptions
    const mailOptions = {
      from: this.#from,
      to: this.#to,
      subject,
      text: convert(html),
      html,
    };

    // Send email
    const info = await this.newTransport().sendMail(mailOptions);
    console.log(nodemailer.getTestMessageUrl(info));
  }

  async sendVerificationCode() {
    await this.send('verificationCode', 'Your account verification code');
  }

  async sendPasswordResetToken() {
    await this.send(
      'resetPassword',
      'Your password reset token (valid for only 10 minutes)'
    );
  }
}



Create Controllers

Now it’s time to define the controllers that will send the password reset token and reset the password.

Forgot Password Controller

The forgot password controller is responsible for validating the user’s email, generating the password reset token, and sending the password reset token to the user’s email address.

src/controllers/auth.controller.ts


export const forgotPasswordHandler = async (
  req: Request<
    Record<string, never>,
    Record<string, never>,
    ForgotPasswordInput
  >,
  res: Response,
  next: NextFunction
) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: req.body.email.toLowerCase() });
    const message =
      'You will receive a reset email if user with that email exist';
    if (!user) {
      return res.status(200).json({
        status: 'success',
        message,
      });
    }

    if (!user.verified) {
      return res.status(403).json({
        status: 'fail',
        message: 'Account not verified',
      });
    }

    if (user.provider) {
      return res.status(403).json({
        status: 'fail',
        message:
          'We found your account. It looks like you registered with a social auth account. Try signing in with social auth.',
      });
    }

    const resetToken = crypto.randomBytes(32).toString('hex');
    const passwordResetToken = crypto
      .createHash('sha256')
      .update(resetToken)
      .digest('hex');

    await updateUser(
      { id: user.id },
      {
        passwordResetToken,
        passwordResetAt: new Date(Date.now() + 10 * 60 * 1000),
      },
      { email: true }
    );

    try {
      const url = `${config.get<string>('origin')}/resetPassword/${resetToken}`;
      await new Email(user, url).sendPasswordResetToken();

      res.status(200).json({
        status: 'success',
        message,
      });
    } catch (err: any) {
      await updateUser(
        { id: user.id },
        { passwordResetToken: null, passwordResetAt: null },
        {}
      );
      return res.status(500).json({
        status: 'error',
        message: 'There was an error sending email',
      });
    }
  } catch (err: any) {
    next(err);
  }
};

Below is a summary of what happened in the forgotPasswordHandler :

  • I first retrieved the email from the request body and called the findUser() service to check if a user with that email exists in the database.
  • Next, I generated the password reset token with the Node.js Crypto module and hashed it.
  • Lastly, I sent the unhashed reset token to the user’s email and stored the hashed one in the PostgreSQL database.

Create the Password Reset Controller

Now let’s define the resetPasswordHandler that will validate the reset token and update the user’s password in the PostgreSQL database.

src/controllers/auth.controller.ts


export const resetPasswordHandler = async (
  req: Request<
    ResetPasswordInput['params'],
    Record<string, never>,
    ResetPasswordInput['body']
  >,
  res: Response,
  next: NextFunction
) => {
  try {
    // Get the user from the collection
    const passwordResetToken = crypto
      .createHash('sha256')
      .update(req.params.resetToken)
      .digest('hex');

    const user = await findUser({
      passwordResetToken,
      passwordResetAt: {
        gt: new Date(),
      },
    });

    if (!user) {
      return res.status(403).json({
        status: 'fail',
        message: 'Invalid token or token has expired',
      });
    }

    const hashedPassword = await bcrypt.hash(req.body.password, 12);
    // Change password data
    await updateUser(
      {
        id: user.id,
      },
      {
        password: hashedPassword,
        passwordResetToken: null,
        passwordResetAt: null,
      },
      { email: true }
    );

    logout(res);
    res.status(200).json({
      status: 'success',
      message: 'Password data updated successfully',
    });
  } catch (err: any) {
    next(err);
  }
};

Here is a breakdown of what I did in the above code snippets:

  • First, I extracted the reset token from the request params and hashed it.
  • Next, I called the findUser() service to check if a user with that reset token exists in the database.
  • Next, I hashed the new password with BcryptJs and called the updateUser() service to update the user’s password in the database.
  • Also, I set the passwordResetToken and passwordResetAt to null to prevent a user from resetting the password twice.
  • Lastly, I logged the user out of the application by sending expired cookies.

Add the routes

Next, add the following routes to the auth.routes.ts file. Also, remember to call the schema validation middleware before the controllers.

src/routes/auth.routes.ts


router.post(
  '/forgotpassword',
  validate(forgotPasswordSchema),
  forgotPasswordHandler
);

router.patch(
  '/resetpassword/:resetToken',
  validate(resetPasswordSchema),
  resetPasswordHandler
);

The auth.routes.ts file should now look like this:

src/routes/auth.routes.ts


import express from 'express';
import {
  forgotPasswordHandler,
  loginUserHandler,
  logoutUserHandler,
  refreshAccessTokenHandler,
  registerUserHandler,
  resetPasswordHandler,
  verifyEmailHandler,
} from '../controllers/auth.controller';
import { deserializeUser } from '../middleware/deserializeUser';
import { requireUser } from '../middleware/requireUser';
import { validate } from '../middleware/validate';
import {
  forgotPasswordSchema,
  loginUserSchema,
  registerUserSchema,
  resetPasswordSchema,
  verifyEmailSchema,
} from '../schemas/user.schema';

const router = express.Router();

router.post('/register', validate(registerUserSchema), registerUserHandler);

router.post('/login', validate(loginUserSchema), loginUserHandler);

router.get('/refresh', refreshAccessTokenHandler);

router.get(
  '/verifyemail/:verificationCode',
  validate(verifyEmailSchema),
  verifyEmailHandler
);

router.get('/logout', deserializeUser, requireUser, logoutUserHandler);

router.post(
  '/forgotpassword',
  validate(forgotPasswordSchema),
  forgotPasswordHandler
);

router.patch(
  '/resetpassword/:resetToken',
  validate(resetPasswordSchema),
  resetPasswordHandler
);

export default router;


Conclusion

In this article, you learned how to implement forget/reset password functionalities with Node.js, Prisma, PostgreSQL, Nodemailer, Redis, Pug, and Docker-compose.

Check out the source codes: