Nader's Daily Blog
Welcome to Every Developers favorite blog in the Devosphere

New product features | The latest in technology | The weekly debugging nightmares & more!

How to add 2-factor authentication

How to add 2-factor authentication

January 6, 2024

Nader Elmahdy

Nader Elmahdy

Take my knowledge and shove it up your brain...

Enhance your users' security using 2-factor authentication

Authentication

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.

Update our schemas

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.

Utils

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.

Generate the confirmation code

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


Update the login function

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


Update auth.ts file

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


Update the login form

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&apos;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.

You might also like: