会话和 Cookie
本页内容

Sessions(会话)与 Cookies

Sessions(会话)

Session 是网站的重要组成部分,它允许服务器识别来自同一用户的请求,尤其是在涉及服务器端表单验证或页面上没有 JavaScript 时。Session 是许多允许用户“登录”的网站(包括社交、电商、商业和教育网站)的基本组成部分。

在使用 React Router 作为你的框架时,session 是在你的 loaderaction 方法中基于路由(而不是像 express 中间件那样)进行管理的,通过一个“session 存储”对象(实现了 SessionStorage 接口)。Session 存储知道如何解析和生成 cookie,以及如何将 session 数据存储在数据库或文件系统中。

使用 Sessions

这是一个 cookie session 存储的示例

import { createCookieSessionStorage } from "react-router";

type SessionData = {
  userId: string;
};

type SessionFlashData = {
  error: string;
};

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage<SessionData, SessionFlashData>(
    {
      // a Cookie from `createCookie` or the CookieOptions to create one
      cookie: {
        name: "__session",

        // all of these are optional
        domain: "reactrouter.com",
        // Expires can also be set (although maxAge overrides it when used in combination).
        // Note that this method is NOT recommended as `new Date` creates only one date on each server deployment, not a dynamic date in the future!
        //
        // expires: new Date(Date.now() + 60_000),
        httpOnly: true,
        maxAge: 60,
        path: "/",
        sameSite: "lax",
        secrets: ["s3cret1"],
        secure: true,
      },
    }
  );

export { getSession, commitSession, destroySession };

我们建议在 app/sessions.server.ts 中设置 session 存储对象,这样所有需要访问 session 数据的路由都可以从同一个位置导入。

session 存储对象的输入/输出是 HTTP cookies。getSession() 从传入请求的 Cookie header 中检索当前 session,而 commitSession()/destroySession() 为传出响应提供 Set-Cookie header。

你将使用方法在你的 loaderaction 函数中访问 session。

使用 getSession 检索 session 后,返回的 session 对象具有一些方法和属性

export async function action({
  request,
}: ActionFunctionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  session.get("foo");
  session.has("bar");
  // etc.
}

有关 session 对象上所有可用方法的详细信息,请参阅 Session API

登录表单示例

登录表单可能看起来像这样

import { data, redirect } from "react-router";
import type { Route } from "./+types/login";

import {
  getSession,
  commitSession,
} from "../sessions.server";

export async function loader({
  request,
}: Route.LoaderArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );

  if (session.has("userId")) {
    // Redirect to the home page if they are already signed in.
    return redirect("/");
  }

  return data(
    { error: session.get("error") },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
}

export async function action({
  request,
}: Route.ActionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const form = await request.formData();
  const username = form.get("username");
  const password = form.get("password");

  const userId = await validateCredentials(
    username,
    password
  );

  if (userId == null) {
    session.flash("error", "Invalid username/password");

    // Redirect back to the login page with errors.
    return redirect("/login", {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    });
  }

  session.set("userId", userId);

  // Login succeeded, send them to the home page.
  return redirect("/", {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}

export default function Login({
  loaderData,
}: Route.ComponentProps) {
  const { error } = loaderData;

  return (
    <div>
      {error ? <div className="error">{error}</div> : null}
      <form method="POST">
        <div>
          <p>Please sign in</p>
        </div>
        <label>
          Username: <input type="text" name="username" />
        </label>
        <label>
          Password:{" "}
          <input type="password" name="password" />
        </label>
      </form>
    </div>
  );
}

然后注销表单可能看起来像这样

import {
  getSession,
  destroySession,
} from "../sessions.server";
import type { Route } from "./+types/logout";

export async function action({
  request,
}: Route.ActionArgs) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  return redirect("/login", {
    headers: {
      "Set-Cookie": await destroySession(session),
    },
  });
}

export default function LogoutRoute() {
  return (
    <>
      <p>Are you sure you want to log out?</p>
      <Form method="post">
        <button>Logout</button>
      </Form>
      <Link to="/">Never mind</Link>
    </>
  );
}

重要的是,你需要在 action 中执行注销(或任何其他修改),而不是在 loader 中。否则,你会让你的用户面临 跨站请求伪造 攻击。

Session 注意事项

由于嵌套路由,可能会调用多个 loader 来构建一个页面。在使用 session.flash()session.unset() 时,你需要确保请求中的其他 loader 不会读取它,否则你会遇到竞态条件。通常,如果你使用 flash,你会希望只有一个 loader 读取它;如果另一个 loader 需要 flash 消息,请为该 loader 使用不同的键。

创建自定义 session 存储

如果需要,React Router 可以轻松地将 session 存储在你自己的数据库中。createSessionStorage() API 需要一个 cookie(有关创建 cookie 的选项,请参阅 cookies)和一组用于管理 session 数据的创建、读取、更新和删除 (CRUD) 方法。cookie 用于持久化 session ID。

  • 当 cookie 中不存在 session ID 并且首次创建 session 时,将调用 createDatacommitSession 中执行
  • 当 cookie 中存在 session ID 时,将调用 readDatagetSession 中执行
  • 当 cookie 中已存在 session ID 时,将调用 updateDatacommitSession 中执行
  • 当调用 destroySession 时,会调用 deleteData

以下示例展示了如何使用通用数据库客户端来实现

import { createSessionStorage } from "react-router";

function createDatabaseSessionStorage({
  cookie,
  host,
  port,
}) {
  // Configure your database client...
  const db = createDatabaseClient(host, port);

  return createSessionStorage({
    cookie,
    async createData(data, expires) {
      // `expires` is a Date after which the data should be considered
      // invalid. You could use it to invalidate the data somehow or
      // automatically purge this record from your database.
      const id = await db.insert(data);
      return id;
    },
    async readData(id) {
      return (await db.select(id)) || null;
    },
    async updateData(id, data, expires) {
      await db.update(id, data);
    },
    async deleteData(id) {
      await db.delete(id);
    },
  });
}

然后你可以像这样使用它

const { getSession, commitSession, destroySession } =
  createDatabaseSessionStorage({
    host: "localhost",
    port: 1234,
    cookie: {
      name: "__session",
      sameSite: "lax",
    },
  });

传递给 createDataupdateDataexpires 参数是 cookie 本身过期并失效的同一个 Date。你可以利用此信息自动从数据库中清除 session 记录以节省空间,或确保你不会返回任何旧的、过期的 cookie 的数据。

其他 session 工具函数

如果需要,还有一些其他的 session 工具函数可用

Cookies

一个 cookie 是服务器在 HTTP 响应中发送给用户的一小块信息,用户的浏览器会在后续请求中将其发送回服务器。这项技术是许多交互式网站的基本组成部分,它添加了状态,以便你可以构建身份验证(参见 sessions)、购物车、用户偏好设置以及许多其他需要记住“谁已登录”的功能。

React Router 的 Cookie 接口 提供了一个逻辑的、可重用的 cookie 元数据容器。

使用 cookies

虽然你可以手动创建这些 cookie,但更常见的是使用 session 存储

在 React Router 中,你通常会在 loader 和/或 action 函数中处理 cookie,因为这些是你需要读写数据的地方。

假设你的电商网站上有一个横幅,提示用户查看当前正在促销的商品。该横幅横跨主页顶部,并在侧面包含一个按钮,允许用户关闭该横幅,以便至少在一周内不再看到它。

首先,创建一个 cookie

import { createCookie } from "react-router";

export const userPrefs = createCookie("user-prefs", {
  maxAge: 604_800, // one week
});

然后,你可以 import 这个 cookie 并在你的 loader 和/或 action 中使用它。在这种情况下,loader 只检查用户偏好设置的值,以便你可以在组件中使用它来决定是否渲染该横幅。当点击按钮时,<form> 会在服务器上调用 action 并重新加载页面,此时横幅已消失。

用户偏好设置示例

import { Link, Form, redirect } from "react-router";
import type { Route } from "./+types/home";

import { userPrefs } from "../cookies.server";

export async function loader({
  request,
}: Route.LoaderArgs) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie =
    (await userPrefs.parse(cookieHeader)) || {};
  return { showBanner: cookie.showBanner };
}

