New product features | The latest in technology | The weekly debugging nightmares & more!
January 6, 2024
Take my knowledge and shove it up your brain...
2-factor authentication is one of the best ways to protect your account.
In this article, we'll learn how to add 2-factor authentication to our website so we can protect our users' accounts.
We will continue where we left off in the last article after we enabled email verification.
we'll need to make some changes in our user schema and add new schemas.
In the user schema, we'll add these 2 fields
isTwoFactorEnabled
twoFactorConfirmation
schema.prisma
1datasource db { 2 provider = "postgresql" 3 url = env("DATABASE_URL") 4 directUrl = env("DIRECT_URL") 5} 6 7generator client { 8 provider = "prisma-client-js" 9} 10model User { 11 id String @id @default(cuid()) 12 name String? 13 email String? @unique 14 emailVerified DateTime? 15 image String? 16 password String? 17 accounts Account[] 18 isTwoFactorEnabled Boolean @default(true) 19 twoFactorConfirmation TwoFactorConfirmation? 20} 21model Account { 22 id String @id @default(cuid()) 23 userId String 24 type String 25 provider String 26 providerAccountId String 27 refresh_token String? @db.Text 28 access_token String? @db.Text 29 expires_at Int? 30 token_type String? 31 scope String? 32 id_token String? @db.Text 33 session_state String? 34 35 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 36 37 @@unique([provider, providerAccountId]) 38} 39model VerificationToken { 40 id String @id @default(cuid()) 41 email String 42 token String @unique 43 expires DateTime 44 45 @@unique([email, token]) 46} 47 48model TwoFactorToken { 49 id String @id @default(cuid()) 50 email String 51 token String @unique 52 expires DateTime 53 54 @@unique([email, token]) 55} 56 57model TwoFactorConfirmation { 58 id String @id @default(cuid()) 59 userId String 60 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 61 62 @@unique([userId]) 63} 64
The isTwoFactorEnabled
should default to false and the user would enable it on his settings page, but for the sake of simplicity in this tutorial, we'll make it enabled by default for all users.
The TwoFactorConfirmation
will determine if the user has successfully submitted the code he received or not.
The TwoFactorToken
stores the token value and its expiry date.
In your utils
folder create two-factor-token.ts
file.
utils/two-factor-token.ts
1import { prisma } from "@/lib/prisma"; 2 3export const getTwoFactorTokenByToken = async (token: string) => { 4 try { 5 const twoFactorToken = await prisma.twoFactorToken.findUnique({ 6 where: { token }, 7 }); 8 9 return twoFactorToken; 10 } catch { 11 return null; 12 } 13}; 14 15export const getTwoFactorTokenByEmail = async (email: string) => { 16 try { 17 const twoFactorToken = await prisma.twoFactorToken.findFirst({ 18 where: { email }, 19 }); 20 21 return twoFactorToken; 22 } catch { 23 return null; 24 } 25}; 26 27export const getTwoFactorConfirmationByUserId = async (userId: string) => { 28 try { 29 const twoFactorConfirmation = await prisma.twoFactorConfirmation.findUnique( 30 { 31 where: { userId }, 32 } 33 ); 34 35 return twoFactorConfirmation; 36 } catch { 37 return null; 38 } 39}; 40
The above functions will help us get the token sent to the user and check if he submitted the correct token.
In the tokens.ts
file in the lib
folder, we'll create a function that checks if there's a previous token, delete it, and create a new one.
lib/tokens.ts
1import crypto from "crypto";
2
3import { getTwoFactorTokenByEmail } from "@/utils/two-factor-token";
4
5
6export const generateTwoFactorToken = async (email: string) => {
7 const token = crypto.randomInt(100_000, 1_000_000).toString();
8 const expires = new Date(new Date().getTime() + 5 * 60 * 1000);
9
10 const existingToken = await getTwoFactorTokenByEmail(email);
11
12 if (existingToken) {
13 await prisma.twoFactorToken.delete({
14 where: {
15 id: existingToken.id,
16 },
17 });
18 }
19
20 const twoFactorToken = await prisma.twoFactorToken.create({
21 data: {
22 email,
23 token,
24 expires,
25 },
26 });
27
28 return twoFactorToken;
29};
The full code for the tokens.ts
file should be:
lib/tokens.ts
1import crypto from "crypto";
2import { v4 as uuidv4 } from "uuid";
3
4import { prisma } from "./prisma";
5import { getTwoFactorTokenByEmail } from "@/utils/two-factor-token";
6import { getVerificationTokenByEmail } from "@/utils/verification-token";
7
8export const generateTwoFactorToken = async (email: string) => {
9 const token = crypto.randomInt(100_000, 1_000_000).toString();
10 const expires = new Date(new Date().getTime() + 5 * 60 * 1000);
11
12 const existingToken = await getTwoFactorTokenByEmail(email);
13
14 if (existingToken) {
15 await prisma.twoFactorToken.delete({
16 where: {
17 id: existingToken.id,
18 },
19 });
20 }
21
22 const twoFactorToken = await prisma.twoFactorToken.create({
23 data: {
24 email,
25 token,
26 expires,
27 },
28 });
29
30 return twoFactorToken;
31};
32
33export const generateVerificationToken = async (email: string) => {
34 const token = uuidv4();
35 const expires = new Date(new Date().getTime() + 3600 * 1000);
36
37 const existingToken = await getVerificationTokenByEmail(email);
38
39 if (existingToken) {
40 await prisma.verificationToken.delete({
41 where: {
42 id: existingToken.id,
43 },
44 });
45 }
46
47 const verficationToken = await prisma.verificationToken.create({
48 data: {
49 email,
50 token,
51 expires,
52 },
53 });
54
55 return verficationToken;
56};
57
We'll need to update the login function so that it checks for the 2-factor code.
When the user first tries to log in, he'll use his email and password, so no verification code will be sent to the login function, in that case, the function will generate a new code and send it to the user's email.
The user will then be asked to submit the confirmation code and when he clicks submit, he will trigger the same login function again but this time it will receive the confirmation code.
It will check if the code is valid and not expired, if so, it will delete it from the database and create a new twoFactorConfirmation
row which simply indicates that this user has successfully submitted the confirmation code.
We will use the value of twoFactorConfirmation
later in the auth.ts file to make sure the user submitted the correct code, then we'll remove that row and let the user it, so you can consider this twoFactorConfirmation
as a ticket that gets created and removed right before the user is allowed in.
Here's the full code for the login function:
actions/login.ts
1"use server"; 2 3import * as z from "zod"; 4import bcrypt from "bcryptjs"; 5import { AuthError } from "next-auth"; 6 7import { signIn } from "@/auth"; 8import { prisma } from "@/lib/prisma"; 9import { LoginSchema } from "@/schemas"; 10import { getUserByEmail } from "@/utils/users"; 11import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 12import { getTwoFactorTokenByEmail } from "@/utils/two-factor-token"; 13import { sendVerificationEmail, sendTwoFactorTokenEmail } from "@/lib/mail"; 14import { 15 generateVerificationToken, 16 generateTwoFactorToken, 17} from "@/lib/tokens"; 18import { getTwoFactorConfirmationByUserId } from "@/utils/two-factor-token"; 19 20export const login = async ( 21 values: z.infer<typeof LoginSchema>, 22 callbackUrl?: string | null 23) => { 24 const validatedFields = LoginSchema.safeParse(values); 25 26 if (!validatedFields.success) { 27 return { error: "Invalid fields!" }; 28 } 29 30 const { email, password, code } = validatedFields.data; 31 32 const existingUser = await getUserByEmail(email); 33 34 if (!existingUser || !existingUser.email || !existingUser.password) { 35 return { error: "Email does not exist!" }; 36 } 37 // Check if password is correct 38 if (!(await bcrypt.compare(password, existingUser.password))) { 39 return { error: "Invalid credentials!" }; 40 } 41 // Send a verification email if the user's email is not verified 42 if (!existingUser.emailVerified) { 43 const verificationToken = await generateVerificationToken( 44 existingUser.email 45 ); 46 47 await sendVerificationEmail( 48 verificationToken.email, 49 verificationToken.token 50 ); 51 52 return { success: "Confirmation email sent!" }; 53 } 54 55 // Send a two factor code if the user has two factor enabled 56 if (existingUser.isTwoFactorEnabled && existingUser.email) { 57 if (code) { 58 const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email); 59 60 if (!twoFactorToken) { 61 return { error: "Invalid code!" }; 62 } 63 64 if (twoFactorToken.token !== code) { 65 return { error: "Invalid code!" }; 66 } 67 68 const hasExpired = new Date(twoFactorToken.expires) < new Date(); 69 70 if (hasExpired) { 71 return { error: "Code expired!" }; 72 } 73 74 await prisma.twoFactorToken.delete({ 75 where: { id: twoFactorToken.id }, 76 }); 77 78 const existingConfirmation = await getTwoFactorConfirmationByUserId( 79 existingUser.id 80 ); 81 82 if (existingConfirmation) { 83 await prisma.twoFactorConfirmation.delete({ 84 where: { id: existingConfirmation.id }, 85 }); 86 } 87 88 await prisma.twoFactorConfirmation.create({ 89 data: { 90 userId: existingUser.id, 91 }, 92 }); 93 } else { 94 const twoFactorToken = await generateTwoFactorToken(existingUser.email); 95 await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token); 96 97 return { twoFactor: true }; 98 } 99 } 100 101 try { 102 await signIn("credentials", { 103 email, 104 password, 105 redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, 106 }); 107 return { success: "Successfully logged in!" }; 108 } catch (error) { 109 if (error instanceof AuthError) { 110 switch (error.type) { 111 case "CredentialsSignin": 112 return { error: "Invalid credentials!" }; 113 default: 114 return { error: "Something went wrong!" }; 115 } 116 } 117 118 throw error; 119 } 120}; 121
As I said we'll check for the twoFactorConfirmation
right before the user is allowed in.
auth.ts
1import NextAuth from "next-auth";
2import { PrismaAdapter } from "@auth/prisma-adapter";
3
4import { prisma } from "./lib/prisma";
5import authConfig from "@/auth.config";
6import { getUserById } from "@/utils/users";
7import { getTwoFactorConfirmationByUserId } from "./utils/two-factor-token";
8
9export const {
10 handlers: { GET, POST },
11 auth,
12 signIn,
13 signOut,
14 update,
15} = NextAuth({
16 pages: {
17 signIn: "/login",
18 error: "/error",
19 },
20 callbacks: {
21 async signIn({ user, account }) {
22 const existingUser = await getUserById(user.id);
23
24 // Prevent sign in without email verification
25 if (!existingUser?.emailVerified) return false;
26
27 if (existingUser.isTwoFactorEnabled) {
28 const twoFactorConfirmation = await getTwoFactorConfirmationByUserId(
29 existingUser.id
30 );
31
32 if (!twoFactorConfirmation) return false;
33
34 // Delete two factor confirmation for next sign in
35 await prisma.twoFactorConfirmation.delete({
36 where: { id: twoFactorConfirmation.id },
37 });
38 }
39
40 return true;
41 },
42
43 },
44 adapter: PrismaAdapter(prisma),
45 session: { strategy: "jwt" },
46 ...authConfig,
47});
48
Now we need to add an input field to allow the user to submit the confirmation code.
LoginForm.tsx
1"use client";
2import * as z from "zod";
3import Link from "next/link";
4import { useForm } from "react-hook-form";
5import { useState, useTransition } from "react";
6import { useSearchParams } from "next/navigation";
7import { zodResolver } from "@hookform/resolvers/zod";
8import { LoginSchema } from "@/schemas";
9import { login } from "@/actions/login";
10
11import {
12 Card,
13 CardHeader,
14 CardBody,
15 Divider,
16 Link as NUILink,
17 Input,
18 Button,
19} from "@nextui-org/react";
20
21import { ErrorMessage } from "@hookform/error-message";
22type Props = {};
23
24export const LoginForm = ({}: Props) => {
25 const searchParams = useSearchParams();
26 const callbackUrl = searchParams.get("callbackUrl");
27 const [error, setError] = useState<string | undefined>("");
28 const [success, setSuccess] = useState<string | undefined>("");
29 const [showTwoFactor, setShowTwoFactor] = useState(false);
30
31 const [isPending, startTransition] = useTransition();
32 const {
33 reset,
34 register,
35 handleSubmit,
36 formState: { errors },
37 } = useForm<z.infer<typeof LoginSchema>>({
38 resolver: zodResolver(LoginSchema),
39 defaultValues: {
40 email: "",
41 password: "",
42 },
43 });
44
45 const onSubmit = (values: z.infer<typeof LoginSchema>) => {
46 setError("");
47 setSuccess("");
48
49 startTransition(() => {
50 login(values, callbackUrl)
51 .then((data) => {
52 if (data?.error) {
53 // reset();
54 setError(data.error);
55 }
56 if (data?.success) {
57 reset();
58 setSuccess(data.success);
59 }
60
61 if (data?.twoFactor) {
62 setShowTwoFactor(true);
63 }
64 })
65 .catch(() => setError("Something went wrong"));
66 });
67 };
68 return (
69 <Card className="w-[400px]">
70 <CardHeader className="flex justify-center">
71 <h1 className="text-xl font-bold">Login</h1>
72 </CardHeader>
73 <Divider />
74 <CardBody>
75 <form
76 onSubmit={handleSubmit(onSubmit)}
77 className="space-y-3 flex flex-col items-center"
78 >
79 {showTwoFactor ? (
80 <>
81 <Input
82 variant="bordered"
83 label="Two factor code"
84 type="text"
85 {...register("code")}
86 />
87 <ErrorMessage
88 errors={errors}
89 name="code"
90 render={({ message }) => (
91 <p className="text-red-500 text-sm text-left w-full">
92 {message}
93 </p>
94 )}
95 />
96 </>
97 ) : (
98 <>
99 <Input
100 variant="bordered"
101 label="Email"
102 type="email"
103 {...register("email")}
104 />
105 <ErrorMessage
106 errors={errors}
107 name="email"
108 render={({ message }) => (
109 <p className="text-red-500 text-sm text-left w-full">
110 {message}
111 </p>
112 )}
113 />
114 <Input
115 variant="bordered"
116 label="Password"
117 type="password"
118 {...register("password")}
119 />
120 <ErrorMessage
121 errors={errors}
122 name="password"
123 render={({ message }) => (
124 <p className="text-red-500 text-sm text-left w-full">
125 {message}
126 </p>
127 )}
128 />
129 </>
130 )}
131 {error && (
132 <p className="text-red-500 text-sm text-left w-full">{error}</p>
133 )}
134 {success && (
135 <p className="text-green-500 text-sm text-left w-full">{success}</p>
136 )}
137 <Button
138 type="submit"
139 isLoading={isPending}
140 isDisabled={isPending}
141 color="primary"
142 fullWidth
143 >
144 Login
145 </Button>
146 <NUILink as={Link} href="/signup" color="primary">
147 Don't have an account?
148 </NUILink>
149 </form>
150 </CardBody>
151 </Card>
152 );
153};
154
Let's try that out.
And it works!
If you submit a wrong code it will give you an error.
In a real application, you shouldn't force the users to use 2FA, but this is just a demo to teach you how it's done.
In the next article, we'll learn how to add reset password functionality.
How to add credential authentication using Auth.js & Next 14
Allow users to login using their email & password.
How to add email verification using Auth.js
Enhance your website security by allowing only verified users to log in.
How to enable password reset using auth.js
Forgot your password? now worries, I got your bro.
How to use clerk to protect your route handlers
Clerk can also protect route handlers, Nice