در این مقاله قصد داریم تا با مفهوم احراز هویت (authentication) آشنا شویم و نحوه افزودن آن به برنامه Next.js، با کمک NextAuth.js را با هم بررسی کنیم.

منظور از احراز هویت چیست؟

امروزه احراز هویت بخش کلیدی بسیاری از اپلیکیشن‌های وب است. به زبان ساده، کارکرد احراز هویت به این صورت است که بررسی می‌کند آیا کاربر همان کسی است که می‌گوید یا خیر.

یک وب سایت امن اغلب از راه‌های متعددی برای بررسی هویت کاربر استفاده می‌نماید. به عنوان مثال، پس از وارد کردن نام کاربری و رمز عبور، سایت ممکن است یک کد تأیید به دستگاه ما ارسال کند، یا این که از یک برنامه خارجی مانند Google Authenticator استفاده نماید. این احراز هویت دو مرحله‌ای (۲FA) به افزایش امنیت کمک می‌کند. در این صورت، حتی اگر شخصی رمز عبور ما را یاد بگیرد، نمی‌تواند بدون داشتن رمز منحصر به فرد ما به حساب کاربری‌مان دسترسی داشته باشد.

مقایسه Authentication و Authorization

در توسعه وب، authentication و authorization نقش‌های مختلفی را ایفا می‌کنند:

بنابراین، authentication بررسی می‌کند که ما چه کسی هستیم، و authorization تعیین می‌کند که چه کاری می‌توانیم انجام دهیم یا به چه بخش‌هایی دسترسی داشته باشیم.

ساخت مسیر login

برای این کار یک مسیر جدید در برنامه خود به نام /loginایجاد می‌کنیم و کد زیر را در آن می‌نویسیم:

import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
 
export default function LoginPage() {
  return (
    <main className="flex items-center justify-center md:h-screen">
      <div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
        <div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
          <div className="w-32 text-white md:w-36">
            <AcmeLogo />
          </div>
        </div>
        <LoginForm />
      </div>
    </main>
  );
}

NextAuth.js

ما در این مقاله برای افزودن مبحث احراز هویت به برنامه Next.js از NextAuth.js استفاده خواهیم کرد. NextAuth.js بسیاری از پیچیدگی‌های مربوط به مدیریت sessionها، sign-in و sign-out و سایر جنبه‌های احراز هویت را از بین می‌برد. ما می‌توانیم این ویژگی‌ها را به صورت دستی هم پیاده‌سازی کنیم، اما این فرآیند می‌تواند زمان‌بر و مستعد خطا باشد. بنابراین NextAuth.js این فرآیند را ساده‌تر کرده و راه حلی یکپارچه برای احراز هویت در برنامه‌های Next.js ارائه می‌دهد.

راه اندازی NextAuth.js

NextAuth.js را با اجرای دستور زیر در ترمینال خود نصب می‌کنیم:

npm install next-auth@beta

در اینجا، ما نسخه بتا NextAuth.js را نصب می‌کنیم که با Next.js 14 سازگار است.

در مرحله بعد، یک secret key برای برنامه خود می‌سازیم. این کلید برای رمزگذاری کوکی‌ها استفاده می‌شود و امنیت sessionهای کاربر را تضمین می‌کند. برای انجام این کار می‌توانیم دستور زیر را در ترمینال اجرا کنیم:

openssl rand -base64 32

سپس در فایل .envخود، کلید تولید شده را به متغیر AUTH_SECRETاضافه می‌نماییم:

AUTH_SECRET=your-secret-key

برای اینکه auth در پروژه نهایی که می‌سازیم کار کند، باید متغیرهای محیطی خود را در پروژه Vercel خود نیز به روز رسانی کنیم. مطالعه این راهنما در مورد نحوه اضافه کردن متغیرهای محیطی در Vercel می‌تواند مفید باشد.

افزودن گزینه pages

یک فایل auth.config.tsدر root پروژه خود ایجاد می‌کنیم که یک آبجکت authConfigرا export می‌کند. این آبجکت شامل گزینه‌های پیکربندی NextAuth.js خواهد بود. در حال حاضر، فقط گزینه pages را داریم:

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
};

می توانیم از گزینه pagesبرای تعیین مسیر سفارشی sign-in، sign-out و صفحات خطا استفاده نماییم. این مورد ضروری نیست، اما با افزودن گزینه signIn: '/login'به pages، کاربر به جای صفحه پیش‌فرض NextAuth.js به صفحه لاگین سفارشی مدنظر ما هست هدایت می‌شود.

محافظت از مسیرها با استفاده از Next.js Middleware

در مرحله بعد، منطق محافظت از مسیرهای خود را اضافه می‌کنیم. این کار باعث می‌شود تا کاربران به صفحات داشبورد دسترسی نداشته باشند مگر اینکه وارد سیستم شوند.

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    signIn: '/login',
  },
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
      if (isOnDashboard) {
        if (isLoggedIn) return true;
        return false; // Redirect unauthenticated users to login page
      } else if (isLoggedIn) {
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

callback authorizedمورد استفاده قرار می‌گیرد تا اینکه بررسی کند که آیا درخواستی که برای دسترسی به صفحه از طریق Next.js Middleware اعلام شده است مجاز است یا خیر. این تابع قبل از تکمیل درخواست فراخوانی می‌شود، و یک آبجکت با ویژگی‌های authو requestدریافت می‌کند. ویژگی auth شامل session کاربر و ویژگی request شامل درخواست ورودی است.

گزینه providersآرایه‌ای است که در آن گزینه‌های مختلف برای لاگین را لیست می‌کنیم. در حال حاضر، این یک آرایه خالی است تا پیکربندی NextAuth را تکمیل کند. مطالعه Adding the Credentials provider می‌تواند مفید باشد.

پس از آن، باید آبجکت authConfig را در یک فایل Middleware وارد کنیم. در root پروژه خود یک فایل به نام Middleware.tsایجاد کرده و کد زیر را در آن می‌نویسیم:

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export default NextAuth(authConfig).auth;
 
export const config = {
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

در اینجا ما NextAuth.js را با آبجکت authConfigمقداردهی اولیه کرده و ویژگی authرا export می‌کنیم. همچنین برای این که مشخص کنیم که باید در مسیرهای خاصی اجرا شود، از گزینه matcherاز Middleware استفاده می‌کنیم.

مزیت استفاده از Middleware برای این کار این است که تا زمانی که Middleware احراز هویت را تأیید نکند، مسیرهای محافظت شده حتی شروع به رندر نمی‌کنند. در نتیجه امنیت و عملکرد برنامه ما افزایش پیدا می‌کند.

Hash کردن رمز عبور

hash کردن رمزهای عبور قبل از ذخیره آن‌ها در پایگاه داده، کار بسیار مفیدی است. hash کردن، رمز عبور را به یک رشته کاراکتر با طول ثابت تبدیل می‌کند که به‌صورت تصادفی ظاهر می‌شود و حتی اگر داده‌های کاربر در معرض دید دیگران قرار گیرد، لایه‌ای از امنیت را فراهم می‌کند.

برای این کار ما باید یک فایل جداگانه برای پکیج bcryptایجاد کنیم. دلیل این کار این است که bcrypt به APIهای Node.js متکی است که در Next.js Middleware موجود نیستند.

یک فایل جدید به نام auth.tsایجاد می‌کنیم که آبجکت authConfigرا گسترش می‌دهد:

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

افزودن Credentials provider

در مرحله بعد، باید گزینه providersرا برای NextAuth.js اضافه نماییم. providers آرایه‌ای است که در آن گزینه‌های مختلف لاگین مانند Google یا GitHub را لیست می‌کنیم. برای این مقاله، ما فقط بر استفاده از Credentials provider تمرکز خواهیم کرد.

Credentials provider به کاربران اجازه می‌دهد تا با نام کاربری و رمز عبور وارد شوند.

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [Credentials({})],
});

بهتر است به این موضوع توجه داشته باشیم که، اگرچه ما از Credentials provider استفاده می‌کنیم، اما به طور کلی توصیه می‌شود از providerهای جایگزین مانند OAuth یا providerهای email استفاده نماییم. برای داشتن لیست کامل گزینه‌ها، مطالعه مستندات NextAuth.js می‌تواند مفید باشد.

افزودن قابلیت sign in

می‌توانیم از تابع autorizeبرای مدیریت منطق احراز هویت استفاده کنیم. مشابه Server Actionها، می‌توانیم از zodبرای اعتبارسنجی ایمیل و رمز عبور، قبل از بررسی وجود کاربر در پایگاه داده استفاده نماییم:

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
      },
    }),
  ],
});

پس از تأیید اعتبار، یک تابع getUserجدید ایجاد می‌کنیم که کاربر را از پایگاه داده درخواست می‌کند.

import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
 
async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * FROM users WHERE email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
 
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
        }
 
        return null;
      },
    }),
  ],
});

سپس، bcrypt.compareرا فراخوانی می‌کنیم تا بررسی کنیم که آیا رمزهای عبور باهم مطابقت دارند یا نه:

import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { authConfig } from './auth.config';
import { sql } from '@vercel/postgres';
import { z } from 'zod';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
 
// ...
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        // ...
 
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
          const passwordsMatch = await bcrypt.compare(password, user.password);
 
          if (passwordsMatch) return user;
        }
 
        console.log('Invalid credentials');
        return null;
      },
    }),
  ],
});

در نهایت، اگر رمزهای عبور مطابقت داشته باشند می‌خواهیم تا کاربر را return کنیم، در غیر این صورت، برای جلوگیری از ورود کاربر، nullرا return می‌کنیم.

به روز رسانی فرم لاگین

اکنون باید منطق تأیید اعتبار را به فرم لاگین خود متصل کنیم. در فایل actions.tsخود یک action جدید به نام authenticateایجاد می‌کنیم. این action باید تابع signInرا از auth.tsimport کند:

import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
 
// ...
 
export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error;
  }
}

اگر خطای 'CredentialsSignin'وجود داشته باشد، می‌خواهیم یک پیام خطای مناسب نشان دهیم. برای آشنایی بیشتر در مورد خطاهای NextAuth.js مطالعه مستندات آن می‌تواند مفید باشد.

در نهایت، در کامپوننت login-form.tsx، می‌توانیم از useFormStateReact برای فراخوانی server action و رسیدگی به خطاهای فرم، و از useFormStatusبرای رسیدگی به pending state فرم استفاده نماییم:

'use client';
 
import { lusitana } from '@/app/ui/fonts';
import {
  AtSymbolIcon,
  KeyIcon,
  ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
import { ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@/app/ui/button';
import { useFormState, useFormStatus } from 'react-dom';
import { authenticate } from '@/app/lib/actions';
 
export default function LoginForm() {
  const [errorMessage, dispatch] = useFormState(authenticate, undefined);
 
  return (
    <form action={dispatch} className="space-y-3">
      <div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
        <h1 className={`${lusitana.className} mb-3 text-2xl`}>
          Please log in to continue.
        </h1>
        <div className="w-full">
          <div>
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="email"
            >
              Email
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="email"
                type="email"
                name="email"
                placeholder="Enter your email address"
                required
              />
              <AtSymbolIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <div className="mt-4">
            <label
              className="mb-3 mt-5 block text-xs font-medium text-gray-900"
              htmlFor="password"
            >
              Password
            </label>
            <div className="relative">
              <input
                className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
                id="password"
                type="password"
                name="password"
                placeholder="Enter password"
                required
                minLength={6}
              />
              <KeyIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
        </div>
        <LoginButton />
        <div
          className="flex h-8 items-end space-x-1"
          aria-live="polite"
          aria-atomic="true"
        >
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}
        </div>
      </div>
    </form>
  );
}
 
function LoginButton() {
  const { pending } = useFormStatus();
 
  return (
    <Button className="mt-4 w-full" aria-disabled={pending}>
      Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
    </Button>
  );
}

افزودن قابلیت logout

برای افزودن logout به <SideNav />، تابع signOutرا از auth.tsدر المنت <form>خود فراخوانی می‌کنیم:

import Link from 'next/link';
import NavLinks from '@/app/ui/dashboard/nav-links';
import AcmeLogo from '@/app/ui/acme-logo';
import { PowerIcon } from '@heroicons/react/24/outline';
import { signOut } from '@/auth';
 
export default function SideNav() {
  return (
    <div className="flex h-full flex-col px-3 py-4 md:px-2">
      // ...
      <div className="flex grow flex-row justify-between space-x-2 md:flex-col md:space-x-0 md:space-y-2">
        <NavLinks />
        <div className="hidden h-auto w-full grow rounded-md bg-gray-50 md:block"></div>
        <form
          action={async () => {
            'use server';
            await signOut();
          }}
        >
          <button className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3">
            <PowerIcon className="w-6" />
            <div className="hidden md:block">Sign Out</div>
          </button>
        </form>
      </div>
    </div>
  );
}

جمع‌بندی

در این مقاله سعی کردیم تا با استفاده از NextAuth.js، مفهوم احراز هویت را به برنامه Next.js اضافه کنیم. در نهایت باید بتوانیم با استفاده از اطلاعات زیر وارد برنامه خود شده و از آن خارج شویم:

ایمیل: user@nextmail.com

رمز عبور: ۱۲۳۴۵۶