如何在 Next.js 中实现身份验证

理解身份验证机制对于保护应用数据至关重要。本页将指导你使用 React 和 Next.js 的哪些功能来实现认证流程。

在开始之前,我们可以将整个流程分解为三个核心概念:

  1. 身份验证 (Authentication):验证用户是否与其声称的身份一致。要求用户通过用户名密码等方式证明身份。
  2. 会话管理 (Session Management):跨请求跟踪用户的认证状态。
  3. 授权 (Authorization):决定用户可以访问哪些路由和数据。

下图展示了使用 React 和 Next.js 功能的认证流程:

展示使用 React 和 Next.js 功能的认证流程图

本文示例出于教学目的演示了基础的用户名密码认证。虽然你可以实现自定义认证方案,但为了更高的安全性和简便性,我们推荐使用认证库。这些库提供了开箱即用的认证、会话管理和授权解决方案,以及社交登录、多因素认证和基于角色的访问控制等附加功能。你可以在认证库部分找到相关列表。

身份验证

注册与登录功能

你可以使用 <form> 元素配合 React 的服务端操作 (Server Actions)useActionState 来捕获用户凭证、验证表单字段并调用认证提供商的 API 或数据库。

由于服务端操作始终在服务器端执行,它们为处理认证逻辑提供了安全环境。

以下是实现注册/登录功能的步骤:

1. 捕获用户凭证

要捕获用户凭证,创建一个表单并在提交时触发服务端操作。例如,一个接收用户名、邮箱和密码的注册表单:

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>
  )
}

2. 在服务端验证表单字段

使用服务端操作在服务端验证表单字段。如果你的认证提供商不提供表单验证,可以使用 ZodYup 等模式验证库。

以 Zod 为例,你可以定义带有适当错误信息的表单模式:

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

为了避免不必要的认证提供商 API 或数据库调用,如果任何表单字段不符合定义的模式,可以在服务端操作中提前 return

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,
    }
  }

  // 调用提供商或数据库创建用户...
}

回到你的 <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">姓名</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 键可用。
  • 在修改数据前,应始终确保用户有执行该操作的权限。参见认证与授权

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: '创建账户时发生错误。',
    }
  }

  // 待办事项:
  // 4. 创建用户会话
  // 5. 重定向用户
}

成功创建用户账户或验证用户凭证后,您可以创建一个会话来管理用户的认证状态。根据您的会话管理策略,会话可以存储在 cookie 或数据库中,或两者兼有。继续阅读会话管理部分了解更多。

提示:

  • 上面的示例较为详细,目的是为了教学而分解了认证步骤。这表明实现自己的安全解决方案可能很快变得复杂。考虑使用认证库来简化流程。
  • 为了改善用户体验,您可能希望在注册流程的早期检查重复的电子邮件或用户名。例如,当用户输入用户名或输入框失去焦点时。这可以帮助防止不必要的表单提交,并立即向用户提供反馈。您可以使用诸如 use-debounce 这样的库来管理这些检查的频率。

会话管理

会话管理确保用户的认证状态在多个请求之间得以保持。它涉及创建、存储、刷新和删除会话或令牌。

有两种类型的会话:

  1. 无状态会话:会话数据(或令牌)存储在浏览器的 cookie 中。每次请求都会发送 cookie,允许在服务器上验证会话。这种方法更简单,但如果实现不当可能不太安全。
  2. 数据库会话:会话数据存储在数据库中,用户的浏览器仅接收加密的会话 ID。这种方法更安全,但可能更复杂且占用更多服务器资源。

须知: 虽然您可以使用其中一种方法或两者兼用,但我们建议使用会话管理库,如 iron-sessionJose

无状态会话

要创建和管理无状态会话,您需要遵循以下步骤:

  1. 生成一个密钥,用于签名您的会话,并将其存储为环境变量
  2. 使用会话管理库编写加密/解密会话数据的逻辑。
  3. 使用 Next.js cookies API 管理 cookie。

除了上述内容,还可以考虑添加功能,在用户返回应用程序时更新(或刷新)会话,并在用户注销时删除会话。

须知: 检查您的认证库是否包含会话管理功能。

1. 生成密钥

有几种方法可以生成用于签名会话的密钥。例如,您可以选择在终端中使用 openssl 命令:

terminal
openssl rand -base64 32

此命令生成一个 32 字符的随机字符串,您可以用作密钥并存储在环境变量文件中:

.env
SESSION_SECRET=your_secret_key

然后,您可以在会话管理逻辑中引用此密钥:

app/lib/session.js
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('验证会话失败')
  }
}

提示:

  • 负载应包含在后续请求中使用的最小、唯一的用户数据,例如用户的 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() {
  await deleteSession()
  redirect('/login')
}

数据库会话

要创建和管理数据库会话,需要遵循以下步骤:

  1. 在数据库中创建表来存储会话数据(或检查你的认证库是否已处理此功能)
  2. 实现插入、更新和删除会话的功能
  3. 将会话 ID 加密后再存储到用户浏览器中,并确保数据库和 cookie 保持同步(这是可选的,但推荐用于 中间件 中的乐观认证检查)

例如:

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: '/',
  })
}

提示

  • 为了更快访问,可以考虑在会话生命周期内添加服务端缓存。你也可以将会话数据保留在主数据库中,并合并数据请求以减少查询次数。
  • 对于更高级的用例,如跟踪用户最后登录时间、活动设备数量或让用户能够注销所有设备,可以选择使用数据库会话。

实现会话管理后,需要添加授权逻辑来控制用户可以在应用中访问和执行的操作。继续阅读 授权 部分了解更多。

授权

用户认证并创建会话后,可以实现授权来控制用户在应用中可以访问和执行的操作。

主要有两种授权检查:

  1. 乐观检查:使用存储在 cookie 中的会话数据检查用户是否有权访问路由或执行操作。这些检查适用于快速操作,如根据权限或角色显示/隐藏 UI 元素或重定向用户。
  2. 安全检查:使用存储在数据库中的会话数据检查用户是否有权访问路由或执行操作。这些检查更安全,用于需要访问敏感数据或操作的情况。

对于这两种情况,我们建议:

使用中间件进行乐观检查(可选)

在某些情况下,你可能希望使用 中间件 (Middleware) 并根据权限重定向用户:

  • 执行乐观检查。由于中间件在每个路由上运行,这是集中重定向逻辑和预过滤未授权用户的好方法。
  • 保护用户间共享数据的静态路由(如付费内容)。

然而,由于中间件在每个路由上运行,包括 预取 (prefetched) 路由,重要的是仅从 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()
}

// 中间件不应运行的路由
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
}

虽然中间件可用于初始检查,但它不应该是保护数据的唯一防线。大多数安全检查应尽可能靠近数据源执行,详见 数据访问层 (DAL)

提示

  • 在中间件中,你也可以使用 req.cookies.get('session').value 读取 cookie。
  • 中间件使用 边缘运行时 (Edge Runtime),请检查你的认证库和会话管理库是否兼容。
  • 你可以使用中间件中的 matcher 属性指定中间件应运行的路由。但对于认证,建议中间件在所有路由上运行。

创建数据访问层 (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 }
})

然后你可以在数据请求、服务器操作、路由处理程序中调用 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 可用于保护请求时获取的数据。然而,对于用户间共享数据的静态路由,数据将在构建时获取而不是请求时获取。使用 中间件 保护静态路由。
  • 对于安全检查,可以通过将会话 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)

服务端组件中进行权限检查适用于基于角色的访问控制。例如,根据用户角色条件渲染组件:

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' 和未授权角色,确保用户仅访问与其角色匹配的组件。

布局与权限检查

由于部分渲染 (Partial Rendering),在布局 (Layouts) 中进行检查需谨慎,因其不会在导航时重新渲染,用户会话不会在每次路由变更时被验证。

应将检查逻辑靠近数据源或条件渲染的组件。例如,共享布局获取用户数据并在导航栏显示头像时,应在布局中调用 getUser(),而在 DAL 中执行权限检查。这确保无论 getUser() 在何处调用,都会执行权限检查,避免开发者遗漏授权验证。

export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();

  return (
    // ...
  )
}

须知:

  • 单页应用 (SPA) 中常见模式是在布局或顶层组件中 return null 以拒绝未授权用户。此模式不推荐,因为 Next.js 应用有多个入口点,无法阻止嵌套路由段和服务端操作 (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)

对待路由处理器需与对外 API 端点同等安全,验证用户是否有权访问。例如:

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'。

上下文提供器 (Context Providers)

由于交错渲染 (interleaving),上下文提供器可用于认证。但 React context 不支持服务端组件,仅适用于客户端组件。

此方式有效,但任何子服务端组件会先在服务端渲染,无法访问上下文提供器的会话数据:

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 (
    // ...
  );
}
'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 防止敏感会话数据暴露至客户端。

资源

了解 Next.js 认证后,以下兼容库和资源可帮助实现安全的认证和会话管理:

认证库

会话管理库

延伸阅读

继续学习认证与安全,可查阅以下资源: