如何在 Next.js 中实现身份验证
理解身份验证对于保护应用程序数据至关重要。本页将指导你使用哪些 React 和 Next.js 功能来实现身份验证。
在开始之前,将这个过程分解为三个概念会有所帮助:
下图展示了使用 React 和 Next.js 功能的身份验证流程:
本页上的示例演示了基本的用户名和密码身份验证,仅供教育目的。虽然你可以实现自定义身份验证解决方案,但为了提高安全性和简便性,我们建议使用身份验证库。这些库为身份验证、会话管理和授权提供内置解决方案,以及其他功能,如社交登录、多因素身份验证和基于角色的访问控制。你可以在身份验证库部分找到相关列表。
身份验证
注册和登录功能
你可以使用 <form>
元素与 React 的服务器操作和 useActionState
来捕获用户凭据、验证表单字段,并调用你的身份验证提供商的 API 或数据库。
由于服务器操作始终在服务器上执行,它们为处理身份验证逻辑提供了安全的环境。
以下是实现注册/登录功能的步骤:
1. 捕获用户凭据
要捕获用户凭据,创建一个在提交时调用服务器操作的表单。例如,接受用户姓名、电子邮件和密码的注册表单:
import { signup } from '@/app/actions/auth'
export function SignupForm() {
return (
<form action={signup}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" placeholder="Name" />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" placeholder="Email" />
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />
</div>
<button type="submit">Sign Up</button>
</form>
)
}
export async function signup(formData: FormData) {}
2. 在服务器上验证表单字段
使用服务器操作在服务器上验证表单字段。如果你的身份验证提供商不提供表单验证,你可以使用模式验证库,如 Zod 或 Yup。
以 Zod 为例,你可以定义一个带有适当错误消息的表单模式:
import { z } from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters long.' })
.trim(),
email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
password: z
.string()
.min(8, { message: 'Be at least 8 characters long' })
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
.regex(/[0-9]/, { message: 'Contain at least one number.' })
.regex(/[^a-zA-Z0-9]/, {
message: 'Contain at least one special character.',
})
.trim(),
})
export type FormState =
| {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
}
| undefined
为了避免不必要地调用你的身份验证提供商的 API 或数据库,如果任何表单字段不符合定义的模式,你可以在服务器操作中提前 return
。
import { SignupFormSchema, FormState } from '@/app/lib/definitions'
export async function signup(state: FormState, formData: FormData) {
// Validate form fields
const validatedFields = SignupFormSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
})
// If any form fields are invalid, return early
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
// Call the provider or db to create a user...
}
在你的 <SignupForm />
中,你可以使用 React 的 useActionState
钩子在表单提交时显示验证错误:
'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">Name</label>
<input id="name" name="name" placeholder="Name" />
</div>
{state?.errors?.name && <p>{state.errors.name}</p>}
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" placeholder="Email" />
</div>
{state?.errors?.email && <p>{state.errors.email}</p>}
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />
</div>
{state?.errors?.password && (
<div>
<p>Password must:</p>
<ul>
{state.errors.password.map((error) => (
<li key={error}>- {error}</li>
))}
</ul>
</div>
)}
<button disabled={pending} type="submit">
Sign Up
</button>
</form>
)
}
值得注意的是:
- 在 React 19 中,
useFormStatus
在返回的对象中包含额外的键,如 data、method 和 action。如果你不使用 React 19,则只有pending
键可用。- 在修改数据之前,你应该始终确保用户也有权执行该操作。请参阅身份验证和授权。
3. 创建用户或检查用户凭据
验证表单字段后,你可以通过调用身份验证提供商的 API 或数据库来创建新用户帐户或检查用户是否存在。
继续上面的示例:
export async function signup(state: FormState, formData: FormData) {
// 1. 验证表单字段
// ...
// 2. 准备要插入数据库的数据
const { name, email, password } = validatedFields.data
// 例如,在存储之前对用户密码进行哈希处理
const hashedPassword = await bcrypt.hash(password, 10)
// 3. 将用户插入数据库或调用身份验证库的 API
const data = await db
.insert(users)
.values({
name,
email,
password: hashedPassword,
})
.returning({ id: users.id })
const user = data[0]
if (!user) {
return {
message: 'An error occurred while creating your account.',
}
}
// TODO:
// 4. 创建用户会话
// 5. 重定向用户
}
成功创建用户帐户或验证用户凭据后,你可以创建会话来管理用户的身份验证状态。根据你的会话管理策略,会话可以存储在 cookie 或数据库中,或两者都存储。继续阅读会话管理部分了解更多信息。
提示:
- 上面的示例很详细,因为它为了教育目的分解了身份验证步骤。这突显了实现自己的安全解决方案可能很快变得复杂。考虑使用身份验证库来简化这个过程。
- 为了改善用户体验,你可能希望在注册流程的早期阶段检查重复的电子邮件或用户名。例如,当用户输入用户名或输入字段失去焦点时。这可以帮助防止不必要的表单提交,并为用户提供即时反馈。你可以使用 use-debounce 等库来管理这些检查的频率。
会话管理
会话管理确保用户的身份验证状态在请求之间得到保持。它涉及创建、存储、刷新和删除会话或令牌。
有两种类型的会话:
- 无状态:会话数据(或令牌)存储在浏览器的 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
包来确保你的会话管理逻辑仅在服务器上执行。
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')
}
}
提示:
- 有效载荷应包含在后续请求中使用的最小、唯一的用户数据,例如用户的 ID、角色等。它不应包含个人身份信息,如电话号码、电子邮件地址、信用卡信息等,或敏感数据如密码。
3. 设置 cookie(推荐选项)
要将会话存储在 cookie 中,请使用 Next.js cookies
API。Cookie 应该在服务器上设置,并包含以下推荐选项:
- HttpOnly:防止客户端 JavaScript 访问 cookie。
- Secure:使用 https 发送 cookie。
- SameSite:指定 cookie 是否可以与跨站点请求一起发送。
- Max-Age 或 Expires:在一定时间后删除 cookie。
- Path:为 cookie 定义 URL 路径。
有关这些选项的更多信息,请参阅 MDN。
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: '/',
})
}
在你的服务器操作中,你可以调用 createSession()
函数,并使用 redirect()
API 将用户重定向到适当的页面:
import { createSession } from '@/app/lib/session'
export async function signup(state: FormState, formData: FormData) {
// 前面的步骤:
// 1. 验证表单字段
// 2. 准备要插入数据库的数据
// 3. 将用户插入数据库或调用库 API
// 当前步骤:
// 4. 创建用户会话
await createSession(user.id)
// 5. 重定向用户
redirect('/profile')
}
提示:
- Cookie 应该在服务器上设置,以防止客户端篡改。
- 🎥 观看:了解更多关于无状态会话和 Next.js 中的身份验证 → YouTube(11 分钟)。
更新(或刷新)会话
你还可以延长会话的过期时间。当用户再次访问应用程序时,这对保持用户的登录状态很有用。例如:
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: '/',
})
}
提示: 检查你的认证库是否支持刷新令牌,这可以用来延长用户的会话。
删除会话
要删除会话,你可以删除 cookie:
import 'server-only'
import { cookies } from 'next/headers'
export async function deleteSession() {
const cookieStore = await cookies()
cookieStore.delete('session')
}
然后你可以在应用程序中重复使用 deleteSession()
函数,例如,在登出时:
import { cookies } from 'next/headers'
import { deleteSession } from '@/app/lib/session'
export async function logout() {
deleteSession()
redirect('/login')
}
数据库会话
要创建和管理数据库会话,你需要按照以下步骤操作:
- 在数据库中创建一个表来存储会话和数据(或检查你的认证库是否处理这个问题)。
- 实现插入、更新和删除会话的功能
- 在存储到用户浏览器之前加密会话 ID,并确保数据库和 cookie 保持同步(这是可选的,但对于在 Middleware 中进行乐观认证检查是推荐的)。
例如:
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: '/',
})
}
提示:
- 为了更快的访问速度,你可以考虑在会话生命周期内添加服务器缓存。你还可以将会话数据保存在主数据库中,并合并数据请求以减少查询次数。
- 对于更高级的用例,你可以选择使用数据库会话,例如跟踪用户上次登录的时间,或活跃设备的数量,或者给用户提供从所有设备登出的能力。
实现会话管理后,你需要添加授权逻辑来控制用户在应用程序中可以访问和执行的操作。继续阅读 授权 部分了解更多信息。
授权
一旦用户通过认证并创建了会话,你可以实现授权来控制用户在应用程序中可以访问和执行的操作。
有两种主要类型的授权检查:
- 乐观检查:使用存储在 cookie 中的会话数据检查用户是否有权访问路由或执行操作。这些检查对于快速操作很有用,例如基于权限或角色显示/隐藏 UI 元素或重定向用户。
- 安全检查:使用存储在数据库中的会话数据检查用户是否有权访问路由或执行操作。这些检查更安全,用于需要访问敏感数据或操作的操作。
对于这两种情况,我们建议:
- 创建一个数据访问层来集中你的授权逻辑
- 使用数据传输对象 (DTO)只返回必要的数据
- 可选地使用 Middleware 执行乐观检查。
使用 Middleware 进行乐观检查(可选)
在某些情况下,你可能想要使用 Middleware 并根据权限重定向用户:
- 执行乐观检查。由于 Middleware 在每个路由上运行,它是集中重定向逻辑和预先过滤未授权用户的好方法。
- 保护共享用户之间数据的静态路由(例如,付费墙后面的内容)。
然而,由于 Middleware 在每个路由上运行,包括 预取 的路由,重要的是只从 cookie 中读取会话(乐观检查),并避免数据库检查以防止性能问题。
例如:
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()
}
// Middleware 不应运行的路由
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}
虽然 Middleware 对于初始检查很有用,但它不应该是保护数据的唯一防线。大多数安全检查应尽可能靠近数据源执行,查看数据访问层了解更多信息。
提示:
- 在 Middleware 中,你也可以使用
req.cookies.get('session').value
读取 cookie。- Middleware 使用 Edge Runtime,检查你的认证库和会话管理库是否兼容。
- 你可以在 Middleware 中使用
matcher
属性指定 Middleware 应该在哪些路由上运行。不过,对于认证,建议 Middleware 在所有路由上运行。
创建数据访问层 (DAL)
我们建议创建一个 DAL 来集中你的数据请求和授权逻辑。
DAL 应该包含一个函数,在用户与应用程序交互时验证用户的会话。至少,该函数应该检查会话是否有效,然后重定向或返回进行进一步请求所需的用户信息。
例如,为你的 DAL 创建一个单独的文件,其中包含一个 verifySession()
函数。然后使用 React 的 cache API 在 React 渲染过程中记忆该函数的返回值:
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()
函数:
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
}
})
提示:
- DAL 可用于保护在请求时获取的数据。然而,对于在用户之间共享数据的静态路由,数据将在构建时而不是请求时获取。使用 Middleware 保护静态路由。
- 对于安全检查,你可以通过将会话 ID 与数据库进行比较来检查会话是否有效。使用 React 的 cache 函数避免在渲染过程中对数据库进行不必要的重复请求。
- 你可能希望将相关的数据请求整合到一个 JavaScript 类中,该类在任何方法之前运行
verifySession()
。
使用数据传输对象 (DTO)
检索数据时,建议你只返回应用程序中将使用的必要数据,而不是整个对象。例如,如果你正在获取用户数据,你可能只返回用户的 ID 和名称,而不是可能包含密码、电话号码等的整个用户对象。
然而,如果你无法控制返回的数据结构,或者在团队中工作,希望避免将整个对象传递给客户端,你可以使用策略,如指定哪些字段可以安全地暴露给客户端。
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,
}
}
通过在 DAL 中集中你的数据请求和授权逻辑,并使用 DTO,你可以确保所有数据请求都是安全和一致的,使得在应用程序扩展时更容易维护、审计和调试。
值得注意的是:
- 有几种不同的方法可以定义 DTO,从使用
toJSON()
,到如上例所示的单独函数,或 JS 类。由于这些是 JavaScript 模式而不是 React 或 Next.js 特性,我们建议进行一些研究,找到最适合你应用程序的模式。- 在我们的 Next.js 中的安全性文章 中了解更多关于安全最佳实践的信息。
Server Components
Server Components 中的认证检查对于基于角色的访问很有用。例如,根据用户角色有条件地渲染组件:
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')
}
}
在示例中,我们使用 DAL 中的 verifySession()
函数检查"admin"、"user"和未授权角色。这种模式确保每个用户只与适合其角色的组件交互。
布局和认证检查
由于部分渲染,在布局中进行检查时要谨慎,因为这些不会在导航时重新渲染,这意味着用户会话不会在每次路由更改时检查。
相反,你应该在靠近数据源或将被有条件渲染的组件处进行检查。
例如,考虑一个共享布局,它获取用户数据并在导航栏中显示用户图像。你应该在布局中获取用户数据(getUser()
),并在 DAL 中进行认证检查,而不是在布局中进行认证检查。
这保证了无论在应用程序中何处调用 getUser()
,都会执行认证检查,并防止开发人员忘记检查用户是否有权访问数据。
export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUser();
return (
// ...
)
}
export const getUser = cache(async () => {
const session = await verifySession()
if (!session) return null
// 从会话中获取用户 ID 并获取数据
})
值得注意的是:
- SPA 中的一种常见模式是,如果用户未授权,则在布局或顶层组件中
return null
。不建议使用这种模式,因为 Next.js 应用程序有多个入口点,这不会阻止嵌套路由段和 Server Actions 被访问。
Server Actions
对 Server Actions 的安全考虑与面向公众的 API 端点相同,并验证用户是否被允许执行变更。
在下面的示例中,我们在允许操作继续之前检查用户的角色:
'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
}
// 为授权用户继续操作
}
Route Handlers
对 Route Handlers 的安全考虑与面向公众的 API 端点相同,并验证用户是否被允许访问 Route Handler。
例如:
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 })
}
// 为授权用户继续
}
上面的示例演示了一个具有两层安全检查的 Route Handler。它首先检查活动会话,然后验证登录用户是否为"admin"。
Context Providers
由于交错,使用 context providers 进行认证是可行的。然而,React 的 context
在 Server Components 中不受支持,使其只适用于 Client Components。
这种方法是可行的,但任何子 Server Components 将首先在服务器上渲染,并且无法访问 context provider 的会话数据:
import { ContextProvider } from 'auth-lib'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ContextProvider>{children}</ContextProvider>
</body>
</html>
)
}
"use client";
import { useSession } from "auth-lib";
export default function Profile() {
const { userId } = useSession();
const { data } = useSWR(`/api/user/${userId}`, fetcher)
return (
// ...
);
}
如果在 Client Components 中需要会话数据(例如,用于客户端数据获取),使用 React 的 taintUniqueValue
API 防止敏感的会话数据暴露给客户端。
资源
现在你已经了解了 Next.js 中的认证,以下是兼容 Next.js 的库和资源,帮助你实现安全的认证和会话管理:
认证库
会话管理库
进一步阅读
要继续了解认证和安全性,请查看以下资源: