Menu

身份认证

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

在开始之前,将这个过程分解为三个概念会有所帮助:

  1. 身份认证: 验证用户是否是他们所声称的那个人。它要求用户提供自己拥有的东西来证明身份,比如用户名和密码。
  2. 会话管理: 在多个请求中跟踪用户的身份认证状态。
  3. 授权: 决定用户可以访问哪些路由和数据。

这个图表展示了使用 React 和 Next.js 功能的身份认证流程:

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

本页的示例演示了基本的用户名和密码认证,仅用于教育目的。虽然你可以实现自定义的身份认证解决方案,但为了提高安全性和简便性,我们建议使用身份认证库。这些库为身份认证、会话管理和授权提供内置解决方案,以及社交登录、多因素身份认证和基于角色的访问控制等附加功能。你可以在 身份认证库 部分找到一个列表。

身份认证

注册和登录功能

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

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

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

1. 捕获用户凭证

要捕获用户凭证,创建一个在提交时调用服务器操作的表单。例如,一个接受用户姓名、电子邮件和密码的注册表单:

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>
  );
}
app/ui/signup-form.js
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>
  );
}
app/actions/auth.tsx
export async function signup(formData: FormData) {}
app/actions/auth.js
export async function signup(formData) {}

2. 在服务器上验证表单字段

使用服务器操作在服务器上验证表单字段。如果你的身份认证提供商不提供表单验证,你可以使用如 ZodYup 这样的模式验证库。

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

app/lib/definitions.ts
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;
app/lib/definitions.js
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

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.js
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 来显示验证错误和表单提交时的待处理状态:

app/ui/signup-form.tsx
"use client";
 
import { useActionState } from "react";
import { signup } from "@/app/actions/auth";
 
export 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 aria-disabled={pending} type="submit">
        {pending ? "提交中..." : "注册"}
      </button>
    </form>
  );
}
app/ui/signup-form.js
"use client";
 
import { useActionState } from "react";
import { signup } from "@/app/actions/auth";
 
export 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="[email protected]" />
      </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 aria-disabled={pending} type="submit">
        {pending ? "提交中..." : "注册"}
      </button>
    </form>
  );
}

需要了解的是: 或者,你可以使用 useFormStatus hook 来显示待处理状态。

3. 创建用户或检查用户凭证

验证表单字段后,你可以通过调用你的身份认证提供商的 API 或数据库来创建新的用户账户或检查用户是否存在。

继续前面的例子:

app/actions/auth.tsx
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: "创建账户时发生错误。",
    };
  }
 
  // TODO:
  // 4. 创建用户会话
  // 5. 重定向用户
}
app/actions/auth.js
export async function signup(state, 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: "创建账户时发生错误。",
    };
  }
 
  // TODO:
  // 4. 创建用户会话
  // 5. 重定向用户
}

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

提示:

  • 上面的示例比较冗长,因为出于教育目的详细拆解了身份验证步骤。这突显出自行实现安全解决方案可能很快变得复杂。考虑使用 Auth 库 来简化这个过程。
  • 为了改善用户体验,你可能希望在注册流程的早期检查重复的电子邮件或用户名。例如,当用户输入用户名或输入框失去焦点时进行检查。这可以帮助避免不必要的表单提交,并为用户提供即时反馈。你可以使用像 use-debounce 这样的库来控制这些检查的频率。

会话管理

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

有两种类型的会话:

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

小贴士: 虽然你可以使用任一方法,或两者都用,但我们建议使用会话管理库,如 iron-sessionJose

无状态会话

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

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

除了上述步骤外,还要考虑添加功能以在用户返回应用程序时 更新 (或刷新) 会话,以及在用户登出时 删除 会话。

小贴士: 检查你的 auth 库 是否包含会话管理功能。

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 包来确保你的会话管理逻辑只在服务器上执行。

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

提示

  • payload 应该包含在后续请求中使用的最少、唯一的用户数据,比如用户 ID、角色等。它不应包含个人身份信息,如电话号码、电子邮件地址、信用卡信息等,也不应包含密码等敏感数据。

要将会话存储在 cookie 中,可以使用 Next.js 的 cookies() API。cookie 应该在服务器端设置,并包含以下推荐选项:

  • HttpOnly:防止客户端 JavaScript 访问 cookie。
  • Secure:使用 https 发送 cookie。
  • SameSite:指定 cookie 是否可以与跨站请求一起发送。
  • Max-Age 或 Expires:在特定时间后删除 cookie。
  • Path:定义 cookie 的 URL 路径。

有关这些选项的更多信息,请参阅 MDN

app/lib/session.ts
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 });
 
  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}
app/lib/session.js
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 });
 
  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}

回到你的服务器操作中,你可以调用 createSession() 函数,并使用 redirect() API 将用户重定向到适当的页面:

app/actions/auth.ts
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");
}
app/actions/auth.js
import { createSession } from "@/app/lib/session";
 
export async function signup(state, formData) {
  // 之前的步骤:
  // 1. 验证表单字段
  // 2. 准备要插入数据库的数据
  // 3. 将用户插入数据库或调用库 API
 
  // 当前步骤:
  // 4. 创建用户会话
  await createSession(user.id);
  // 5. 重定向用户
  redirect("/profile");
}

提示

  • Cookie 应该在服务器端设置以防止客户端篡改。
  • 🎥 观看:了解更多关于 Next.js 中的无状态会话和身份验证 → YouTube (11 分钟)

更新 (或刷新) 会话

你还可以延长会话的过期时间。这对于在用户再次访问应用程序后保持用户登录状态很有用。例如:

app/lib/session.ts
import "server-only";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
 
export async function updateSession() {
  const session = 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);
  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: "lax",
    path: "/",
  });
}
app/lib/session.js
import "server-only";
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
 
export async function updateSession() {
  const session = 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);
  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expires,
    sameSite: "lax",
    path: "/",
  });
}

提示: 检查你的身份验证库是否支持刷新令牌,它可以用来延长用户的会话。

删除会话

要删除会话,你可以删除 cookie:

app/lib/session.ts
import "server-only";
import { cookies } from "next/headers";
 
export function deleteSession() {
  cookies().delete("session");
}
app/lib/session.js
import "server-only";
import { cookies } from "next/headers";
 
export function deleteSession() {
  cookies().delete("session");
}

然后你可以在应用程序中重用 deleteSession() 函数,例如在登出时:

app/actions/auth.ts
import { cookies } from "next/headers";
import { deleteSession } from "@/app/lib/session";
 
export async function logout() {
  deleteSession();
  redirect("/login");
}
app/actions/auth.js
import { cookies } from "next/headers";
import { deleteSession } from "@/app/lib/session";
 
export async function logout() {
  deleteSession();
  redirect("/login");
}

数据库会话

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

  1. 在数据库中创建一个表来存储会话和数据 (或检查你的身份验证库是否处理这个问题)。
  2. 实现插入、更新和删除会话的功能。
  3. 在将会话 ID 存储到用户浏览器之前对其进行加密,并确保数据库和 cookie 保持同步 (这是可选的,但建议用于在 中间件 中进行乐观身份验证检查)。

例如:

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,
    })
    // 返回会话 ID
    .returning({ id: sessions.id });
 
  const sessionId = data[0].id;
 
  // 2. 加密会话 ID
  const session = await encrypt({ sessionId, expiresAt });
 
  // 3. 将会话存储在 cookie 中,用于乐观身份验证检查
  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}
app/lib/session.js
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 中,用于乐观身份验证检查
  cookies().set("session", session, {
    httpOnly: true,
    secure: true,
    expires: expiresAt,
    sameSite: "lax",
    path: "/",
  });
}

提示

  • 为了更快地检索数据,考虑使用像 Vercel Redis 这样的数据库。但是,你也可以将会话数据保存在主数据库中,并合并数据请求以减少查询次数。
  • 你可以选择使用数据库会话来处理更高级的用例,例如跟踪用户上次登录的时间、活跃设备的数量,或者给用户提供从所有设备登出的能力。

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

授权

一旦用户通过身份验证并创建了会话,你就可以实施授权来控制用户可以访问和执行的操作。

主要有两种类型的授权检查:

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

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

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

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

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

然而,由于中间件在每个路由上运行,包括 预取 的路由,所以重要的是只从 cookie 中读取会话 (乐观检查),避免数据库检查以防止性能问题。

例如:

middleware.ts
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 = 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$).*)"],
};
middleware.js
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 = 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$).*)"],
};

虽然中间件对于初步检查很有用,但它不应该是保护数据的唯一防线。大多数安全检查应该尽可能靠近数据源执行,详见 数据访问层 获取更多信息。

提示

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

创建数据访问层 (DAL)

我们建议创建一个 DAL 来集中管理你的数据请求和授权逻辑。

DAL 应包含一个函数,用于在用户与应用程序交互时验证用户的会话。至少,该函数应检查会话是否有效,然后重定向或返回用户信息以进行进一步的请求。

例如,为你的 DAL 创建一个单独的文件,其中包含一个 verifySession() 函数。然后使用 React 的 cache API 在 React 渲染过程中记忆该函数的返回值:

app/lib/dal.ts
import "server-only";
 
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
 
export const verifySession = cache(async () => {
  const cookie = cookies().get("session")?.value;
  const session = await decrypt(cookie);
 
  if (!session?.userId) {
    redirect("/login");
  }
 
  return { isAuth: true, userId: session.userId };
});
app/lib/dal.js
import "server-only";
 
import { cookies } from "next/headers";
import { decrypt } from "@/app/lib/session";
 
export const verifySession = cache(async () => {
  const cookie = cookies().get("session").value;
  const session = await decrypt(cookie);
 
  if (!session.userId) {
    redirect("/login");
  }
 
  return { isAuth: true, userId: session.userId };
});

然后你可以在数据请求、服务器操作和路由处理程序中调用 verifySession() 函数:

app/lib/dal.ts
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("获取用户失败");
    return null;
  }
});
app/lib/dal.js
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("获取用户失败");
    return null;
  }
});

提示

  • DAL 可用于保护在请求时获取的数据。然而,对于在用户之间共享数据的静态路由,数据将在构建时而不是请求时获取。使用 中间件 来保护静态路由。
  • 对于安全检查,你可以通过将会话 ID 与数据库进行比较来检查会话是否有效。使用 React 的 cache 函数可以避免在渲染过程中不必要的重复请求数据库。
  • 你可能希望将相关的数据请求整合到一个 JavaScript 类中,该类在执行任何方法之前运行 verifySession()

使用数据传输对象 (DTO)

在检索数据时,建议你只返回应用程序中将使用的必要数据,而不是整个对象。例如,如果你正在获取用户数据,你可能只返回用户的 ID 和姓名,而不是包含密码、电话号码等的整个用户对象。

然而,如果你无法控制返回的数据结构,或者在团队中希望避免将整个对象传递给客户端,你可以使用一些策略,比如指定哪些字段可以安全地暴露给客户端。

app/lib/dto.ts
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,
  };
}
app/lib/dto.js
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,
  };
}

通过将数据请求和授权逻辑集中在 DAL 中并使用 DTO,你可以确保所有数据请求都是安全和一致的,这使得随着应用程序的扩展,维护、审计和调试变得更加容易。

值得注意的是:

  • 有几种不同的方式可以定义 DTO,从使用 toJSON(),到上面示例中的单独函数,再到 JS 类。由于这些是 JavaScript 模式而不是 React 或 Next.js 的功能,我们建议做一些研究以找到最适合你应用程序的模式。
  • 在我们的 Next.js 中的安全性文章 中了解更多关于安全最佳实践的信息。

服务器组件

服务器组件 中的身份验证检查对于基于角色的访问非常有用。例如,根据用户的角色有条件地渲染组件:

app/dashboard/page.tsx
import { verifySession } from "@/app/lib/dal";
 
export default function Dashboard() {
  const session = await verifySession();
  const userRole = session?.user?.role; // 假设 'role' 是 session 对象的一部分
 
  if (userRole === "admin") {
    return <AdminDashboard />;
  } else if (userRole === "user") {
    return <UserDashboard />;
  } else {
    redirect("/login");
  }
}
app/dashboard/page.jsx
import { verifySession } from '@/app/lib/dal'
 
export default function Dashboard() {
  const session = await verifySession()
  const userRole = session.role // 假设 'role' 是 session 对象的一部分
 
  if (userRole === 'admin') {
    return <AdminDashboard />
  } else if (userRole === 'user') {
    return <UserDashboard />
  } else {
    redirect('/login')
  }
}

在这个例子中,我们使用 DAL 中的 verifySession() 函数来检查 'admin'、'user' 和未授权的角色。这种模式确保每个用户只与适合他们角色的组件交互。

布局和身份验证检查

由于 部分渲染,在 布局 中进行检查时要谨慎,因为这些检查在导航时不会重新渲染,这意味着用户会话不会在每次路由更改时被检查。

相反,你应该在数据源或将被有条件渲染的组件附近进行检查。

例如,考虑一个共享布局,它获取用户数据并在导航栏中显示用户图像。你应该在布局中获取用户数据 (getUser()),并在 DAL 中进行身份验证检查,而不是在布局中进行身份验证检查。

这保证了无论在应用程序中的何处调用 getUser(),都会执行身份验证检查,并防止开发者忘记检查用户是否有权访问数据。

app/layout.tsx
export default async function Layout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getUser();
 
  return (
    // ...
  )
}
app/layout.js
export default async function Layout({ children }) {
  const user = await getUser();
 
  return (
    // ...
  )
}
app/lib/dal.ts
export const getUser = cache(async () => {
  const session = await verifySession();
  if (!session) return null;
 
  // 从 session 中获取用户 ID 并获取数据
});
app/lib/dal.js
export const getUser = cache(async () => {
  const session = await verifySession();
  if (!session) return null;
 
  // 从 session 中获取用户 ID 并获取数据
});

值得注意的是:

  • SPA 中的一种常见模式是在布局或顶级组件中如果用户未经授权就返回 null。这种模式是不推荐的,因为 Next.js 应用程序有多个入口点,这不会阻止嵌套路由段和服务器操作被访问。

服务器操作

服务器操作 应该与面向公众的 API 端点一样考虑安全性,并验证用户是否被允许执行变更。

在下面的例子中,我们在允许操作继续之前检查用户的角色:

app/lib/actions.ts
"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;
  }
 
  // 为授权用户继续执行操作
}
app/lib/actions.js
"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 端点一样考虑安全性,并验证用户是否被允许访问路由处理程序。

例如:

app/api/route.ts
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 });
  }
 
  // 为授权用户继续
}
app/api/route.js
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 在服务器组件中不受支持,使它们只适用于客户端组件。

这种方法是可行的,但任何子服务器组件将首先在服务器上渲染,并且无法访问上下文提供者的会话数据:

app/layout.ts
import { ContextProvider } from "auth-lib";
 
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ContextProvider>{children}</ContextProvider>
      </body>
    </html>
  );
}
app/ui/profile.ts
"use client";
 
import { useSession } from "auth-lib";
 
export default function Profile() {
  const { userId } = useSession();
  const { data } = useSWR(`/api/user/${userId}`, fetcher)
 
  return (
    // ...
  );
}
app/ui/profile.js
"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 中的身份验证,以下是一些与 Next.js 兼容的库和资源,可以帮助你实现安全的身份验证和会话管理:

身份验证库

会话管理库

进一步阅读

要继续学习身份验证和安全性,请查看以下资源: