Next.js 15 认证方案:NextAuth v4 配合 Drizzle ORM 的落地实践
本文将带你从零开始,使用 Next.js 15、NextAuth v4、Drizzle ORM 和 PostgreSQL 构建一个功能完备的用户认证系统,包括注册、登录、会话管理和用户信息显示。
一、项目初始化与技术栈安装
首先,创建一个新的 Next.js 15 项目并安装必要的依赖。
创建项目
npx create-next-app@latest my-auth-app
cd my-auth-app
# 选择 TypeScript, Tailwind CSS, App Router 等选项
安装核心依赖
# 安装 NextAuth, Drizzle, Postgres.js, bcryptjs
npm install next-auth@4 drizzle-orm postgres bcryptjs
npm install -D @types/bcryptjs drizzle-kit
二、数据库设计与Drizzle配置
1. 定义数据库Schema
在src/db/schema.ts中定义用户表。确保包含email和password字段。
// src/db/schema.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
password: text('password').notNull(), // 用于存储加密后的密码
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
2. 配置数据库连接单例
在src/db/index.ts中创建数据库连接。这能避免在 Next.js 开发环境中因热更新导致的连接数耗尽问题。
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
3. 初始化数据库
配置好.env文件中的DATABASE_URL后,使用 Drizzle Kit 生成并执行迁移,在 Postgres 中创建users表。
npx drizzle-kit generate
npx drizzle-kit migrate
三、实现注册功能
NextAuth 只负责认证(登录),不负责注册。我们需要自己创建一个 API 路由来处理用户注册和密码加密。
创建注册API路由
在src/app/api/auth/register/route.ts中:
// src/app/api/auth/register/route.ts
import { NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
export async function POST(request: Request) {
try {
const { name, email, password } = await request.json();
// 1. 基础验证
if (!name || !email || !password) {
return NextResponse.json({ error: '所有字段均为必填' }, { status: 400 });
}
// 2. 检查用户是否已存在
const existingUser = await db.select().from(users).where(eq(users.email, email));
if (existingUser.length > 0) {
return NextResponse.json({ error: '邮箱已被注册' }, { status: 400 });
}
// 3. 加密密码
const hashedPassword = await bcrypt.hash(password, 10);
// 4. 将用户信息存入数据库
await db.insert(users).values({
name,
email,
password: hashedPassword,
});
return NextResponse.json({ message: '注册成功' }, { status: 201 });
} catch (error) {
console.error('注册错误:', error);
return NextResponse.json({ error: '服务器内部错误' }, { status: 500 });
}
}
四、配置NextAuth进行登录认证
1. 配置NextAuth选项
在src/lib/auth.ts中配置 NextAuth,使用CredentialsProvider和Drizzle来验证用户。
// src/lib/auth.ts
import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
// 1. 从数据库查询用户
const user = await db.select().from(users).where(eq(users.email, credentials.email));
if (user.length === 0) {
return null; // 用户不存在
}
const dbUser = user[0];
// 2. 验证密码
const isPasswordValid = await bcrypt.compare(
credentials.password,
dbUser.password
);
if (!isPasswordValid) {
return null; // 密码错误
}
// 3. 返回用户信息(不含密码)
return {
id: dbUser.id,
email: dbUser.email,
name: dbUser.name,
};
},
}),
],
session: {
strategy: 'jwt', // 使用 JWT 策略
},
pages: {
signIn: '/login', // 指定登录页面路径
},
callbacks: {
async jwt({ token, user }) {
// 将用户ID存入JWT
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
// 将JWT中的ID放入session
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
};
2. 创建NextAuth API路由
在src/app/api/auth/[...nextauth]/route.ts中:
// src/app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
五、构建登录注册页面
1. 创建登录/注册合一页面
在src/app/login/page.tsx中创建一个包含登录和注册表单的页面。
// src/app/login/page.tsx
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
if (isLogin) {
// 登录逻辑
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('邮箱或密码错误');
} else {
router.push('/dashboard'); // 登录成功后跳转
router.refresh();
}
} else {
// 注册逻辑
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error);
} else {
alert('注册成功,请登录!');
setIsLogin(true);
}
}
setLoading(false);
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold">
{isLogin ? '登录' : '注册'}
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<div>
<label className="block text-sm font-medium text-gray-700">姓名</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">邮箱</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? '处理中...' : isLogin ? '登录' : '注册'}
</button>
</form>
<p className="mt-4 text-center text-sm">
{isLogin ? '还没有账号?' : '已有账号?'}
<button
onClick={() => setIsLogin(!isLogin)}
className="ml-1 text-blue-600 hover:underline"
>
{isLogin ? '注册' : '登录'}
</button>
</p>
</div>
</div>
);
}
六、显示用户信息与保护路由
1. 创建SessionProvider
在src/components/Providers.tsx中创建SessionProvider组件,用于包裹整个应用,使useSession钩子可用。
// src/components/Providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
2. 在根布局中包裹Provider
在src/app/layout.tsx中使用Providers组件。
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Providers } from '@/components/Providers';
import './globals.css';
export const metadata: Metadata = {
title: 'NextAuth + Drizzle App',
description: '用户认证系统',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
3. 创建受保护的仪表盘页面
在src/app/dashboard/page.tsx中创建一个页面,只有登录用户才能访问,并显示用户信息。
// src/app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { authOptions } from '@/lib/auth';
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
// 如果用户未登录,重定向到登录页
if (!session) {
redirect('/login');
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-100">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
<h1 className="mb-6 text-center text-2xl font-bold">欢迎回来!</h1>
<div className="space-y-2">
<p><strong>姓名:</strong> {session.user?.name}</p>
<p><strong>邮箱:</strong> {session.user?.email}</p>
<p><strong>用户ID:</strong> {session.user?.id}</p>
</div>
</div>
</div>
);
}
七、环境变量配置
在项目根目录创建.env文件,并添加以下内容:
# 数据库连接字符串
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
# NextAuth 密钥,用于加密 JWT
# 可以通过 `openssl rand -base64 32` 命令生成
NEXTAUTH_SECRET="your-super-secret-key-here"
# NextAuth URL,开发环境通常是 localhost
NEXTAUTH_URL="http://localhost:3000"
八、await signIn
const result = await signIn('credentials', {
email,
password,
redirect: false, // 阻止自动跳转,我们自己控制
});
登录逻辑好像就只有这一段代码,是怎么关联api/auth/[…nextauth]/route.ts中的接口调用的?
这确实是 NextAuth 最“魔法”的地方!明明只写了一行 signIn,它背后却自动帮你跑通了整个流程。
其实,signIn 这个函数内部封装了**“自动寻址”**的逻辑。虽然你只传了 'credentials' 这个字符串,但 NextAuth 早就通过“约定”知道该去哪里找谁了。
我们可以把这个过程拆解为三步“自动导航”:
1. 它是如何找到 /api/auth/... 这个地址的?
当你初始化 NextAuth 时(在 route.ts 里),你实际上是在告诉 NextAuth:“嘿,我的认证服务就部署在 /api/auth/[...nextauth] 这个路径下”。
signIn 函数默认就认准了这个标准路径。
- 当你调用
signIn('credentials', ...)时,它内部会立刻拼接出一个目标 URL:/api/auth/callback/credentials
注意:这里的
callback和credentials不是随便写的,而是 NextAuth 内部规定的标准动作路径。
2. 它是如何知道要调用 route.ts 的?
这就回到了我们刚才讨论的 [...nextauth]。
- 前端发出的请求:
POST /api/auth/callback/credentials - Next.js 的路由匹配:
- 它看到
api/auth/匹配上了。 - 它看到后面跟着
callback/credentials(这是一串路径)。 - 它发现只有
[...nextauth]这个通配符文件夹能吞下这串路径。
- 它看到
- 结果:Next.js 自动把请求转交给
app/api/auth/[...nextauth]/route.ts里的handler处理。
3. 它是如何触发 authorize 函数的?
这是最关键的一步。当请求到达 route.ts 后:
- NextAuth 的
handler接收到请求。 - 它分析 URL 路径,发现是
/callback/credentials。 - 它立刻明白:“哦,这是用户提交账号密码来验证了”。
- 于是,它去你配置的
providers列表里找名为credentials的那个配置。 - 找到后,它自动执行你写在那个配置里的
authorize函数。
🔗 完整的连线图
你可以这样理解它们之间的“隐形连线”:
[前端代码]
↓ 调用 signIn('credentials', ...)
↓
[NextAuth 客户端 SDK]
↓ 自动拼接 URL -> /api/auth/callback/credentials
↓ 自动带上 CSRF Token (刚才悄悄拿到的)
↓ 发起 POST 请求
↓
[Next.js 路由系统]
↓ 发现路径匹配 [...nextauth]
↓ 转发给 app/api/auth/[...nextauth]/route.ts
↓
[NextAuth 服务端 Handler]
↓ 识别路径是 /callback/credentials
↓ 找到 CredentialsProvider 配置
↓ 执行你写的 authorize(credentials) 函数
↓
[数据库]
↓ 验证成功/失败
之所以你觉得“代码很少”,是因为 NextAuth 帮你把“拼接 URL”、“发起请求”、“路由匹配”、“调用函数”这些繁琐的步骤都封装在 signIn 这个黑盒子里了。
你只需要告诉它:“我要用 credentials 方式登录”,它就知道该去敲哪扇门。
九、动态路由
这两个问题问得非常深入!这说明你已经开始透过现象看本质了。能关注到这些细节,说明你离精通 NextAuth 不远了。
我来逐一为你揭开这层神秘的面纱。
1. [...nextauth] 是什么?
这个写法在 Next.js 中叫做 动态路由(Catch-all Segments)。
通俗解释
想象你的 app/api/auth/ 文件夹是一个公司的前台。
- 如果文件夹叫
signin,那只有/api/auth/signin这一个房间。 - 如果文件夹叫
[...nextauth],那它就是一个万能总管。它告诉 Next.js:“嘿,不管谁访问/api/auth/后面的什么路径(不管是signin、callback、session还是signout),统统都交给我这个文件来处理!”
为什么要这么写?
NextAuth 需要处理很多种请求,比如:
POST /api/auth/signin(用户提交登录)GET /api/auth/signout(用户退出)GET /api/auth/session(前端检查用户是否登录)GET /api/auth/providers(获取支持的登录方式列表)
如果不用 [...nextauth],你就得在 app/api/auth/ 下面建无数个文件夹(signin, signout, session…),每个里面放一个 route.ts。那样太乱了!
结论:[...nextauth] 就是一个统一的入口,NextAuth 内部会根据 URL 的路径(比如 /callback/credentials)来自动判断该执行登录逻辑还是登出逻辑。你不需要改这个文件名,它是 NextAuth 的标准写法。
2. 点击登录时,那三个接口是怎么回事?
这三个接口是 NextAuth 客户端(signIn 函数) 自动触发的,目的是为了保证安全和流程正确。
我们可以把这个过程看作是一次**“特工接头”**:
第一阶段:侦察与拿令牌
接口 1:/api/auth/providers
- 什么时候触发:页面加载时,或者调用
signIn之前。 - 作用:前端问后端:“嘿,你支持哪些登录方式?”(GitHub?Google?还是 Credentials?)
- 联系:NextAuth 客户端拿到这个列表,确认“哦,原来有 Credentials 模式”,然后才继续下一步。
接口 2:/api/auth/csrf
- 什么时候触发:在真正提交登录表单之前。
- 作用:这是为了安全(防跨站攻击 CSRF)。
- 浏览器先去拿一个“一次性暗号”(CSRF Token)。
- 这个暗号会存在 Cookie 里,也会返回给前端。
- 联系:如果没有这个暗号,黑客就可以伪造一个假页面,冒充你的用户去提交登录。NextAuth 强制要求:“没有暗号,我不处理登录请求”。
第二阶段:正式提交
接口 3:/api/auth/callback/credentials
- 什么时候触发:当你点击登录按钮,且拿到了 CSRF Token 之后。
- 作用:这是真正的“战场”。
- 前端把
{ email, password, csrfToken }打包发给这个接口。 - 这就是我们在
route.ts里配置的authorize函数被执行的地方。 - 验证通过后,服务器会生成 Session(或者 JWT),并写入 Cookie。
- 前端把
📌 总结一下它们的联系
当你点击“登录”按钮的那一刻,NextAuth 帮你自动执行了以下剧本:
- 先看一眼(providers):确认后端支持 Credentials 登录。
- 拿通行证(csrf):先去
/api/auth/csrf拿一个安全令牌,防止被攻击。 - 正式闯关(callback/credentials):带着你的账号密码 + 安全令牌,去
/api/auth/callback/credentials进行验证。- 如果验证成功 -> 后端返回 Session -> 页面跳转/刷新。
- 如果验证失败 -> 后端返回错误 -> 前端显示“密码错误”。
3. 写成[nextaut]可以吗?
哈哈,千万别! 除非你想给自己挖个坑跳进去。
虽然从 Next.js 的语法层面来说,你把文件夹写成 [nextauth] 确实能跑通(它依然是个动态路由),但如果你这么做,NextAuth 这个库本身会直接罢工。
原因很简单:NextAuth 的代码里写死了路径规则。
🛑 为什么不能改?
NextAuth 的客户端代码(就是你调用的 signIn 函数)内部默认会去请求以下路径:
/api/auth/signin/api/auth/session/api/auth/providers
当你把文件夹命名为 [...nextauth] 时,Next.js 会完美匹配这些路径。
但如果你改成 [nextauth](少那三个点):
- 它变成了一个单层动态路由。
- 它只能匹配
/api/auth/signin这一层。 - 但是,NextAuth 的回调流程(比如
callback/credentials)是两层路径。 - 结果就是:
/api/auth/signin可能还能访问,但涉及到登录跳转、回调验证的时候,路由匹配会直接报错(404 或 500),因为[nextauth]处理不了callback/credentials这种带斜杠的子路径。
💡 简单总结
[...nextauth]:是 Catch-all(通配) 路由。它能吞下/api/auth/后面所有的路径(不管多深)。这是 NextAuth 官方规定的标准写法。[nextauth]:是 普通动态 路由。它只能吞下一级路径。
结论:保留那三个点**。它是 NextAuth 能够正常工作的“暗号”。
十、总结
通过以上步骤,你已经成功构建了一个完整的认证系统:
- 数据库:使用 Drizzle 定义了用户表并处理了连接。
- 注册:创建了独立的 API 路由来处理新用户注册和密码加密。
- 登录:使用 NextAuth 的
CredentialsProvider配合 Drizzle 查询数据库并验证密码。 - 前端:构建了一个登录注册合一的页面,并使用
signIn函数处理登录。 - 会话:通过
SessionProvider和getServerSession实现了会话管理和受保护路由。 - 用户信息:在仪表盘页面成功获取并显示了当前登录用户的信息。
更多推荐
所有评论(0)