如何在 Next.js 中实现身份验证
理解身份验证对于保护你的应用程序数据至关重要。本页将指导你了解使用哪些 React 和 Next.js 功能来实现身份验证。
在开始之前,将过程分解为三个概念会很有帮助:
此图显示了使用 React 和 Next.js 功能的身份验证流程:


本页的示例介绍了用于教育目的的基本用户名和密码身份验证。虽然你可以实现自定义身份验证解决方案,但为了增加安全性和简化性,我们建议使用身份验证库。 这些库提供身份验证、会话管理和授权的内置解决方案,以及社交登录、多因素身份验证和基于角色的访问控制等附加功能。你可以在身份验证库部分找到列表。
身份验证
注册和登录功能
你可以使用 <form>
元素与 React 的服务端操作和 useActionState
来捕获用户凭据、验证表单字段并调用你的身份验证提供商的 API 或数据库。
由于服务端操作始终在服务器上执行,它们为处理身份验证逻辑提供了安全的环境。
以下是实现注册/登录功能的步骤:
1. 捕获用户凭据
要捕获用户凭据,创建一个在提交时调用服务端操作的表单。例如,一个接受用户姓名、邮箱和密码的注册表单:
- TypeScript
- JavaScript
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>
)
}
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>
)
}
- TypeScript
- JavaScript
export async function signup(formData: FormData) {}
export async function signup(formData) {}
2. 在服务器上验证表单字段
使用服务端操作在服务器上验证表单字段。如果你的身份验证提供商不提供表单验证,你可以使用模式验证库,如 Zod 或 Yup。
以 Zod 为例,你可以定义一个带有适当错误消息的表单模式:
- TypeScript
- JavaScript
import { z } from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: '姓名必须至少包含 2 个字符。' })
.trim(),
email: z.string().email({ message: '请输入有效的邮箱地址。' }).trim(),
password: z
.string()
.min(8, { message: '至少包含 8 个字符' })
.regex(/[a-zA-Z]/, { message: '至少包含一个字母。' })
.regex(/[0-9]/, { message: '至少包含一个数字。' })
.regex(/[^a-zA-Z0-9]/, {
message: '至少包含一个特殊字符。',
})
.trim(),
})
export type FormState =
| {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
}
| undefined
import { z } from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: '姓名必须至少包含 2 个字符。' })
.trim(),
email: z.string().email({ message: '请输入有效的邮箱地址。' }).trim(),
password: z
.string()
.min(8, { message: '至少包含 8 个字符' })
.regex(/[a-zA-Z]/, { message: '至少包含一个字母。' })
.regex(/[0-9]/, { message: '至少包含一个数字。' })
.regex(/[^a-zA-Z0-9]/, {
message: '至少包含一个特殊字符。',
})
.trim(),
})
为了防止对身份验证提供商的 API 或数据库进行不必要的调用,如果任何表单字段与定义的模式不匹配,你可以在服务端操作中提前 return
。
- TypeScript
- JavaScript
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,
}
}
// 调用提供商或数据库创建用户...
}
import { SignupFormSchema } from '@/app/lib/definitions'
export async function signup(state, 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,
}
}
// 调用提供商或数据库创建用户...
}
回到你的 <SignupForm />
中,你可以使用 React 的 useActionState
hook 在表单提交时显示验证错误:
- TypeScript
- JavaScript
'use client'
import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'
export default function SignupForm() {
const [state, action, pending] = useActionState(signup, undefined)
return (
<form action={action}>
<div>
<label htmlFor="name">姓名</label>
<input id="name" name="name" placeholder="姓名" />
</div>
{state?.errors?.name && <p>{state.errors.name}</p>}
<div>
<label htmlFor="email">邮箱</label>
<input id="email" name="email" placeholder="邮箱" />
</div>
{state?.errors?.email && <p>{state.errors.email}</p>}
<div>
<label htmlFor="password">密码</label>
<input id="password" name="password" type="password" />
</div>
{state?.errors?.password && (
<div>
<p>密码必须包含:</p>
<ul>
{state.errors.password.map((error) => (
<li key={error}>- {error}</li>
))}
</ul>
</div>
)}
<button disabled={pending} type="submit">
注册
</button>
</form>
)
}
'use client'
import { signup } from '@/app/actions/auth'
import { useActionState } from 'react'
export default function SignupForm() {
const [state, action, pending] = useActionState(signup, undefined)
return (
<form action={action}>
<div>
<label htmlFor="name">姓名</label>
<input id="name" name="name" placeholder="姓名" />
</div>
{state?.errors?.name && <p>{state.errors.name}</p>}
<div>
<label htmlFor="email">邮箱</label>
<input id="email" name="email" placeholder="邮箱" />
</div>
{state?.errors?.email && <p>{state.errors.email}</p>}
<div>
<label htmlFor="password">密码</label>
<input id="password" name="password" type="password" />
</div>
{state?.errors?.password && (
<div>
<p>密码必须包含:</p>
<ul>
{state.errors.password.map((error) => (
<li key={error}>- {error}</li>
))}
</ul>
</div>
)}
<button disabled={pending} type="submit">
注册
</button>
</form>
)
}
注意:
- 在 React 19 中,
useFormStatus
返回的对象中包含额外的键,如 data、method 和 action。如果你不使用 React 19,则只有pending
键可用。- 在修改数据之前,你应该始终确保用户也授权执行该操作。请参阅 Authentication and Authorization。
3. 创建用户或检查用户凭据
验证表单字段后,你可以创建一个新用户账户或通过调用你的身份验证提供商的 API 或数据库来检查用户是否存在。
继续上一个示例:
- TypeScript
- JavaScript
export async function signup(state: FormState, formData: FormData) {
// 1. 验证表单字段
// ...
// 2. 准备数据以插入数据库
const { name, email, password } = validatedFields.data
// 例如,在存储之前对用户密码进行哈希处理
const hashedPassword = await bcrypt.hash(password, 10)
// 3. 将用户插入数据库或调用 Auth 库的 API
const data = await db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id })
const user = data[0]
if (!user) {
return {
message: '创建账户时发生错误。',
}
}
// TODO:
// 4. 创建用户会话
// 5. 重定向用户
}
export async function signup(state, formData) {
// 1. 验证表单字段
// ...
// 2. 准备数据以插入数据库
const { name, email, password } = validatedFields.data
// 例如,在存储之前对用户密码进行哈希处理
const hashedPassword = await bcrypt.hash(password, 10)
// 3. 将用户插入数据库或调用 Library API
const data = await db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id })
const user = data[0]
if (!user) {
return {
message: '创建账户时发生错误。',
}
}
// TODO:
// 4. 创建用户会话
// 5. 重定向用户
}
成功创建用户账户或验证用户凭据后,你可以创建一个会话来管理用户的认证状态。根据你的会话管理策略,会话可以存储在 cookie 或数据库中,或两者都存储。继续进入会话管理部分以了解更多信息。
Tips:
- 上述示例由于教育目的而详细说明了认证步骤,这突显了实现自己的安全解决方案可以快速变得复杂。考虑使用身份验证库来简化过程。
- 为了改善用户体验,你可能希望在注册流程的早期检查重复的邮箱或用户名。例如,当用户在用户名中输入或输入框失去焦点时。这可以帮助防止不必要的表单提交并为用户提供即时反馈。你可以使用像 use-debounce 这样的库来管理这些检查的频率。
以下是实现注册和/或登录表单的步骤:
- 用户通过表单提交他们的凭据。
- 表单发送一个请求,由 API 路由处理。
- 验证成功后,过程完成,表示用户认证成功。
- 如果验证失败,则显示错误消息。
考虑一个登录表单,用户可以输入他们的凭据:
- TypeScript
- JavaScript
import { FormEvent } from 'react'
import { useRouter } from 'next/router'
export default function LoginPage() {
const router = useRouter()
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const email = formData.get('email')
const password = formData.get('password')
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (response.ok) {
router.push('/profile')
} else {
// 处理错误
}
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="邮箱" required />
<input type="password" name="password" placeholder="密码" required />
<button type="submit">登录</button>
</form>
)
}
import { FormEvent } from 'react'
import { useRouter } from 'next/router'
export default function LoginPage() {
const router = useRouter()
async function handleSubmit(event) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const email = formData.get('email')
const password = formData.get('password')
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (response.ok) {
router.push('/profile')
} else {
// 处理错误
}
}
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" placeholder="邮箱" required />
<input type="password" name="password" placeholder="密码" required />
<button type="submit">登录</button>
</form>
)
}
上述表单有两个输入字段用于捕获用户的邮箱和密码。在提交时,它会触发一个发送 POST 请求到 API 路由 (/api/auth/login
) 的函数。
你可以在 API 路由中调用你的身份验证提供商的 API 来处理认证:
- TypeScript
- JavaScript
import type { NextApiRequest, NextApiResponse } from 'next'
import { signIn } from '@/auth'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { email, password } = req.body
await signIn('credentials', { email, password })
res.status(200).json({ success: true })
} catch (error) {
if (error.type === 'CredentialsSignin') {
res.status(401).json({ error: '无效凭据。' })
} else {
res.status(500).json({ error: '发生错误。' })
}
}
}
import { signIn } from '@/auth'
export default async function handler(req, res) {
try {
const { email, password } = req.body
await signIn('credentials', { email, password })
res.status(200).json({ success: true })
} catch (error) {
if (error.type === 'CredentialsSignin') {
res.status(401).json({ error: '无效凭据。' })
} else {
res.status(500).json({ error: '发生错误。' })
}
}
}
会话管理
会话管理确 保用户认证状态在请求之间得到保留。它涉及创建、存储、刷新和删除会话或令牌。
有两种类型的会话:
- 无状态会话: 会话数据(或令牌)存储在浏览器的 cookie 中。cookie 随每个请求发送,允许服务器验证会话。这种方法更简单,但如果实现不当,则可能不那么安全。
- 数据库会话: 会话数据存储在数据库中,用户的浏览器只接收加密的会话 ID。这种方法更安全,但可能更复杂并使用更多服务器资源。
注意: 虽然你可以使用这两种方法,或两者都使用,但我们建议使用会话管理库,例如 iron-session 或 Jose。
无状态会话
要创建和管理无状态会话,你需要遵循以下步骤:
此外,请考虑添加功能来更新(或刷新)当用户返回应用程序时,以及删除会话。
注意: 检查你的身份验证库是否包含会话管理。
1. 生成密钥
有几种方法可以生成签署会话的密钥。例如,你可以在终端中使用 openssl
命令:
openssl rand -base64 32
此命令生成一个 32 字符的随机字符串,你可以将其用作你的密钥并存储在你的环境变量文件中:
SESSION_SECRET=your_secret_key
然后你可以在会话管理逻辑中引用此密钥:
const secretKey = process.env.SESSION_SECRET
2. 加密和解密会话
接下来,你可以使用你喜欢的会话管理库来加密和解密会话。继续上一个示例,我们将使用 Jose (兼容 Edge Runtime) 和 React 的 server-only
包,以确保你的会话管理逻辑仅在服务器上执行。
- TypeScript
- JavaScript
import 'server-only'
import { SignJWT, jwtVerify } from 'jose'
import { SessionPayload } from '@/app/lib/definitions'
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('Failed to verify session')
}
}
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) {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(encodedKey)
}
export async function decrypt(session) {
try {
const { payload } = await jwtVerify(session, encodedKey, {
algorithms: ['HS256'],
})
return payload
} catch (error) {
console.log('Failed to verify session')
}
}
Tips:
- 负载应包含最小、唯一的用户数据,这些数据将在后续请求中使用,例如用户 ID、角色等。它不应包含个人身份信息(如电话号码、电子邮件地址、信用卡信息等)或敏感数据(如密码)。
3. 设置 cookie(推荐选项)
要将会话存储在 cookie 中,请使用 Next.js cookies
API。cookie 应在服务器上设置,并包含建议的选项:
- HttpOnly:防止客户端 JavaScript 访问 cookie。
- Secure:使用 https 发送 cookie。
- SameSite:指定 cookie 是否可以与跨站请求一起发送。
- Max-Age 或 Expires:在一定时间后删除 cookie。
- Path:定义 cookie 的路径。
请参阅 MDN 了解更多关于这些选项的信息。
- TypeScript
- JavaScript
import 'server-only'
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: '/',
})
}
import 'server-only'
import { cookies } from 'next/headers'
export async function createSession(userId) {
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: '/',
})
}
回到你的 Server Action,你可以调用 createSession()
函数,并使用 redirect()
API 将用户重定向到适当的页面:
- TypeScript
- JavaScript
import { createSession } from '@/app/lib/session'
export async function signup(state: FormState, formData: FormData) {
// 之前的步骤:
// 1. 验证表单字段
// 2. 准备数据以插入数据库
// 3. 将用户插入数据库或调用 Library API
// 当前步骤:
// 4. 创建用户会话
await createSession(user.id)
// 5. 重定向用户
redirect('/profile')
}
import { createSession } from '@/app/lib/session'
export async function signup(state, formData) {
// 之前的步骤:
// 1. 验证表单字段
// 2. 准备数据以插入数据库
// 3. 将用户插入数据库或调用 Library API
// 当前步骤:
// 4. 创建用户会话
await createSession(user.id)
// 5. 重定向用户
redirect('/profile')
}
Tips:
- Cookies 应该在服务器上设置,以防止客户端篡改。
- 🎥 观看:了解更多关于 Next.js 的无状态会话和认证 → YouTube (11 分钟)。
更新(或刷新)会话
你也可以扩展会话的过期时间。这对于在用户再次访问应用程序后保持用户登录状态很有用。例如:
- TypeScript
- JavaScript
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
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: '/',
})
}
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
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)(
await cookies()
).set('session', session, {
httpOnly: true,
secure: true,
expires: expires,
sameSite: 'lax',
path: '/',
})
}
Tip: 检查你的认证库是否支持刷新令牌,这些令牌可以用于延长用户的会话。
删除会话
要删除会话,你可以删除 cookie:
- TypeScript
- JavaScript
import 'server-only'
import { cookies } from 'next/headers'
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
import 'server-only'
import { cookies } from 'next/headers'
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
然后你可以在你的应用程序中重用 deleteSession()
函数,例如,在登出时:
- TypeScript
- JavaScript
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
export async function logout() {
await deleteSession()
redirect('/login')
}
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
export async function logout() {
await deleteSession()
redirect('/login')
}
设置和删除 cookie
你可以使用 API Routes 在服务器上将会话设置为 cookie:
- TypeScript
- JavaScript
import { serialize } from 'cookie'
import type { NextApiRequest, NextApiResponse } from 'next'
import { encrypt } from '@/app/lib/session'
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const sessionData = req.body
const encryptedSessionData = encrypt(sessionData)
const cookie = serialize('session', encryptedSessionData, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 一周
path: '/',
})
res.setHeader('Set-Cookie', cookie)
res.status(200).json({ message: '成功设置 cookie!' })
}
import { serialize } from 'cookie'
import { encrypt } from '@/app/lib/session'
export default function handler(req, res) {
const sessionData = req.body
const encryptedSessionData = encrypt(sessionData)
const cookie = serialize('session', encryptedSessionData, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 一周
path: '/',
})
res.setHeader('Set-Cookie', cookie)
res.status(200).json({ message: '成功设置 cookie!' })
}
数据库会话
要创建和管理数据库会话,你需要遵循以下步骤:
- 在数据库中创建一个表来存储会话和数据(或检查你的认证库是否处理此问题)。
- 实现插入、更新和删除会话的功能
- 在用户浏览器中加密会话 ID 并确保数据库和 cookie 保持同步(这是可选的,但推荐用于乐观认证检查 中间件)。
例如:
- TypeScript
- JavaScript
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,
})
// 返回会话 ID
.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: '/',
})
}
import cookies from 'next/headers'
import { db } from '@/app/lib/db'
import { encrypt } from '@/app/lib/session'
export async function createSession(id) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
// 1. 在数据库中创建会话
const data = await db
.insert(sessions)
.values({
userId: id,
expiresAt,
})
// 返回会话 ID
.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: '/',
})
}
Tips:
- 为了更快访问,你可能考虑为会话的生命周期添加服务器缓存。你也可以将会话数据保存在你的主数据库中,并结合数据请求以减少查询次数。
- 你可能选择使用数据库会话来处理更高级的用例,例如跟踪用户上次登录时间,或活跃设备数量,或为用户提供注销所有设备的能力。
实现会话管理后,你需要添加授权逻辑来控制用户可以在应用程序中访问和执行的操作。继续进入授权部分以了解更多信息。
在服务器上创建会话:
- TypeScript
- JavaScript
import db from '../../lib/db'
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const user = req.body
const sessionId = generateSessionId()
await db.insertSession({
sessionId,
userId: user.id,
createdAt: new Date(),
})
res.status(200).json({ sessionId })
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' })
}
}
import db from '../../lib/db'
export default async function handler(req, res) {
try {
const user = req.body
const sessionId = generateSessionId()
await db.insertSession({
sessionId,
userId: user.id,
createdAt: new Date(),
})
res.status(200).json({ sessionId })
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' })
}
}
授权
一旦用户认证并创建了会话,你就可以实现授权来控制用户可以在应用程序中访问和执行的操作。
有两种主要的授权检查类型:
- 乐观:检查 cookie 中存储的会话数据,以确定用户是否有权访问路由或执行操作。这些检查对于快速操作很有用,例如显示/隐藏 UI 元素或根据权限或角色重定向用户。
- 安全:检查数据库中存储的会话数据,以确定用户是否有权访问路由或执行操作。这些检查更安全,并用于需要访问敏感数据或执行操作的操作。
对于这两种情况,我们建议:
- 创建一个数据访问层来集中你的数据请求和授权逻辑
- 使用数据传输对象 (DTO)只返回必要的数据
- 可选地使用 中间件 执行乐观检查。
乐观检查与中间件(可选)
在某些情况下,你可能希望使用 中间件 并根据权限重定向用户:
- 为了执行乐观检查。由于中间件在每个路由上运行,它是集中重定向逻辑和预过滤未授权用户的好方法。
- 为了保护共享数据的路由(例如,内容在付费墙后面)。
然而,由于中间件在每个路由上运行,包括 预取 路由,重要的是只从 cookie 读取会话(乐观检查),并避免数据库检查以防止性能问题。
例如:
- TypeScript
- JavaScript
import { NextRequest, NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'
// 1. 指定受保护和公共路由
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']
export default async function middleware(req: NextRequest) {
// 2. 检查当前路由是否受保护或公共
const path = req.nextUrl.pathname
const isProtectedRoute = protectedRoutes.includes(path)
const isPublicRoute = publicRoutes.includes(path)
// 3. 从 cookie 解密会话
const cookie = (await cookies()).get('session')?.value
const session = await decrypt(cookie)
// 4. 如果用户未认证,重定向到 /login
if (isProtectedRoute && !session?.userId) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
// 5. 如果用户已认证,重定向到 /dashboard
if (
isPublicRoute &&
session?.userId &&
!req.nextUrl.pathname.startsWith('/dashboard')
) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
}
return NextResponse.next()
}
// 中间件不应运行的路由
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
import { NextResponse } from 'next/server'
import { decrypt } from '@/app/lib/session'
import { cookies } from 'next/headers'
// 1. 指定受保护和公共路由
const protectedRoutes = ['/dashboard']
const publicRoutes = ['/login', '/signup', '/']
export default async function middleware(req) {
// 2. 检查当前路由是否受保护或公共
const path = req.nextUrl.pathname
const isProtectedRoute = protectedRoutes.includes(path)
const isPublicRoute = publicRoutes.includes(path)
// 3. 从 cookie 解密会话
const cookie = (await cookies()).get('session')?.value
const session = await decrypt(cookie)
// 5. 如果用户未认证,重定向到 /login
if (isProtectedRoute && !session?.userId) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
// 6. 如果用户已认证,重定向到 /dashboard
if (
isPublicRoute &&
session?.userId &&
!req.nextUrl.pathname.startsWith('/dashboard')
) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
}
return NextResponse.next()
}
// 中间件不应运行的路由
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
虽然中间件可以很有用,但它不应成为保护你数据的主要防线。大多数安全检查应该尽可能接近你的数据源,请参阅 数据访问层 了解更多信息。
Tips:
- 在中间件中,你也可以使用
req.cookies.get('session').value
读取 cookie。- 中间件使用 Edge Runtime,请检查你的认证库和会话管理库是否兼容。
- 你可以使用 Middleware 的
matcher
属性来指定 Middleware 应运行的路由。虽然,对于认证,建议 Middleware 运行在所有路由上。
创建数据访问层 (DAL)
我们建议创建一个 DAL 来集中你的数据请求和授权逻辑。
DAL 应该包含一个验证用户会话的函数,当用户与你的应用程序交互时。至少,该函数应检查会话是否有效,然后重定向或返回用户所需的信息以进行进一步请求。
例如,创建一个单独的文件用于 DAL,其中包含一个 verifySession()
函数。然后使用 React 的 缓存 API 来缓存函数返回值,在 React 渲染过程中:
- TypeScript
- JavaScript
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
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 }
})
import 'server-only'
import { cookies } from 'next/headers'
import { decrypt } from '@/app/lib/session'
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 }
})
然后你可以在你的数据请求、Server Actions、Route Handlers 中调用 verifySession()
函数:
- TypeScript
- JavaScript
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,
},
})
const user = data[0]
return user
} catch (error) {
console.log('Failed to fetch user')
return null
}
})
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,
},
})
const user = data[0]
return user
} catch (error) {
console.log('Failed to fetch user')
return null
}
})
Tip:
使用数据传输对象 (DTO)
当检索数据时,建议只返回应用程序中将使用的必要数据,而不是整个对象。例如,如果你正在获取用户数据,你可能只返回用户的 ID 和名称,而不是整个用户对象,该对象可能包含密码、电话号码等。
然而,如果你无法控制返回的数据结构,或者你与一个希望避免将整个对象传递给客户端的团队一起工作,你可以使用策略,例如指定哪些字段可以安全地暴露给客户端。
- TypeScript
- JavaScript
import 'server-only'
import { getUser } from '@/app/lib/dal'
function canSeeUsername(viewer: User) {
return true
}
function canSeePhoneNumber(viewer: User, team: string) {
return viewer.isAdmin || team === viewer.team
}
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,
}
}
import 'server-only'
import { getUser } from '@/app/lib/dal'
function canSeeUsername(viewer) {
return true
}
function canSeePhoneNumber(viewer, team) {
return viewer.isAdmin || team === viewer.team
}
export async function getProfileDTO(slug) {
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,
}
}
通过集中你的数据请求和授权逻辑,你可以确保所有数据请求都是安全的和一致的,使维护、审计和调试变得更加容易,因为你的应用程序扩展。
注意:
- 定义 DTO 有几种不同的方法,从使用
toJSON()
,到像上面的示例那样使用单独的函数,或者 JS 类。由于这些是 JavaScript 模式,而不是 React 或 Next.js 特性,我们建议根据你的应用程序进行一些研究,找到最适合你的模式。- 了解更多关于安全最佳实践,请参阅我们的 Next.js 安全性文章。
服务器组件
服务器组件中的认证检查对于基于角色的访问很有用。例如,根据用户角色有条件地渲染组件:
- TypeScript
- JavaScript
import { verifySession } from '@/app/lib/dal'
export default function Dashboard() {
const session = await verifySession()
const userRole = session?.user?.role // 假设 'role' 是会话对象的一部分
if (userRole === 'admin') {
return <AdminDashboard />
} else if (userRole === 'user') {
return <UserDashboard />
} else {
redirect('/login')
}
}
import { verifySession } from '@/app/lib/dal'
export default function Dashboard() {
const session = await verifySession()
const userRole = session.role // 假设 'role' 是会话对象的一部分
if (userRole === 'admin') {
return <AdminDashboard />
} else if (userRole === 'user') {
return <UserDashboard />
} else {
redirect('/login')
}
}
在示例中,我们使用 DAL 中的 verifySession()
函数来检查 'admin'、'user' 和未授权的角色。这种模式确保每个用户只与适合其角色的组件交互。
布局和认证检查
由于 部分渲染,在 布局 中进行检查时要小心,因为这些不会在导航时重新渲染,这意味着用户会话不会在每次路由更改时都进行检查。
相反,你应该在数据源或将要条件渲染的组件附近进行检查。
例如,考虑一个共享布局,它获取用户数据并显示导航栏中的用户图像。与其在布局中进行认证检查,不如在布局中获取用户数据 (getUser()
) 并在 DAL 中进行认证检查。
这确保了无论 getUser()
在应用程序中的何处被调用,认证检查都会执行,并防止开发人员忘记检查用户是否有权访问数据。
- TypeScript
- JavaScript
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser();
return (
// ...
)
}
export default async function Layout({ children }) {
const user = await getUser();
return (
// ...
)
}
- TypeScript
- JavaScript
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
// 从会话获取用户 ID 并获取数据
})
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
// 从会话获取用户 ID 并获取数据
})
注意:
- SPA 中的一种常见模式是在布局或顶层组件中
return null
,如果用户未认证。这种模式 不推荐,因为 Next.js 应用程序有多个入口点,这些入口点不会阻止嵌套路由段和服务端操作的访问。
服务器操作
将 服务器操作 与公共 API 端点相同的保护考虑因素,并验证用户是否有权执行突变。
在下面的示例中,我们在允许操作继续之前检查用户角色:
- TypeScript
- JavaScript
'use server'
import { verifySession } from '@/app/lib/dal'
export async function serverAction(formData: FormData) {
const session = await verifySession()
const userRole = session?.user?.role
// 如果用户未授权执行操作,提前返回
if (userRole !== 'admin') {
return null
}
// 授权用户继续操作
}
'use server'
import { verifySession } from '@/app/lib/dal'
export async function serverAction() {
const session = await verifySession()
const userRole = session.user.role
// 如果用户未授权执行操作,提前返回
if (userRole !== 'admin') {
return null
}
// 授权用户继续操作
}
路由处理器
将 路由处理器 与公共 API 端点相同的保护考虑因素,并验证用户是否有权访问路由处理器。
例如:
- TypeScript
- JavaScript
import { verifySession } from '@/app/lib/dal'
export async function GET() {
// 用户认证和角色验证
const session = await verifySession()
// 检查用户是否已认证
if (!session) {
// 用户未认证
return new Response(null, { status: 401 })
}
// 检查用户是否有 'admin' 角色
if (session.user.role !== 'admin') {
// 用户已认证但权限不足
return new Response(null, { status: 403 })
}
// 继续授权用户
}
import { verifySession } from '@/app/lib/dal'
export async function GET() {
// 用户认证和角色验证
const session = await verifySession()
// 检查用户是否已认证
if (!session) {
// 用户未认证
return new Response(null, { status: 401 })
}
// 检查用户是否有 'admin' 角色
if (session.user.role !== 'admin') {
// 用户已认证但权限不足
return new Response(null, { status: 403 })
}
// 继续授权用户
}
上述示例演示了一个具有两层安全检查的路由处理器。它首先检查是否有活动会话,然后验证登录用户是否为 'admin'。
上下文提供者
使用上下文提供者进行认证由于 交错 而有效。然而,React context
在服务器组件中不受支持,使它们仅适用于客户端组件。
这种方法有效,但任何子服务器组件将首先在服务器上渲染,并且不会访问上下文提供者的会话数据:
import { ContextProvider } from 'auth-lib'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ContextProvider>{children}</ContextProvider>
</body>
</html>
)
}
- TypeScript
- JavaScript
'use client';
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
'use client';
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
如果会话数据在客户端组件中(例如,用于客户端数据获取),则使用 React 的 taintUniqueValue
API 来防止敏感会话数据暴露给客户端。
创建数据访问层 (DAL)
保护 API 路由
Next.js 中的 API 路由是处理服务器端逻辑和管理数据的关键。确保这些路由的安全性对于确保只有授权用户可以访问特定功能至关重要。这通常涉及验证用户的认证状态和他们的基于角色的权限。
这是一个保护 API 路由的示例:
- TypeScript
- JavaScript
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const session = await getSession(req)
// 检查用户是否已认证
if (!session) {
res.status(401).json({
error: '用户未认证',
})
return
}
// 检查用户是否有 'admin' 角色
if (session.user.role !== 'admin') {
res.status(401).json({
error: '未授权访问:用户没有管理员权限。',
})
return
}
// 继续授权用户
// ... API 路由的实现
}
export default async function handler(req, res) {
const session = await getSession(req)
// 检查用户是否已认证
if (!session) {
res.status(401).json({
error: '用户未认证',
})
return
}
// 检查用户是否有 'admin' 角色
if (session.user.role !== 'admin') {
res.status(401).json({
error: '未授权访问:用户没有管理员权限。',
})
return
}
// 继续授权用户
// ... API 路由的实现
}
此示例演示了一个具有两层安全检查的 API 路由,它首先检查是否有活动会话,然后验证登录用户是否为 'admin'。这种方法确保了安全的访问,仅限于认证和授权的用户,维护了请求处理的强大安全性。
资源
现在你已经了解了 Next.js 中的认证,这里有一些 Next.js 兼容的库和资源,可以帮助你实现安全的认证和会话管理: