大多数项目都需要实现账号登录功能。这里介绍一个最小实现方案,以帮助你理解完整的登录流程。
需求分析
- 可注册: 少数项目不需要注册,只需要通过 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 提供的命令行工具实现基本骨架
bashnpx 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的输出结果。envAUTH_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 是你想要的数据库名称)
envDATABASE_URL="mysql://root:123456@localhost:3306/miniauth"接着在 prisma/schema.prisma 文件中定义用户模型如下
prismagenerator 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 文件,并输入以下内容
tsimport { 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 文件,用于验证用户的登录
tsimport 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
tsimport 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
tsimport { 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
tsximport '@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> ); }到此,我们实现整个 注册 -> 登录 -> 登出 的闭环。
小结
账号系统一个 验证 + 授权 的集合体,是帮助我们实现权限管理的第一步。有了注册、登录、登出的完整体验,相信你对接下来的各种权限管理有了更清晰的思路。希望本文对你有所帮助。