Next.js 2-Factor Authentication with Email and Password credentials based on session cookies and Prisma ORM

Next.js 2-Factor Authentication (app directory) with Email and Password credentials, role-based authorizations, basic admin dashboard with CRUD functionality and toggle light dark mode as cherry on top.

Tuedo#017

 by  tuedodev

12
Apr 2024
 0

Next.js and its shift to app directory and server side rendering (SSR) as a standard design pattern introduces new challenges for authentication, a fundamental aspect for many websites.

Although NextAuth is a standard library for many authentication options, its documentation warns against the use of email and password credentials as an authentication method due to additional complexity associated with supporting usernames and passwords.

Without going into the pros and cons in detail, it should be noted that in many cases it is necessary to integrate a simple email credential system into a website. Whether 3rd party providers can guarantee more security in every case is not something I would like to evaluate conclusively at this point.

However, the fact that SSR with Next.js and a separation of concern between clientside code and sensitive data on the server site provides an additional security level is certainly beyond doubt. So why not write a simple boilerplate code for Next.js, which is a good base camp for the ascension to higher and more complex websites?

The goal is to implement basic authentication with the following features:

  • user administration with session cookies and specified roles (in our case USER and ADMIN).
  • 2-Factor Authentication: User has to confirm registration via magic link provided by email.
  • Password-forget: User gets magic link via email and can reset password within a given time frame.
  • User (role USER) can update the settings and reset password.
  • User (role USER) can delete his or her account inside the danger zone after additional modal confirmation.
  • user (role ADMIN) can make CRUD operations on users on the admin dashboard. We are using useOptimistic hook for a smooth UX.
  • toggle light/dark mode (current fallback theme is dark).

For the transactional emails we use a free tier of the Resend API. If you implement your project yourself, I recommend integrating your own domain so that full email functionality is possible when registering users. In the demo version of the site on Vercel, new registration is not possible because the free tier for development only allows an email to be sent to a registered email address in order to avoid spamming.

The context providers as the backbone of the app

The core of Next.js Authentication is the embedding of Next.js Pages and React Components in four context providers.

The one that is crucial for authentication is the session provider (@/component/context/SessionProvider.tsx), which provides a useSession hook and allows access to the current user and grants authorization rights via the two roles ADMIN and USER that have just been assigned.

'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import type { SessionContextType, SessionType } from '@/lib/types';
import { isValidUser } from '@/lib/utils';

const SessionContext = createContext(null);

type Props = {
	children: React.ReactNode;
	currentSession: SessionType;
};

const SessionProvider: React.FC = ({ children, currentSession }) => {
	const [session, setSession] = useState(currentSession);
	const [isAuthenticated, setIsAuthenticated] = useState(isValidUser(currentSession));

	useEffect(() => {
		setSession((_) => currentSession);
		setIsAuthenticated((_) => isValidUser(currentSession));
	}, [currentSession]);

	return {children};
};

export function useSession() {
	const context = useContext(SessionContext);
	if (!context) throw new Error('useSession hook has to be used within a SessionProvider');
	return context;
}

export default SessionProvider;

In accordance with the requirements of SSR and the architecture of Next.js, the providers are each flagged with 'use client' as client components.

A second important component is the FormProvider (@/component/context/FormProvider.tsx), which uses the hook useForm to allow shared access and control of the form elements InputField.tsx (uncontrolled) and InputFieldControlled.tsx (controlled), PasswordField.tsx, SelectFieldControlled.tsx (controlled), HiddenField.tsx and the child components SubmitButton.tsx and ClickButton.tsx.

'use client';

import { INITIAL_ERROR_STATE } from '@/lib/constants';
import { validatateRawFormDataAgainstSchema } from '@/lib/validation';
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { useFormState } from 'react-dom';
import { ZodSchema, ZodType, z } from 'zod';
import { useModal } from './ModalProvider';
import { ModalContentProps } from '../layout/ModalBuilder';
import { isEqualWithCurrentFormData } from '@/lib/utils';

type FormContextType = {
	onChangeHandler: (event: React.FormEvent) => void;
	error: z.inferFlattenedErrors> | undefined;
	values: Record;
	isChanged: boolean;
};
type Props = {
	children: React.ReactNode;
	dispatcher: (previousState: any, formData: FormData) => Promise>>;
	schema: ZodSchema;
	passwordRefs?: React.MutableRefObject;
	initValues: Record;
	modal?: ModalContentProps;
};
const FormContext = createContext(null);

