【NEXT JS 之旅】next-auth 助力实现最小登录方案

大多数项目都需要实现账号登录功能。这里介绍一个最小实现方案,以帮助你理解完整的登录流程。

需求分析

  • 可注册: 少数项目不需要注册,只需要通过 Excel 、后台导入等方式为系统导入账号即可。但我们这里希望实现注册功能。
  • 登录校验: 基本功能。
  • 退出登录: 基本功能。
  • 密码加密: 密码在存储前必须加密,以防数据库泄漏。
  • UI 好看: 使用 UI 库实现基本 UI。

技术环境

  • 开发环境

    • 操作系统 - CentOS stream 9
    • NODE 版本 - v18.20.4
    • NPM 版本 - v10.7.0
    • 编辑器 - VS Code
    • 数据库 - MySQL v8.0.41
  • 技术栈:

    • 框架骨架 - next
    • 登录功能 - next-auth
    • 动态加密 - bcrypt
    • 数据库 ORM - prisma
    • 美观 UI - antd

搭建框架

  • 首先使用 NextJs 提供的命令行工具实现基本骨架

    bash 复制代码
    npx create-next-app@latest
  • 等项目自动创建以及自动安装依赖后,我们就得到了一个基本的开发骨架

    可以看到 react 的版本是 19,next 的版本是 15,tailwindcss 的版本是 4。

  • 接下来,需要安装其他必要的依赖

    bash 复制代码
    # 安装必要依赖
    npm i --save next-auth@beta bcrypt@5 zod @prisma/client antd @ant-design/icons
    
    # 为 antd 安装支持 react19 的补丁
    npm i --save @ant-design/v5-patch-for-react-19
    
    # 安装开发依赖
    npm i --save-dev @types/bcrypt prisma
  • 在根目录创建一个 .env 配置文件并生成一个 AUTH_SECRET

    bash 复制代码
    # 生成 .env 文件
    touch .env
    
    # 生成 32 个字符的字符串,然后将该字符串 base64 化(最终生成 44 个字符)
    openssl rand -base64 32
    
    # 将上一条命令的结果作为 AUTH_SECRET 的值
    vi .env

    打开 .env 文件后,在末尾空行输入以下内容,其中 IjW...ro= 部分替换为 openssl rand -base64 32 的输出结果。

    env 复制代码
    AUTH_SECRET=IjWmFecxkYQGycsDaUnv66smJPsyHNgJPzpaD6rZsro=
  • 生成数据库

    创建 prisma/schema.prisma 文件

    bash 复制代码
    # 除了 prisma/schema.prisma 文件,还会自动生成 .env 文件并往里面添加 DATABASE_URL 字段
    npx prisma init

    修改 .env 文件的 DATABASE_URL 字段为如下(其中 root 是你的 MySQL 账号,123456 是你的 MySQL 密码,3306 是你的 MySQL 端口,miniauth 是你想要的数据库名称)

    env 复制代码
    DATABASE_URL="mysql://root:123456@localhost:3306/miniauth"

    接着在 prisma/schema.prisma 文件中定义用户模型如下

    prisma 复制代码
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "mysql"
      url      = env("DATABASE_URL")
    }
    
    model User {
      id        Int      @id @default(autoincrement())
      name      String   @unique
      pass      String
    
      @@map("user")
    }

    然后回到命令行中创建数据库

    bash 复制代码
    # 该命令会生成 prisma/migrations 目录,以便于数据库结构快速回滚
    npx prisma migrate dev --name init

    可以看到,我们的 miniauth 数据库已经成功创建

验证配置

  • 在项目根目录中创建一份 auth.config.ts 文件,并输入以下内容

    ts 复制代码
    import { NextAuthConfig } from "next-auth";
    
    // 验证白名单页面
    const WHITELIST_PAGES = [
      '/register',
    ]
    // 登录页面
    const LOGIN_PAGES = [
      '/login',
    ]
    
    export const authConfig = {
      pages: {
        // 验证失败就会跳到该页面
        signIn: '/login',
      },
      callbacks: {
        /**
         * 每次页面路由进行切换时被调用
         */
        authorized({ auth, request }) {
          const { nextUrl } = request
          const isLoggedIn = !!auth?.user;
          const isWhitelistPage = WHITELIST_PAGES.some(prefix => {
            return nextUrl.pathname.startsWith(prefix)
          })
          const isLoginPage = !isWhitelistPage && LOGIN_PAGES.some(prefix => {
            return nextUrl.pathname.startsWith(prefix)
          })
    
          if (isLoginPage && isLoggedIn) {
            // 在登录页且已登录,重定向到首页
            return Response.redirect(new URL(nextUrl.searchParams?.get('callbackUrl') || (nextUrl.origin + '/')))
          }
          if (isLoginPage || isWhitelistPage) {
            // 这种情况不需要跳转
            return true
          }
          // 返回 false 表示重定向到登录页(signIn),返回 true 则表示通过验证
          return isLoggedIn
        },
      },
      providers: [
      ],
    } as NextAuthConfig
  • 然后继续在根目录下创建一个 auth.ts 文件,用于验证用户的登录

    ts 复制代码
    import NextAuth from "next-auth";
    import { authConfig } from "./auth.config";
    import Credentials from "next-auth/providers/credentials";
    import { z } from "zod";
    import { PrismaClient } from "@prisma/client";
    import bcrypt from "bcrypt";
    import { User as AuthUser } from "next-auth";
    
    // 实际开发中,应当把 prisma 作为一个公共模块引入
    const prisma = new PrismaClient();
    
    export const { handlers: { GET, POST } } = NextAuth({
      ...authConfig,
      providers: [
        ...authConfig.providers,
        Credentials({
          async authorize(credentials) {
            // 验证用户凭证
            const parsedCredentials =  z.object({
              name: z.string().min(3).max(20),
              pass: z.string().min(6).max(50),
            }).safeParse(credentials)
    
            // 如果验证成功,则从数据库中获得相应的用户信息
            if (parsedCredentials.success) {
              const { name, pass } = parsedCredentials.data
              const user = await prisma.user.findFirst({
                where: { name },
              });
    
              if (!user) return null
    
              // 进行密码匹配,如果匹配成功,返回用户信息
              const passMatched = await bcrypt.compare(pass, user.pass)
    
              if (!passMatched) return null
    
              return {
                id: '' + user.id,
                name: user.name,
              } as AuthUser
            }
    
            return null
          }
        })
      ],
    })
  • 有了 auth.ts ,我们就可以创建一个通用的路由端点:app/api/auth/[...nextauth]/route.ts

    ts 复制代码
    /**
     * next-auth 会根据本路由自动生成以下具体路由(无需手动创建):
     * 
     * /api/auth/signin - 处理用户登录(支持所有配置的 Provider,如 Credentials、Google 等)
     * /api/auth/signout - 处理用户注销
     * /api/auth/callback - 处理 OAuth 提供者的回调(如 Google 登录后的跳转)
     * /api/auth/session - 获取当前用户的会话信息
     * /api/auth/csrf - 获取 CSRF 令牌(用于安全验证)
     * /api/auth/providers - 列出所有配置的身份验证提供者(如 Google、GitHub)
     * ...
     * 
     */
    
    export { GET, POST } from "../../../../auth"
  • 我们编写 auth.config.ts ,是为了拦截必要的请求。我们使用中间件实现:同样在根目录中,创建 middleware.ts

    ts 复制代码
    import NextAuth from "next-auth";
    import { authConfig } from "./auth.config";
    
    /**
     * auth 方法内部会调用 authConfig 中的 authorized 方法
     */
    export default NextAuth(authConfig).auth
    
    export const config = {
      // 请求路径不以 /api、/_next/static、/_next/image、/favicon. 开头的,不以 .png 结尾的
      // 则运行默认输出的中间件函数
      matcher: ['/((?!api|_next/static|_next/image|favicon\\.|.*\\.png$).*)']
    }
  • 到此,我们实现了验证的逻辑。

    此时如果你运行 npm run dev,会发现页面跳转到了 /login 页(尚未编写该页面)。

登录登出

  • 首先创建一个注册接口 app/api/register/route.ts

    ts 复制代码
    import { NextResponse } from 'next/server';
    import { PrismaClient } from "@prisma/client";
    import bcrypt from "bcrypt";
    
    // 在实际开发中,prisma 应当以引入的方式全局统一使用
    const prisma = new PrismaClient();
    
    export async function POST(request: Request) {
      const body = await request.json();
    
      if (!body.name || !body.pass) {
        return NextResponse.json(
          { message: '缺少必要参数' },
          { status: 400 }
        );
      }
    
      // 检查用户是否已存在
      const existingUser = await prisma.user.findUnique({
        where: { name: body.name },
        select: { name: true },
      });
    
      // 用户名已存在的情况
      if (existingUser) {
        return NextResponse.json(
          { message: '用户名已存在' },
          { status: 400 }
        );
      }
    
      const hashedPassword = bcrypt.hashSync(body.pass, 10);
      // 创建用户
      const user = await prisma.user.create({
        data: {
          name: body.name,
          pass: hashedPassword,
        },
        select: {
          id: true,
          name: true,
        }
      });
    
      // 成功创建了用户
      return NextResponse.json(
        { message: '注册成功', user },
        { status: 200 }
      );
    }
  • 为注册接口编写专门的注册页面 app/(pages)/register/page.tsx 。这里我们需要掌握 ant-design 的表单用法

    tsx 复制代码
    "use client";
    
    import { LockOutlined, UserOutlined } from "@ant-design/icons";
    import { Button, Card, Form, Input, ConfigProvider, theme, Typography, message } from "antd";
    import { useRouter } from "next/navigation";
    import React from "react";
    
    const { Title } = Typography;
    
    interface RegisterFormValues {
      name: string;
      pass: string;
      confirmPass: string;
    }
    
    const RegisterPage: React.FC = () => {
      const router = useRouter();
      const [form] = Form.useForm();
      const [loading, setLoading] = React.useState(false);
    
      const onFinish = async (values: RegisterFormValues) => {
        if (values.pass !== values.confirmPass) {
          message.error("两次输入的密码不一致!");
          return;
        }
    
        setLoading(true);
    
        try {
          // 使用 fetch 与后端交互
          const response = await fetch('/api/register', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              name: values.name,
              pass: values.pass,
            }),
          });
    
          const data = await response.json();
    
          if (response.ok) {
            message.success("注册成功!");
            router.push("/login");
          } else {
            message.error(data.message || "注册失败");
          }
        } catch (error) {
          console.error("注册错误:", error);
          message.error("注册过程中发生错误");
        } finally {
          setLoading(false);
        }
      };
    
      return (
        <ConfigProvider
          theme={{
            algorithm: theme.defaultAlgorithm,
            token: {
              colorPrimary: "#1890ff", // 浅蓝色主题
              borderRadius: 6,
            },
          }}
        >
          <div
            style={{
              display: "flex",
              justifyContent: "center",
              alignItems: "center",
              minHeight: "100vh",
              background: "#f0f2f5",
              padding: "16px",
            }}
          >
            <Card
              style={{
                width: "100%",
                maxWidth: "440px",
                boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
              }}
            >
              <div style={{ textAlign: "center", marginBottom: "24px" }}>
                <Title level={3}>用户注册</Title>
              </div>
              <Form
                form={form}
                name="register"
                initialValues={{ remember: true }}
                onFinish={onFinish}
                autoComplete="off"
                layout="vertical"
              >
                <Form.Item
                  name="name"
                  rules={[
                    { required: true, message: "请输入用户名!" },
                    { min: 4, message: "用户名至少4个字符" },
                    { max: 16, message: "用户名最多16个字符" }
                  ]}
                >
                  <Input
                    prefix={<UserOutlined />}
                    placeholder="用户名"
                    size="large"
                  />
                </Form.Item>
    
                <Form.Item
                  name="pass"
                  rules={[
                    { required: true, message: "请输入密码!" },
                    { min: 6, message: "密码至少6个字符" }
                  ]}
                  hasFeedback
                >
                  <Input.Password
                    prefix={<LockOutlined />}
                    placeholder="密码"
                    size="large"
                  />
                </Form.Item>
    
                <Form.Item
                  name="confirmPass"
                  dependencies={['pass']}
                  hasFeedback
                  rules={[
                    { required: true, message: "请确认密码!" },
                    ({ getFieldValue }) => ({
                      validator(_, value) {
                        if (!value || getFieldValue('pass') === value) {
                          return Promise.resolve();
                        }
                        return Promise.reject(new Error('两次输入的密码不一致!'));
                      },
                    }),
                  ]}
                >
                  <Input.Password
                    prefix={<LockOutlined />}
                    placeholder="确认密码"
                    size="large"
                  />
                </Form.Item>
    
                <Form.Item>
                  <Button
                    type="primary"
                    htmlType="submit"
                    block
                    size="large"
                    loading={loading}
                  >
                    注册
                  </Button>
                </Form.Item>
    
                <div style={{ textAlign: "center" }}>
                  <Button type="link" onClick={() => router.push("/login")}>
                    已有账号?去登录
                  </Button>
                </div>
              </Form>
            </Card>
          </div>
        </ConfigProvider>
      );
    };
    
    export default RegisterPage;
  • 有了注册页面,我们就可以接着编写登录页面

    我们直接使用 next-auth 提供的 signIn 函数实现登录。对于登录出现的错误,我们可以创建一个 app/lib/error-msg.ts 来统一处理

    ts 复制代码
    /**
     * 所有可能出现的错误
     */
    const ERROR_MAP = {
      "CredentialsSignin": "用户名或密码错误",
      "MISSING_CREDENTIALS": "请输入邮箱和密码",
      "USER_NOT_FOUND": "用户不存在",
      "INVALID_PASSWORD": "密码错误",
      "UNKNOWN_ERROR": "发生未知错误",
    }
    
    /**
     * 获取错误的友好文本
     * 
     * @param key - 错误键
     * @param defaultMsg - 默认文本
     * 
     * @returns {string}
     */
    export function getErrorMessage(key: string, defaultMsg?: string) {
    
      return ERROR_MAP[key as keyof typeof ERROR_MAP] || defaultMsg || ERROR_MAP['UNKNOWN_ERROR'];
    }

    然后我们直接调用 signIn 方法实现 app/(pages)/login/page.tsx 登录页

    tsx 复制代码
    "use client";
    
    import '@ant-design/v5-patch-for-react-19';
    import { ExclamationCircleFilled, LockOutlined, UserOutlined } from "@ant-design/icons";
    import { Button, Card, Form, Input, ConfigProvider, theme, Typography, Spin } from "antd";
    import { useRouter } from "next/navigation";
    import { FC, useState } from "react";
    import { signIn } from "next-auth/react";
    import { getErrorMessage } from '@/app/lib/error-msg';
    
    interface LoginFormValues {
      name: string;
      pass: string;
    }
    
    const { Title } = Typography;
    
    const LoginPage: FC = () => {
      const router = useRouter();
      const [form] = Form.useForm();
      const [loading, setLoading] = useState(false);
      const [errorMsg, setErrorMsg] = useState('');
    
      const onFinish = async (values: LoginFormValues) => {
        setLoading(true);
        try {
          const result = await signIn('credentials', {
            name: values.name,
            pass: values.pass,
            redirect: false,    // 为了处理登录错误,我们禁止了 signIn 自动跳转
          });
          if (result?.error) {
            setErrorMsg(getErrorMessage(result.error));
          } else {
            window.location.href = "/";
          }
        } catch (error) {
          console.error("登录错误:", error);
          setErrorMsg("登录过程中发生错误");
        } finally {
          setLoading(false);
        }
      };
    
      return (
        <ConfigProvider
          theme={{
            algorithm: theme.defaultAlgorithm,
            token: {
              colorPrimary: "#1890ff", // 浅蓝色主题
              borderRadius: 6,
            },
          }}
        >
          <div
            style={{
              display: "flex",
              justifyContent: "center",
              alignItems: "center",
              minHeight: "100vh",
              background: "#f0f2f5",
              padding: "16px",
            }}
          >
            <Spin spinning={loading} tip="登录中...">
              <Card
                style={{
                  width: "100%",
                  maxWidth: "440px",
                  boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)",
                }}
              >
                <div style={{ textAlign: "center", marginBottom: "24px" }}>
                  <Title level={3}>系统登录</Title>
                </div>
                <Form
                  form={form}
                  name="login"
                  initialValues={{ remember: true }}
                  onFinish={onFinish}
                  autoComplete="off"
                  layout="vertical"
                >
                  <Form.Item
                    name="name"
                    rules={[
                      { required: true, message: "请输入用户名!" },
                      { min: 4, message: "用户名至少4个字符" }
                    ]}
                  >
                    <Input
                      prefix={<UserOutlined />}
                      placeholder="用户名"
                      size="large"
                    />
                  </Form.Item>
    
                  <Form.Item
                    name="pass"
                    rules={[
                      { required: true, message: "请输入密码!" },
                      { min: 6, message: "密码至少6个字符" }
                    ]}
                  >
                    <Input.Password
                      prefix={<LockOutlined />}
                      placeholder="密码"
                      size="large"
                    />
                  </Form.Item>
    
                  <Form.Item>
                    <Button
                      type="primary"
                      htmlType="submit"
                      block
                      size="large"
                      loading={loading}
                    >
                      登录
                    </Button>
                  </Form.Item>
    
                  <div className="flex h-8 items-end space-x-1" aria-live="polite" aria-atomic="true">
                    {errorMsg && (
                      <>
                        <ExclamationCircleFilled className="h-5 w-5 text-red-500" />
                        <p className="text-sm text-red-500">{errorMsg}</p>
                      </>
                    )}
                  </div>
    
                  <div style={{ textAlign: "center" }}>
                    <Button type="link" onClick={() => router.push("/register")}>
                      没有账号?立即注册
                    </Button>
                  </div>
                </Form>
              </Card>
            </Spin>
          </div>
        </ConfigProvider>
      );
    };
    
    export default LoginPage;
  • 有了注册与登录,我们就可以实现登出功能。在首页 app/page.tsx 中实现即可

    登出,我们同样是直接调用 next-auth 提供的 signOut 函数。在调用 signOut 函数后,页面会处于轮询状态,我们需要调用 window.location.reload() 来触发本地状态更新并实现跳转。

    tsx 复制代码
    'use client'
    
    import Image from "next/image";
    import { signOut, useSession } from "next-auth/react";
    
    export default function Home() {
      const { data: session, status } = useSession();
    
      if (status === "loading") {
        return <div>Loading...</div>;
      }
    
      if (status === "unauthenticated") {
        return <div>请登录</div>;
      }
    
      return (
        <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
          <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
            <Image
              className="dark:invert"
              src="/next.svg"
              alt="Next.js logo"
              width={180}
              height={38}
              priority
            />
          </main>
          <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
            <a
              className="flex items-center gap-2 hover:underline hover:underline-offset-4"
              target="_blank"
              rel="noopener noreferrer"
            >
              {session?.user?.name}
            </a>
            <button
              className="flex items-center gap-2 hover:underline hover:underline-offset-4"
              onClick={async () => {
                await signOut({ callbackUrl: "/login" });
                window.location.reload();
              }}
            >
              退出登录 →
            </button>
          </footer>
        </div>
      );
    }
  • 我们在首页使用了 useSession 方法,这会导致运行时错误。我们需要创建一个 app/providers/ClientProviders.tsx 模块来提供 SessionProvider 节点

    tsx 复制代码
    "use client";
    
    import { SessionProvider } from "next-auth/react";
    
    /**
     * 后面我们会把所有必要的客户端上用到的 Provider 都将放置于本模块中
     */
    export function ClientProviders({ children }: { children: React.ReactNode }) {
      return <SessionProvider>{children}</SessionProvider>;
    }
  • 然后在 app/layout.tsx 中引入 ClientProviders.tsx

    tsx 复制代码
    import '@ant-design/v5-patch-for-react-19';
    import type { Metadata } from "next";
    import { ClientProviders } from './providers/ClientProviders';
    import "./globals.css";
    
    export const metadata: Metadata = {
      title: "Create Next App",
      description: "Generated by create next app",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="en">
          <body
            className={`antialiased`}
          >
            <ClientProviders>{children}</ClientProviders>
          </body>
        </html>
      );
    }

    到此,我们实现整个 注册 -> 登录 -> 登出 的闭环。

小结

账号系统一个 验证 + 授权 的集合体,是帮助我们实现权限管理的第一步。有了注册、登录、登出的完整体验,相信你对接下来的各种权限管理有了更清晰的思路。希望本文对你有所帮助。

相关推荐
我这一生如履薄冰~13 分钟前
css属性pointer-events: none
前端·css
brzhang19 分钟前
A2UI:但 Google 把它写成协议后,模型和交互的最后一公里被彻底补全
前端·后端·架构
coderHing[专注前端]28 分钟前
告别 try/catch 地狱:用三元组重新定义 JavaScript 错误处理
开发语言·前端·javascript·react.js·前端框架·ecmascript
开心猴爷42 分钟前
iOS App 性能测试中常被忽略的运行期问题
后端
UIUV44 分钟前
JavaScript中this指向机制与异步回调解决方案详解
前端·javascript·代码规范
momo10044 分钟前
IndexedDB 实战:封装一个通用工具类,搞定所有本地存储需求
前端·javascript
liuniansilence44 分钟前
🚀 高并发场景下的救星:BullMQ如何实现智能流量削峰填谷
前端·分布式·消息队列
再花44 分钟前
在Angular中实现基于nz-calendar的日历甘特图
前端·angular.js
GISer_Jing1 小时前
今天看了京东零售JDS的保温直播,秋招,好像真的结束了,接下来就是论文+工作了!!!加油干论文,学&分享技术
前端·零售
SHERlocked931 小时前
摄像头 RTSP 流视频多路实时监控解决方案实践
c++·后端·音视频开发