Next.jsnext-auth

Xây dựng tính năng authentication trên project Nextjs 13 với NextAuth

By Thái Nguyễn
Picture of the author
Published on
image alt attribute

Next.js và NextAuth là một bộ đôi hoàn hảo cho việc phát triển ứng dụng web. Next.js là một framework React giúp bạn dễ dàng xây dựng ứng dụng React nhanh chóng thân thiện với Search Engine với hệ thống routing và rendering mạnh mẽ. NextAuth là một thư viện xác thực cho Next.js, giúp bảo vệ ứng dụng của bạn khỏi các cuộc tấn công CSRF và cung cấp nhiều phương thức xác thực mạnh mẽ.

Trong bài viết hôm nay, chúng ta sẽ cùng xây dựng 1 ứng dụng web với chức năng Login bằng username, password, và login bằng tài khoản Google sử dụng NextAuth. Next Auth đã hỗ trợ App Router trên Nextjs 13 vì vậy mình sẽ sử dụng App Router cho demo. Các UI component sử dụng shadcn UI. Các bạn có thể thay thế bằng các UI component khác hoặc dùng các UI mặc định.


Table Of Content


Install Next Auth

Để cài đặt Next Auth bạn cần add thêm thư viên vào project bằng 1 trong 3 câu lệnh sau:

yarn add next-auth
npm install next-auth
pnpm install next-auth

Sau khi install chúng ta cần cấu hình 2 biến NEXTAUTH_URLNEXTAUTH_SECRET vào file .env. NextAuth sẽ tự detect được khi chúng ta cấu hình trong file .env.

NEXTAUTH_URL là Base Url của website của bạn, Tạo NEXTAUTH_SECRET bằng cách chạy dòng lệnh sau thông qua openssl command:

$ openssl rand -base64 32

Ví dụ về NEXTAUTH_URLNEXTAUTH_SECRET:

// .env
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="vA6YJUFoLvemDmz0DRLVNO5qD06kdu2t1VAbbUjtKZc=" 

Login bằng Username, Password

Ví dụ dưới đây mình sử dụng sẵn API Login của dummyjson.com để làm ví dụ.

Tài khoản để test:

username: "kminchelle"
password: "0lelplR"
// src/app/api/auth/[...nextauth]/route.ts

import NextAuth, { NextAuthOptions, Session } from "next-auth";
import { JWT } from "next-auth/jwt";
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        username: { label: "Username", type: "text", placeholder: "johnsmith" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials, req) {
        if (credentials) {
          const res = await fetch("https://dummyjson.com/auth/login", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
              username: credentials.username,
              password: credentials.password,
              expiresInMins: 60, // optional
            }),
          }).then((res) => res.json());

          if (res?.message) {
            throw new Error("Login Failed");
          }

          if (res) {
            return res;
          } else {
            return null;
          }
        } else {
          return null;
        }
      },
    }),
  ],
  callbacks: {
    async session({ session, token, user }) {
      const sessionInfo = {
        user: {
          id: token.id,
          username: token.username,
          email: token.email,
          name: `${token.firstName} ${token.lastName}`,
          image: token.image,
        },
        expires: session.expires,
        accessToken: token.token,
      };

      return sessionInfo as Session;
    },
    async jwt({ token, user, account, profile }) {
      if (user) {
        return {...token, ...user};
      }
      return token;
    },
  },
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST }; 

Hàm jwt() callback sẽ được trigger khi User Login lần đầu tiên. User object sẽ là kết quả từ authorize function. kết quả return từ jwt callback sẽ được lưu vào session cookie.

Hàm session() callback sẽ nhận nội dung session cookie bởi token parameter. Kết quả return từ session callback sẽ được trả ra khi sử dụng hook useSession hay function getServerSession.

Sau khi cấu hình xong bạn có thể vào đường dẫn /api/auth/signin để xem form login của NextAuth gennerate.

Screenshot 2023-10-27 at 22.08.35

Login bằng tài khoản Google

Tạo OAuth 2.0 Client IDs

Để Login bằng tài khoản Google bạn cần truy cập Google Credentials sau đó click button CREATE CREDENTIALS và tạo Oauth Client ID.

Hãy nhớ cấu hình Authorized redirect URIs( white list callback url ) và Authorized JavaScript origins nhé.

VD về Authorized JavaScript origins:

Local: http://localhost:3000

Trên production thì sẽ là http://yourdomain

VD về Authorized redirect URIs khi sử dụng NextAuth:

Local: http://localhost:3000/api/auth/callback/google

Trên production thì sẽ là http://yourdomain/api/auth/callback/google

Thêm cấu hình options cho NextAuth

Sau khi tạo xong Client IDsClient secret, chúng ta sẽ thêm vào options của NextAuth như sau:

thêm biến vào file env:

// .env
GOOGLE_CLIENT_ID="xxxxx"
GOOGLE_CLIENT_SECRET="xxxxx"

Thêm config cho Google Provider NextAuth

// src/app/api/auth/[...nextauth]/route.ts
providers: [
    ...
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID ?? "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "",
      profile: (profile, tokens) => {
        if (profile) {
          return {
            id: profile.sub,
            name: profile.firstName,
            lastName: profile.family_name,
            firstName: profile.given_name,
            image: profile.picture,
          };
        } else {
          throw new Error("Login Failed");
        }
      },
    }),
  ],
  ...

Vậy chúng ta đã cấu hình xong Authentication bằng Credentials và Google Account. Tiếp theo chúng ta sẽ tạo giao diện hiển thị thông tin User sau khi đăng nhập và button login, logout.


Tạo UI hiển thị thông tin user sau khi đăng nhập


"use client";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { signIn, signOut, useSession } from "next-auth/react";
import { Button } from "./ui/button";

const Header = () => {
  const { data } = useSession();

  const menuItems = [
    {
      href: "/dashboard",
      name: "Dashboard",
    },
    {
      href: "/movies",
      name: "Movies",
    },
  ];

  return (
    <div className="flex flex-row justify-end">
      <div>
        {data?.user && (
          <div className="flex flex-row items-center gap-5">
            <Avatar>
              <AvatarImage src={data?.user?.image ?? ""} />
            </Avatar>
            <h6>{data?.user?.name}</h6>
            <Button variant={"destructive"} onClick={() => signOut()}>
              Logout
            </Button>
          </div>
        )}

        {!data?.user && (
          <Button variant={"default"} onClick={() => signIn()}>
            Login
          </Button>
        )}
      </div>
    </div>
  );
};

export default Header;

Với logic code ở trên, tên và avatar người dùng sẽ được hiển thị nếu đăng nhập thành công. Hiển thị button Login khi chưa có thông tin user, ngược lại hiển thị button Logout khi đã login. Data của useSession() chính là dữ liệu trả về từ session function trong thuộc tính callbacks từ step trước.

Sử dụng Session Provider NextAuth trên Nextjs13

Trong ví dụ ở đoạn code trên, để sử dụng được useSession() hook của NextAuth trên Nextjs13 chúng ta cần thêm session provider vào project.

Tạo Session Provider với use client:

// src/hocs/session-provider.tsx
"use client";

import { SessionProvider } from "next-auth/react";
export default SessionProvider;

Sau đó thêm provider vào file src/layout.tsx để có thể sử dụng data của useSession() hook trong toàn bộ project

// src/layout.tsx
import SessionProvider from "@/hocs/session-provider";
import { authOptions } from "@/lib/auth";
import type { Metadata } from "next";
import { getServerSession } from "next-auth";
import { Inter } from "next/font/google";
import "@/styles/globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}

Customize Login Form

Nếu bạn muốn tự customize Login Form theo style riêng của mình hay bạn muốn tự validate error cho các form fields theo rule của mình thì NextAuth có thể đáp ứng khá dễ dàng.

1. Cấu hình path cho Login Page
// src/app/api/auth/[...nextauth]/route.ts
providers: [
    ...
  ],
  callbacks: {
    ...
  },
  pages: {
    signIn: "/login",
  },
2. Tạo customize Login Form cho NextAuth
// src/components/user-auth-form.tsx
"use client";

import * as React from "react";
import { useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from "next-auth/react";
import { useForm } from "react-hook-form";
import * as z from "zod";

import { cn } from "@/lib/utils";
import { userAuthSchema } from "@/lib/validations/auth";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/use-toast";
import { Icons } from "./icons";
import { useRouter } from "next/navigation";
import Image from "next/image";

interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}

type FormData = z.infer<typeof userAuthSchema>;

export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(userAuthSchema),
  });
  const [isLoading, setIsLoading] = React.useState<boolean>(false);
  const [isGoogleLoading, setIsGoogleLoading] = React.useState<boolean>(false);
  const searchParams = useSearchParams();
  const router = useRouter();

  async function onSubmit(data: FormData) {
    setIsLoading(true);

    const signInResult = await signIn("credentials", {
      username: data.username,
      password: data.password,
      redirect: false,
      callbackUrl: searchParams?.get("callbackUrl") || "/",
    });

    setIsLoading(false);

    if (!signInResult?.ok || signInResult?.error) {
      return toast({
        title: signInResult?.error ?? "Something went wrong.",
        description: "Your sign in request failed. Please try again.",
        variant: "destructive",
      });
    }

    toast({
      title: "Login Success",
    });

    if (signInResult?.url) {
      router.push(signInResult.url);
    }
  }

  return (
    <div className={cn("grid gap-6", className)} {...props}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div className="grid gap-2">
          <div className="grid gap-1">
            <Label className="sr-only" htmlFor="email">
              Email
            </Label>
            <Input
              id="username"
              placeholder="username"
              type="text"
              autoCapitalize="none"
              autoCorrect="off"
              disabled={isLoading}
              {...register("username")}
            />
            {errors?.username && (
              <p className="px-1 text-xs text-red-600">
                {errors.username.message}
              </p>
            )}
          </div>
          <div className="grid gap-1">
            <Label className="sr-only" htmlFor="email">
              Password
            </Label>
            <Input
              id="password"
              type="password"
              autoCapitalize="none"
              autoComplete="email"
              autoCorrect="off"
              placeholder="password"
              disabled={isLoading || isGoogleLoading}
              {...register("password")}
            />
            {errors?.password && (
              <p className="px-1 text-xs text-red-600">
                {errors.password.message}
              </p>
            )}
          </div>
          <Button variant="default" disabled={isLoading}>
            Sign In
          </Button>
        </div>
      </form>
      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <span className="w-full border-t" />
        </div>
        <div className="relative flex justify-center text-xs uppercase">
          <span className="bg-background px-2 text-muted-foreground">
            Or continue with
          </span>
        </div>
      </div>
      <button
        type="button"
        className={cn(buttonVariants({ variant: "outline" }))}
        onClick={() => {
          setIsGoogleLoading(true);
          signIn("google", {
            redirect: true,
            callbackUrl: searchParams?.get("callbackUrl") || "/",
          });
        }}
        disabled={isLoading || isGoogleLoading}
      >
        {isGoogleLoading ? (
          <Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
        ) : (
          <Image
            src="/google-icon.png"
            width={16}
            height={16}
            alt="google icon"
            className="mr-2"
          />
        )}{" "}
        google
      </button>
    </div>
  );
}

Đoạn code trên mình sử dụng React Hook Form kết hợp với thư viện Zod để handle logic form và validation. Bạn có thể xem live demo tại đây.

Screen Recording 2023-10-27 at 22.30.46

Phân quyền với Middleware

Một phần khá quan trọng đó là phần quyền các Page của Web Application. Phải làm thế nào nếu bạn muốn User phải đăng nhập trước khi xem được nội dung của một số Page?

Dưới đây là 1 ví dụ về sử dụng withAuth() hook của NextAuth và middleware của Nextjs:

import { getToken } from "next-auth/jwt";
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";

export default withAuth(
  async function middleware(req) {
    const token = await getToken({ req });

    const isAuth = !!token;
    const isAuthPage =
      req.nextUrl.pathname.startsWith("/login") ||
      req.nextUrl.pathname.startsWith("/register");

    if (isAuthPage) {
      if (isAuth) {
        return NextResponse.redirect(new URL("/dashboard", req.url));
      }

      return null;
    }

    if (!isAuth) {
      let from = req.nextUrl.pathname;
      if (req.nextUrl.search) {
        from += req.nextUrl.search;
      }

      return NextResponse.redirect(
        new URL(`/login?callbackUrl=${encodeURIComponent(from)}`, req.url)
      );
    }
  },
  {
    callbacks: {
      authorized: ({ req, token }) => {
        return true;
      },
    },
  }
);

export const config = {
  matcher: ["/dashboard/:path*", "/movies/:path*", "/login", "/register"],
};

Với config được cấu hình như trên, các path match trong list matcher thì mới trigger function middleware.

trong function middleware, token trả về từ function getToken chính là dữ liệu return từ function session trong options callback của NextAuth. Nếu chưa đăng nhập thì token sẽ không có dữ liệu và back về Page Login cùng param callbackUrl. Nếu Login thành công sẽ quay trở lại trang đó.


Resource

Conclusion

Chúng ta đã hoàn thành 1 flow authentication sử dụng username, password hoặc Google Account. NextAuth quả thực là một thư viện hữu ích, giúp chúng ta giảm đáng kể thời gian phát triển tính năng authentication. Chúng ta cũng dễ dàng tích hợp luồng authen với các bên thứ 3 như Google, Github, Facebook, ... Về vấn đề bảo mật, NextAuth cũng đảm bảo được sự an toàn khi đã có những phương án phòng chống CSRF Attack như: sử dụng CSRF Token, sử dụng cookies với SameSite là Lax.

Hãy comment nếu bạn có điều gì thắc mắc hoặc muốn trao đổi thêm về bài viết này nhé 🥰