const FormProvider: React.FC = ({ children, dispatcher, schema, passwordRefs, initValues, modal }) => {
	const [error, setError] = useState>>(INITIAL_ERROR_STATE);
	const [serverValidation, formAction] = useFormState(dispatcher, null);
	const [values, setValues] = useState(initValues);
	const [isChanged, setIsChanged] = useState(false);
	const ref = useRef(null);
	const { setContent, setShowModal, showModal } = useModal();

	useEffect(() => {
		const formData = ref.current ? new FormData(ref.current) : new FormData();
		setIsChanged((_) => !isEqualWithCurrentFormData(initValues, formData));
	}, [values, initValues, setIsChanged]);

	useEffect(() => {
		if (serverValidation) {
			if (Object.keys(serverValidation.fieldErrors).length > 0 || serverValidation.formErrors.length > 0) {
				setError((prev) => {
					return {
						...prev,
						...serverValidation,
					};
				});
				if (passwordRefs?.current) {
					for (let i = 0; i < passwordRefs.current.length; i++) {
						passwordRefs.current[i].value = '';
					}
				}
			} else {
				if (modal) {
					setContent(modal);
				}
				setShowModal(true);
			}
		}
	}, [serverValidation, setError, passwordRefs, setContent, setShowModal, modal]);

	const onChangeHandler = useCallback(
		async (event: React.FormEvent) => {
			const formData = ref.current ? new FormData(ref.current) : new FormData();
			const inputElement = event.target as HTMLInputElement;
			const { id, value } = inputElement;
			if (inputElement) {
				const result = validatateRawFormDataAgainstSchema(schema, formData);
				setValues((prev) => ({ ...prev, [id]: value }));
				if (!result.success) {
					const errors = result.error.flatten();
					setError((prev) => {
						let fieldErrors =
							id in errors.fieldErrors
								? { ...prev?.fieldErrors, [id]: errors.fieldErrors[id] }
								: { ...prev?.fieldErrors, [id]: undefined };

						return {
							formErrors: [...errors.formErrors],
							fieldErrors,
						};
					});
				} else {
					setError((_) => INITIAL_ERROR_STATE);
				}
			}
		},
		[setError, schema]
	);

	return (
		
{children}
); }; export function useForm() { const context = useContext(FormContext); if (!context) throw new Error('useForm hook has to be used within a FormProvider'); return context; } export default FormProvider;

My intention was to integrate as few dependencies as possible; the React Hook Form library would certainly allow more extensive access. The present validation is carried out via the validation library Zod on the server side with Server Actions using the React Form Hook useFormState, which enables client-side feedback (error messages) after the server-side managed validation.

Also worth mentioning within the @components/context directory are the providers ModalProvider.tsx, which, as the name suggests, generates the modals via React Portals and controls the forwarding, and the ThemeProvider.tsx for switching the light and dark themes1.

Middleware paves the way

Next.js makes it possible to read the session cookie via ./middleware.ts before the response object is returned and to determine the control flow of the website in advance depending on the user and his or her role.

In this way, sensitive areas of the website can only be made accessible to certain roles and their authorizations.

import { NextRequest, NextResponse } from 'next/server';
import { getSessionToken, updateSessionToken } from './lib/session';

export async function middleware(request: NextRequest) {
	const pathname = request.nextUrl.pathname;
	const session = await getSessionToken();

	if (pathname.startsWith('/auth')) {
		if (
			session &&
			(pathname.startsWith('/auth/login') ||
				pathname.startsWith('/auth/register') ||
				pathname.startsWith('/auth/verify') ||
				pathname.startsWith('/auth/set-new-password') ||
				pathname.startsWith('/auth/forgot-password'))
		) {
			return NextResponse.redirect(new URL('/', request.url));
		}

		if (!session && pathname.startsWith('/auth/settings')) {
			return NextResponse.redirect(new URL('/auth/login?refresh=true', request.url));
		}

		if (pathname.startsWith('/auth/admin') && (!session || session.role !== 'ADMIN')) {
			return NextResponse.redirect(new URL('/auth/login?refresh=true', request.url));
		}
	}

	return await updateSessionToken(session);
}

export const config = {
	matcher: [
		/*
		 * Match all request paths except for the ones starting with:
		 * - api (API routes)
		 * - _next/static (static files)
		 * - _next/image (image optimization files)
		 * - favicon.ico (favicon file)
		 */
		{
			source: '/((?!api|_next/static|_next/image|favicon.ico|jpg$|png$).*)',
			missing: [
				{ type: 'header', key: 'next-router-prefetch' },
				{ type: 'header', key: 'purpose', value: 'prefetch' },
			],
		},
	],
};

The auth directory now contains all the pages required for user administration: Login and Register, Admin (only accessible for the ADMIN role), forgot-password and set-new-password as well as settings, where the users can change their data and set a new password via the switch toggle button or delete their account in the Danger Zone after entering an additional security string.

Environment variables and settings for session cookies and magic links

Within .env.local in the root directory of the Next.js app, a secret private key for encrypting and decrypting the session cookies should be specified, otherwise an error message will be displayed.

Furthermore, settings for sending emails (here via the Resend API) and the database can be adjusted there.

SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
RESEND_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
POSTGRES_URL="postgres://default:xxxxxxxxxxxxxxxxxxxxxx.eu-central-1.aws.neon.tech:5432/xxxxxxxx?sslmode=require"
POSTGRES_PRISMA_URL="postgres://default:xxxxxxxxxxxxxxxxxxxxxx.eu-central-1.aws.neon.tech:5432/xxxxxxxx?sslmode=require&pgbouncer=true&connect_timeout=15"
POSTGRES_URL_NO_SSL="postgres://default:xxxxxxxxxxxxxxxxxxxxxx.eu-central-1.aws.neon.tech:5432/xxxxxxxx"
POSTGRES_URL_NON_POOLING="postgres://xxxxxxxxxxxxxxxxxxxxxx.eu-central-1.aws.neon.tech:5432/xxxxxxxx?sslmode=require"
POSTGRES_USER="default"
POSTGRES_HOST="ep-rapid-xxxxxxxxxxxxxxxxxxxxxx.aws.neon.tech"
POSTGRES_PASSWORD="xxxxxxxxxxxxxxxx"
POSTGRES_DATABASE="xxxxxxxx"

The expiration period of the session cookies and magic links is very easy and can be adapted to your own requirements in the configuration file @lib/constants.ts. The base URL for the domain should also be adjusted there.

export const SESSION_EXPIRING_SECONDS = 24 * 60 * 60; /* 24 hours */
export const THEME_EXPIRING_SECONDS = 24 * 60 * 60; /* 24 hours */
export const EMAIL_VERIFICATION_LINK_VALID_SECONDS = 60 * 60; /* 1 hour */
export const EMAIL_PASSWORD_RESET_LINK_VALID_SECONDS = 15 * 60; /* 15 minutes */
export const DROPDOWN_MENU_DELAY = 180;
export const THROTTLE_EMAIL_DISPATCH_IN_MILLISECONDS = 1000 * 60 * 5; /* only 1 email can be sent every 5 minutes */
export const FALLBACK_THEME: Theme = 'dark'; /* Dark mode is standard theme*/
export const INITIAL_ERROR_STATE = {
	formErrors: [] as string[],
	fieldErrors: {},
};
export const STATUS_TAILWIND_BG_COLORS: Record = {
	REGISTERED: 'bg-yellow-700/50',
	ACTIVE: 'bg-green-700/50',
	INACTIVE: 'bg-red-700/50',
};

Prisma ORM and database seed

For the sake of simplicity, I have used Prisma ORM for the database connection (Postgres is used as the database; in order to make the Prisma schema compatible with SQLite, I have avoided using enums for the role and status schema and established a reference via foreign keys).

With npx prisma db seed, 20 dummy accounts can be created in the database. These can be adapted to the respective requirements in the JSON file @prisma/dummyUser.json. As Prisma and Next.js use different sources for their environment variables by default, the CLI must be prefixed with dotenv -e .env.local so that Next.js and Prisma can read from the shared pool of env.local.

Demo version deployed on Vercel, Tests and Code repo

A demo version of this authentication boilerplate for Next.js with limited functionality is deployed on Vercel, the code is available for free use in my Github repo. With npm run test, some unit tests are started with vitest (dev dependency). Enjoy!

Update 03.09.2024
With React 19, the previous hook useFormState is now renamed to useActionState. However, React 19 is not yet fully supported in the stable version of Next.js, which is why the previous name is currently still used in this code.

Github Repository of the Code

Live-Demo on Vercel

  1. Toggle Light and Dark Theme derserves its own blog article: Click this Link.

This post has not been commented on yet.

Add a comment