export async function action({
  request,
}: Route.ActionArgs) {
  const cookieHeader = request.headers.get("Cookie");
  const cookie =
    (await userPrefs.parse(cookieHeader)) || {};
  const bodyParams = await request.formData();

  if (bodyParams.get("bannerVisibility") === "hidden") {
    cookie.showBanner = false;
  }

  return redirect("/", {
    headers: {
      "Set-Cookie": await userPrefs.serialize(cookie),
    },
  });
}

export default function Home({
  loaderData,
}: Route.ComponentProps) {
  return (
    <div>
      {loaderData.showBanner ? (
        <div>
          <Link to="/sale">Don't miss our sale!</Link>
          <Form method="post">
            <input
              type="hidden"
              name="bannerVisibility"
              value="hidden"
            />
            <button type="submit">Hide</button>
          </Form>
        </div>
      ) : null}
      <h1>Welcome!</h1>
    </div>
  );
}

Cookie 有 几个属性 控制它们的过期时间、访问方式以及发送位置。这些属性中的任何一个都可以在 createCookie(name, options) 中指定,或者在生成 Set-Cookie header 时通过 serialize() 指定。

const cookie = createCookie("user-prefs", {
  // These are defaults for this cookie.
  path: "/",
  sameSite: "lax",
  httpOnly: true,
  secure: true,
  expires: new Date(Date.now() + 60_000),
  maxAge: 60,
});

// You can either use the defaults:
cookie.serialize(userPrefs);

// Or override individual ones as needed:
cookie.serialize(userPrefs, { sameSite: "strict" });

请阅读 有关这些属性的更多信息,以便更好地理解它们的作用。

签名 cookies

可以对 cookie 进行签名,以便在接收到时自动验证其内容。由于伪造 HTTP header 相对容易,对于任何你不希望被伪造的信息(例如身份验证信息,参见 sessions),这是一个好主意。

要对 cookie 进行签名,请在首次创建 cookie 时提供一个或多个 secrets

const cookie = createCookie("user-prefs", {
  secrets: ["s3cret1"],
});

具有一个或多个 secrets 的 cookie 将以确保 cookie 完整性的方式存储和验证。

可以通过向 secrets 数组的前面添加新的 secrets 来轮换 secrets。使用旧 secrets 签名的 cookie 在 cookie.parse() 中仍将成功解码,而最新的 secret(数组中的第一个)将始终用于签名在 cookie.serialize() 中创建的传出 cookie。

export const cookie = createCookie("user-prefs", {
  secrets: ["n3wsecr3t", "olds3cret"],
});
import { data } from "react-router";
import { cookie } from "../cookies.server";
import type { Route } from "./+types/my-route";

export async function loader({
  request,
}: Route.LoaderArgs) {
  const oldCookie = request.headers.get("Cookie");
  // oldCookie may have been signed with "olds3cret", but still parses ok
  const value = await cookie.parse(oldCookie);

  return data("...", {
    headers: {
      // Set-Cookie is signed with "n3wsecr3t"
      "Set-Cookie": await cookie.serialize(value),
    },
  });
}

如果需要,还有一些其他的 cookie 工具函数可用

要了解每个属性的更多信息,请参阅 MDN Set-Cookie 文档

文档和示例 CC 4.0