认证方案
本教程共 42 篇 · 第 25 篇 · 更新于 2026-07-30 · 约 8 分钟阅读
25. 认证方案
本节目标:理解认证的三个核心概念(认证、会话管理、授权),掌握基于 Cookie 的无状态会话和基于数据库的会话两种实现方式,了解主流认证库的特点与选择。
认证的三个核心概念
在实现认证之前,需要将整个过程拆解为三个独立的概念:
- 认证(Authentication):验证用户身份,确认”你是你所说的那个人”。通常需要用户提供用户名和密码等凭证。
- 会话管理(Session Management):在多次请求之间追踪用户的认证状态。
- 授权(Authorization):决定用户可以访问哪些路由和数据。
Tip虽然可以自己实现完整的认证方案,但出于安全性和简洁性考虑,推荐使用成熟的认证库。它们提供认证、会话管理、社交登录、多因素认证、角色访问控制等开箱即用的功能。
认证流程:注册与登录
第一步:捕获用户凭证
使用 React 的 <form> 元素配合 Server Action 来捕获用户凭证。由于 Server Action 始终在服务器上执行,这为处理认证逻辑提供了安全的环境。
// app/ui/signup-form.tsx
import { signup } from '@/app/actions/auth'
export function SignupForm() {
return (
<form action={signup}>
<div>
<label htmlFor="name">姓名</label>
<input id="name" name="name" placeholder="请输入姓名" />
</div>
<div>
<label htmlFor="email">邮箱</label>
<input id="email" name="email" type="email" placeholder="请输入邮箱" />
</div>
<div>
<label htmlFor="password">密码</label>
<input id="password" name="password" type="password" />
</div>
<button type="submit">注册</button>
</form>
)
}
第二步:服务器端表单验证
使用 Zod 等 schema 验证库在服务器端验证表单字段:
// app/lib/definitions.ts
import * as z from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { error: '姓名至少需要 2 个字符' })
.trim(),
email: z.email({ error: '请输入有效的邮箱' }).trim(),
password: z
.string()
.min(8, { error: '密码至少需要 8 个字符' })
.regex(/[a-zA-Z]/, { error: '密码必须包含至少一个字母' })
.regex(/[0-9]/, { error: '密码必须包含至少一个数字' })
.regex(/[^a-zA-Z0-9]/, {
error: '密码必须包含至少一个特殊字符',
})
.trim(),
})
export type FormState =
| {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
}
| undefined
在 Server Action 中使用:
// app/actions/auth.ts
import { SignupFormSchema, FormState } from '@/app/lib/definitions'
export async function signup(state: FormState, formData: FormData) {
// 验证表单字段
const validatedFields = SignupFormSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
})
// 如果验证失败,提前返回错误
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// 调用认证库或数据库创建用户...
}
第三步:创建用户或验证凭证
验证通过后,可以创建新用户或验证现有用户:
// app/actions/auth.ts
export async function signup(state: FormState, formData: FormData) {
// ... 验证步骤 ...
const { name, email, password } = validatedFields.data
// 密码加密存储
const hashedPassword = await bcrypt.hash(password, 10)
// 插入数据库
const data = await db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id })
const user = data[0]
if (!user) {
return {
message: '创建账户时发生错误',
}
}
// TODO: 创建会话、重定向
}
会话管理
会话管理确保用户的认证状态在多次请求之间保持。有两种主要方式:
无状态会话(Stateless Sessions)
会话数据存储在浏览器的 Cookie 中,每次请求都会携带 Cookie,服务器据此验证会话。
实现步骤:
- 生成密钥:
openssl rand -base64 32
将生成的密钥存入环境变量:
# .env
SESSION_SECRET=your_secret_key
- 加密/解密会话:
// app/lib/session.ts
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
const secretKey = process.env.SESSION_SECRET
const encodedKey = new TextEncoder().encode(secretKey)
export async function encrypt(payload: SessionPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(session: string | undefined = '') {
try {
const { payload } = await jwtVerify(session, encodedKey, {
algorithms: ['HS256'],
})
return payload
} catch (error) {
console.log('会话验证失败')
}
}
- 设置 Cookie:
// app/lib/session.ts
import { cookies } from 'next/headers'
export async function createSession(userId: string) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const session = await encrypt({ userId, expiresAt })
const cookieStore = await cookies()
cookieStore.set('session', session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: 'lax',
path: '/',
})
}
ImportantCookie 应该在服务器端设置,防止客户端篡改。推荐的安全选项:
HttpOnly:阻止客户端 JavaScript 访问Secure:仅通过 HTTPS 传输SameSite:控制跨站请求行为
数据库会话(Database Sessions)
会话数据存储在数据库中,浏览器只持有加密的会话 ID。这种方式更安全,但实现更复杂。
// app/lib/session.ts
import { cookies } from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'
export async function createSession(id: number) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
// 1. 在数据库中创建会话
const data = await db
.insert(sessions)
.values({
userId: id,
expiresAt,
})
.returning({ id: sessions.id })
const sessionId = data[0].id
// 2. 加密会话 ID
const session = await encrypt({ sessionId, expiresAt })
// 3. 在 Cookie 中存储会话
const cookieStore = await cookies()
cookieStore.set('session', session, {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: 'lax',
path: '/',
})
}
会话刷新与删除
刷新会话(延长过期时间):
export async function updateSession() {
const session = (await cookies()).get('session')?.value
const payload = await decrypt(session)
if (!session || !payload) {
return null
}
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const cookieStore = await cookies()
cookieStore.set('session', session, {
httpOnly: true,
secure: true,
expires: expires,
sameSite: 'lax',
path: '/',
})
}
删除会话(用户登出):
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
授权控制
认证完成后,需要实现授权来控制用户可以访问什么资源。
创建数据访问层(DAL)
推荐创建 DAL 来集中管理数据请求和授权逻辑:
// app/lib/dal.ts
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
import { cache } from 'react'
export const verifySession = cache(async () => {
const cookie = (await cookies()).get('session')?.value
const session = await decrypt(cookie)
if (!session?.userId) {
redirect('/login')
}
return { isAuth: true, userId: session.userId }
})
使用 DAL 获取用户数据:
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
try {
const data = await db.query.users.findMany({
where: eq(users.id, session.userId),
// 只返回需要的列,而不是整个用户对象
columns: {
id: true,
name: true,
email: true,
},
})
return data[0]
} catch (error) {
console.log('获取用户失败')
return null
}
})
使用数据传输对象(DTO)
只返回必要的数据,避免暴露敏感信息:
// app/lib/dto.ts
import 'server-only'
import { getUser } from '@/app/lib/dal'
export async function getProfileDTO(slug: string) {
const data = await db.query.users.findMany({
where: eq(users.slug, slug),
})
const user = data[0]
const currentUser = await getUser(user.id)
// 只返回查询所需的数据
return {
username: canSeeUsername(currentUser) ? user.username : null,
phonenumber: canSeePhoneNumber(currentUser, user.team)
? user.phonenumber
: null,
}
}
在 Server Components 中检查授权
// app/dashboard/page.tsx
import { verifySession } from '@/app/lib/dal'
export default async function Dashboard() {
const session = await verifySession()
const userRole = session?.user?.role
if (userRole === 'admin') {
return <AdminDashboard />
} else if (userRole === 'user') {
return <UserDashboard />
} else {
redirect('/login')
}
}
Warning在 Layout 中做认证检查要谨慎,因为 Layout 不会在导航时重新渲染。应该在数据源附近或条件渲染的组件中进行检查。
认证库推荐
| 库 | 特点 | 适用场景 |
|---|---|---|
| NextAuth.js (Auth.js) | 开源、灵活、支持多种提供商 | 需要自定义认证流程 |
| Clerk | 开箱即用、UI 组件丰富 | 快速集成、付费服务 |
| Auth0 | 企业级、功能全面 | 大型企业应用 |
| Supabase Auth | 与 Supabase 生态集成 | 使用 Supabase 数据库 |
| Better Auth | 现代、TypeScript 优先 | 新项目、追求类型安全 |
会话管理库推荐
| 库 | 特点 |
|---|---|
| Iron Session | 轻量级、无状态、基于 Cookie |
| Jose | JWT 实现、支持 Edge Runtime |
小结
这一章我们学了认证的完整流程:
- 认证、会话管理、授权是三件不同的事
- 认证逻辑放服务端,用 Server Action 处理
- 无状态会话简单,数据库会话更安全
- 用 DAL 和 DTO 集中管理授权逻辑
- 认证检查要靠近数据源,别只在 Layout 里做