Xây dựng tính năng authentication trên project Nextjs 13 với NextAuth
- Published on
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
- Login bằng Username, Password
- Login bằng tài khoản Google
- Tạo UI hiển thị thông tin user sau khi đăng nhập
- Customize Login Form
- Phân quyền với Middleware
- Resource
- Conclusion
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_URL và NEXTAUTH_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_URL và NEXTAUTH_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.
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 IDs
và Client 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.
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
- NextAuth: https://next-auth.js.org
- Github: https://github.com/vanthai2704/nextjs-13.5-with-shadcn
- Link Live Demo: https://nextjs-13-5-with-shadcn.vercel.app
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é 🥰