Enhancing Password Security and Recovery with Next.js 14 and NextAuth.js

Written by ljaviertovar | Published 2024/04/23
Tech Story Tags: nextjs | authentication | front-end-development | web-development | reactjs | javascript | typescript | cybersecurity

TLDRvia the TL;DR App

In the first two parts of our series, we dove into the essentials of user authentication and email verification in Next.js 14 using NextAuth.js, React Hook Form, and Zod, followed by advanced techniques for handling email resend functionalities.

Now, in the third installment, we continue to enhance our authentication system by focusing on crucial security features in the password.

Initial Configuration

Make sure your project already has the authentication system described in the first and second parts of the tutorial implemented. This includes having Next.js 14, NextAuth, and Resend configured correctly.

Check-password-strength and reset Password integration

Setting up

  1. Install the dependencies needed in the project. This time we’ll use pnpm you can use the package manager of your choice.

pnpm add check-password-strength jsonwebtoken
pnpm add -D @types/jsonwebtoken

2. Create the following structure for the project:

...
├── emails/
│   ├── ...
│   └── reset-password-template.tsx
...
├── src/
│   ├── actions/
│   │   ├── email-actions.tsx
│   │   └── auth-actions.tsx
│   ├── app/
│   │   ...
│   │   ├── (primary)/
│   │   │   ├── auth/
│   │   │   │   ... 
│   │   │   │   ├── forgot-password/
│   │   │   │   │   └── page.tsx
│   │   │   │   └── reset-password/
│   │   │   │       └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   └── page.tsx
│   │   │   ...   
│   ├── components/
│   │   └── auth/
│   │       ...
│   │       ├── signup-form.tsx
│   │       ├── reset-password-form.tsx
│   │       └──forgot-password-form.tsx
│   ...
│   ├── lib/
│   │   ...
│   │   └── jwt.ts
│   ...
...
├── .env
...

Check-password-strength component

In previous tutorials, we successfully implemented basic password validation (minimum of 6 characters) along with confirmation password validation.

Now, we will introduce a more robust validation to ensure users create stronger passwords. Additionally, we will add functionality to toggle the visibility of the password, allowing users to view or hide their input as needed.

components/auth/signup-form.tsx :

'use client'

// Import necessary hooks and utilities from React and other libraries.
...
import { passwordStrength } from 'check-password-strength'
// Import UI components and icons.
...
import PasswordStrength from './password-strength'
...
// Function to register a new user.
import { registerUser } from '@/actions/auth-actions'

// Define a schema for the form using Zod for validation rules.
const formSchema = z
 .object({
  ...
  password: z
      .string({ required_error: 'Password is required' })
   .min(6, 'Password must have at least 6 characters')
   .max(32, 'Password must be up to 32 characters')
   .regex(
    new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9]).{6,20}$'),
    'Password must contain at least 1 small letter, 1 capital letter, 1 number and 1 special character'
   ),
  confirmPassword: z
   .string({ required_error: 'Confirm your password is required' })
   .min(6, 'Password must have at least 6 characters')
   .max(20, 'Password must be up to 20 characters'),
 })
 .refine(values => values.password === values.confirmPassword, {
  message: "Password and Confirm Password doesn't match!",
  path: ['confirmPassword'],
 })

export function SignUpForm() {
 const [passStrength, setPassStrength] = useState(0) // State to track password strength.
 const [isVisiblePass, setIsVisiblePass] = useState(false) // State to toggle password visibility.
  ...

 const toggleVisblePass = () => setIsVisiblePass(prev => !prev) // Toggle password visibility.

 const { password } = form.watch() // Watch the password input for changes.
 useEffect(() => {
  setPassStrength(passwordStrength(password).id) // Update password strength on change.
 }, [password])

 ...

 return (
  <Form {...form}>
   <form onSubmit={form.handleSubmit(onSubmit)}>
    <div className='grid gap-2'>
    ...

     {/* Password input field */}
     <FormField
      control={form.control}
      name='password'
      render={({ field }) => (
       <FormItem>
        <FormControl>
         <div className='flex items-center gap-2'>
          <Icons.key
           className={`${form.formState.errors.password ? 'text-destructive' : 'text-muted-foreground'} `}
          />
          <Input
           type={isVisiblePass ? 'text' : 'password'}
           placeholder='Your Password'
           className={`${form.formState.errors.password && 'border-destructive bg-destructive/30'}`}
           {...field}
          />
          {/* Toggle icon for showing/hiding password */}
          {isVisiblePass ? (
           <Icons.eyeOff
            onClick={toggleVisblePass}
            className={`${form.formState.errors.password ? 'text-destructive' : 'text-muted-foreground'} `}
           />
          ) : (
           <Icons.eye
            onClick={toggleVisblePass}
            className={`${form.formState.errors.password ? 'text-destructive' : 'text-muted-foreground'} `}
           />
          )}
         </div>
        </FormControl>
        <FormMessage />
       </FormItem>
      )}
     />

     {/* Display password strength component */}
     <PasswordStrength passStrength={passStrength} />

     {/* Confirm password input field */}
     <FormField
      control={form.control}
      name='confirmPassword'
      render={({ field }) => (
       <FormItem>
        <FormControl>
         <div className='flex items-center gap-2'>
          <Icons.key
           className={`${form.formState.errors.confirmPassword ? 'text-destructive' : 'text-muted-foreground'} `}
          />
          <Input
           type='password'
           placeholder='Confirm your Password'
           className={`${form.formState.errors.confirmPassword && 'border-destructive bg-destructive/30'}`}
           {...field}
          />
         </div>
        </FormControl>
        <FormMessage />
       </FormItem>
      )}
     />
     ...
    </div>
   </form>
  </Form>
 )
}

components/auth/password-strength.tsx :

// Import the utility function for conditional class names.
import { cn } from '@/lib/utils'

interface Props {
 passStrength: number
}

export default function PasswordStrength({ passStrength }: Props) {
 return (
  <div className='w-full flex gap-2'>
   {Array.from({ length: passStrength + 1 }).map((_i, index) => (
    <div
     key={index}
     className={cn('h-1 w-1/4 rounded-md', {
      'bg-red-500': passStrength === 0,
      'bg-orange-500': passStrength === 1,
      'bg-yellow-500': passStrength === 2,
      'bg-green-500': passStrength === 3,
     })}
    ></div>
   ))}
  </div>
 )
}

Password Strength Meter

The passwordStrength function of the check-password-strength library evaluates the password strength dynamically as it is entered by the user.

Dynamic Strength Assessment: The password field from the form is watched using the watch() method from react-hook-form.

Every time the password changes, the useEffect hook triggers an update to the passStrength state by calling passwordStrength(password).id. This function assesses the strength of the password and updates a numerical ID representing the strength level.

Visual Feedback: The PasswordStrength component displays this strength in a visual format to the user, offering immediate feedback on the security level of their chosen password.

Password Visibility Toggle: To improve user experience, especially when creating a complex password adhering to the stringent requirements set by the form, the component includes a toggle feature to show or hide the password.

Sending an email to reset the password

First, we need to create a form where users can enter their email to request a password reset. Users will arrive at this page by clicking on the “Forgot my password” link.

components/auth/forgot-password.tsx :

/* 
all imports 
*/

...

export function ForgotPasswordForm() {
 ...

 async function onSubmit(values: InputType) {
  try {
   setIsLoading(true)

   const result = await forgotPassword(values.email)
   if (result) {
    toast({
     title: 'Reset password link sent!',
     description: 'Please check your email to reset your password.',
     variant: 'success',
    })
   }
  } catch (error) {
   console.error(error)
   toast({
    title: 'Something went wrong!',
    description: `We couldn't create your account.\nPlease try again later!`,
    variant: 'destructive',
   })
  } finally {
   setIsLoading(false)
  }
 }

 return (
  <Form {...form}>
   <form onSubmit={form.handleSubmit(onSubmit)}>
    <div className='grid gap-1'>
     <FormField
      control={form.control}
      name='email'
      render={({ field }) => (
       <FormItem>
        <FormControl>
         <div className='flex items-center gap-2'>
          <Icons.email
           className={`${form.formState.errors.email ? 'text-destructive' : 'text-muted-foreground'} `}
          />
          <Input
           type='email'
           placeholder='Your Email'
           className={`${form.formState.errors.email && 'border-destructive bg-destructive/30'}`}
           {...field}
          />
         </div>
        </FormControl>
        <FormMessage />
       </FormItem>
      )}
     />
    </div>

    <Button
     className='w-full text-foreground mt-4'
     disabled={isLoading}
    >
     {isLoading && <Icons.spinner className='mr-2 h-4 w-4 animate-spin' />}
     Submit
    </Button>
   </form>
  </Form>
 )
}

As we can see, it is a simple form that requires just email input.

actions/auth-actions.ts :

...

export async function forgotPassword(email: string) {
    // Use Prisma to find a unique user by email.
 const user = await prisma.user.findUnique({
  where: {
   email, 
  },
  select: {
   id: true, 
   username: true, 
   email: true, 
  },
 })

    // If no user is found, return true ().
 if (!user) return true

    // Generate a JWT for the user's ID.
 const jwtUserId = signJwt({
  id: user.id, // Include user ID in the JWT payload.
 })

    // Update the user's record in the database with the reset password token.
 await prisma.user.update({
  where: {
   id: user.id,
  },
  data: {
   resetPasswordToken: jwtUserId,
  },
 })

 // Send a reset password email to the user.
 const sendEmailResult = await sendEmail({
  to: [user.email],
  subject: 'Reset your password',
  react: React.createElement(ResetPasswordTemplate, { username: user.username, resetPasswordToken: jwtUserId }),
 })

 return sendEmailResult
}

...

Note: In the previous validation, if we don’t find the user in the database, we return true to exit the function. This action is intended to show the user a success message as if an email had indeed been sent, even if they do not have an account.

This is a security practice designed to avoid revealing additional information and prevent users from attempting to reset passwords using different email addresses.

We will continue with the development of the template, which will be sent by email. The process for developing templates is explained in past tutorials.

emails/reset-password-template.tsx :

import * as React from 'react'
import { Body, Button, Container, Head, Hr, Html, Img, Preview, Section, Text } from '@react-email/components'
import { getBaseUrl } from '@/utils'
const baseUrl = getBaseUrl()

interface ResetPasswordTemplateProps {
 username: string
 resetPasswordToken: string
}

export const ResetPasswordTemplate = ({ username, resetPasswordToken }: ResetPasswordTemplateProps) => (
 <Html>
  <Head />
  <Preview>The sales intelligence platform that helps you uncover qualified leads.</Preview>
  <Body style={main}>
   <Container style={container}>
    <Img
     src='https://res.cloudinary.com/dbwpoihqk/image/upload/v1710118456/taggy/assets/my-saas.png'
     alt='My SaaS'
     style={logo}
    />
    <Text style={title}>Hi {username}!</Text>
    <Text style={title}>We have sent this email because you have forgotten your password.</Text>
    <Text style={paragraph}>You can reset your password with the link below:</Text>
    <Section style={btnContainer}>
     <Button
      style={button}
      // link with token to reset passwopassword 
      href={`${baseUrl}/auth/reset-password/${resetPasswordToken}`}
     >
      Click here to reset
     </Button>
    </Section>
    <Hr style={hr} />
    <Text style={footer}>Something in the footer.</Text>
   </Container>
  </Body>
 </Html>
)

export default ResetPasswordTemplate

/*
styles
*/

Reset password

Once the user receives and follows the link to reset their password, they will be redirected to another page where the authenticity and validity of the token are verified.

On this page, the user can set a new password and replace the old one.

(primary)/auth/reset-password/[jwt] :

/* 
all imports
*/
// Define the Props interface with optional callbackUrl inside searchParams object and params.
interface Props {
 params: {
  jwt: string
 }
 searchParams: {
  callbackUrl?: string
 }
}

export default async function ResetPasswordPage({ params, searchParams }: Props) {
// Await a check to see if the user is already logged in, redirect if they are. 
await isLogged(searchParams.callbackUrl as string)

// Verify the JWT extracted from params.
 const isTokenValid = await verifyValidToken(params.jwt)

 return (
  <div className='grid place-content-center py-40'>
   <Card className='w-80 max-w-sm text-center'>
    <CardHeader>
     <CardTitle className={`${!isTokenValid && 'text-destructive'}`}>Reset Password</CardTitle>
    </CardHeader>
    <CardContent>
     <div className='w-full grid place-content-center py-4'>
      <ChangePasswordIcon
       size={56}
       color={`${!isTokenValid ? '#7F1D1D' : 'currentColor'}`}
      />
     </div>

     {isTokenValid ? (
      <ResetPasswordForm jwtUserId={params.jwt} />
     ) : (
      <p className={`${!isTokenValid && 'text-destructive'}`}>The URL is not valid!</p>
     )}
    </CardContent>
   </Card>
  </div>
 )
}

components/auth/reset-password-form.tsx :

The reset-password-form component is very similar to the signup form, however, it specifically includes only the inputs for the password, confirmPassword, and the passwordStrength display.

actions/auth-actions.ts :

export async function verifyValidToken(jwtUserId: string): Promise<boolean> {
    // Verify the JWT to extract the payload or receive `null` if invalid.
 const payload = verifyJwt(jwtUserId)

 if (!payload) return false // If JWT is invalid, return false.

    // Query the database to check if there is a user with the specified reset password token.
 const user = await prisma.user.findUnique({
  where: {
   resetPasswordToken: jwtUserId,
  },
  select: {
   id: true,
  },
 })

 if (!user) return false // If no user is found with that reset token, return false.

 return true
}

export async function resetPassword(jwtUserId: string, password: string): Promise<'userNotExist' | 'success'> {
    // Verify the JWT and extract the payload or determine it's invalid (null payload).
 const payload = verifyJwt(jwtUserId)

 if (!payload) return 'userNotExist' // If JWT is invalid or user does not exist, return 'userNotExist'.

 const userId = payload.id // Extract the user ID from the JWT payload.

    // Check if the token is still unused.
 const unusedToken = await prisma.user.findUnique({
  where: {
   resetPasswordToken: jwtUserId,
  },
  select {
   resetPasswordToken: true,
  },
 })

 if (!unusedToken) return 'userNotExist' // If no token is found, it might be used already, return 'userNotExist'.

    // Update the user's password in the database and clear the reset token.
 const passwordUpdated = await prisma.user.update({
  where: {
   id: userId,
  },
  data: {
   resetPasswordToken: null,
   password: await bcrypt.hash(password, 10), // Hash the new password before storing it.
  },
 })

 if (passwordUpdated) return 'success' // If update is successful, return 'success'.
 else throw new Error('Something went wrong!') // If update fails, throw an error.
}


Finally, after the user has entered and confirmed a secure password, they will be able to sign in with their new password. Additionally, it’s common practice to send an email confirmation that the password has been reset. Based on what we’ve developed, you can choose to implement this notification if you wish.

🎉 That’s it!

Now, users will have more control over their accounts and can recover them if they lose their passwords.

🧑‍💻 Repo here

Conclusion

The password reset features we have developed provide users with a secure and easy way to regain access to their accounts. This increases user confidence and improves the overall experience by making it easy to recover accounts in the event of forgotten or compromised passwords.


Want to connect with the Author?

Love connecting with friends all around the world on 𝕏.


Also published here.


Written by ljaviertovar | ☕ FrontEnd engineer 👨‍💻 Indie maker ✍️ Tech writer
Published by HackerNoon on 2024/04/23