nextjs 实现帐号用户名登录

143 min read

在本章中,我们将涵盖以下主题:

  • 什么是身份验证。
  • 如何使用 NextAuth.js 向应用程序添加身份验证。
  • 如何使用中间件重定向用户并保护路由。
  • 如何使用 React 的 useFormStatus 和 useFormState 处理挂起状态和表单错误。

什么是身份验证?

身份验证是现代 Web 应用程序中的关键部分。它是系统检查用户是否是他们所声称的那个人的方法。

一个安全的网站通常使用多种方式来检查用户的身份。例如,在输入用户名和密码后,网站可能会向你的设备发送验证码或使用诸如 Google Authenticator 之类的外部应用程序。这种双因素身份验证(2FA)有助于提高安全性。即使有人知道你的密码,他们也不能在没有你的唯一令牌的情况下访问你的账户。

身份验证与授权

在 Web 开发中,身份验证和授权具有不同的作用:

  • 身份验证是确保用户是他们所声称的那个人。你通过拥有的东西(如用户名和密码)证明你的身份。
  • 授权是下一步。一旦确认了用户的身份,授权决定他们可以使用应用程序的哪些部分。

所以,身份验证检查你是谁,授权决定你在应用程序中可以做什么或访问什么。

创建登录路由

首先在应用程序中创建一个新的路由 /login 并粘贴以下代码:

// /app/login/page.tsx

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>
  );
}

你会注意到页面导入了 <LoginForm />,你将在本章稍后更新它。

设置 NextAuth.js

我们将使用 NextAuth.js 向应用程序添加身份验证。NextAuth.js 抽象了管理会话、登录和登出等方面的复杂性。尽管你可以手动实现这些功能,但过程可能耗时且容易出错。NextAuth.js 简化了这一过程,为 Next.js 应用程序提供了统一的身份验证解决方案。

安装 NextAuth.js:

pnpm i next-auth@beta

生成一个应用程序的密钥,用于加密 cookies,以确保用户会话的安全:

openssl rand -base64 32

在你的 .env 文件中,添加生成的密钥:

.env
AUTH_SECRET=your-secret-key

添加页面选项

在项目的根目录下创建一个 auth.config.ts 文件,导出一个 authConfig 对象。该对象将包含 NextAuth.js 的配置选项:

// /auth.config.ts

import type { NextAuthConfig } from 'next-auth';

export const authConfig = {
  pages: {
    signIn: '/login',
  },
};

使用中间件保护路由

接下来,添加逻辑以保护你的路由。这将防止用户在未登录时访问仪表板页面。

// /auth.config.ts

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;

接下来,在项目的根目录下创建一个 middleware.ts 文件并粘贴以下代码:

// /middleware.ts

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';

export default NextAuth(authConfig).auth;

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

添加凭证提供者

添加 Credentials 提供者允许用户使用用户名和密码登录:

// /auth.ts

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({})],
});

更新登录表单

连接认证逻辑与登录表单:

// app/ui/login-form.tsx

'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>
  );
}

添加注销功能

<SideNav /> 组件中调用 signOut 函数以添加注销功能:

// /ui/dashboard/sidenav.tsx

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>
  );
}

试试吧

现在,你应该可以使用以下凭据登录和注销应用程序: