会话和 Cookies
本页目录

会话和 Cookies

会话

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

当使用 React Router 作为框架时,会话是在每个路由的基础上管理的(而不是像 express 中间件那样),在你的 loader 和 action 方法中使用“会话存储”对象(它实现了 SessionStorage 接口)。会话存储了解如何解析和生成 cookies,以及如何在数据库或文件系统中存储会话数据。

使用会话

这是一个 cookie 会话存储的示例

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 中设置你的会话存储对象,以便所有需要访问会话数据的路由都可以从同一个位置导入。

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

你将使用方法在你的 loader 和 action 函数中访问会话。

在使用 getSession 检索会话后,返回的会话对象有许多方法和属性

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

请参阅 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 中注销(或执行任何更改)。否则,你将使用户面临跨站请求伪造攻击。

会话注意事项

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

创建自定义会话存储

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

  • 当 cookie 中不存在会话 ID 时,createData 将在初始会话创建时从 commitSession 调用
  • 当 cookie 中存在会话 ID 时,readData 将从 getSession 调用
  • 当 cookie 中已存在会话 ID 时,updateData 将从 commitSession 调用
  • deleteData 从 destroySession 调用

以下示例展示了如何使用通用数据库客户端来做到这一点

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",
    },
  });

createData 和 updateData 的 expires 参数与 cookie 本身过期且不再有效的 Date 相同。你可以使用此信息从数据库中自动清除会话记录以节省空间,或确保你不会为旧的、过期的 cookies 返回任何数据。

其他会话实用工具

如果你需要,还有其他几个会话实用工具可用

Cookies

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

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

使用 cookies

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

在 React Router 中,你通常会在你的 loader 和/或 action 函数中使用 cookies,因为这些是你需要读取和写入数据的地方。

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

首先,创建一个 cookie

import { createCookie } from "react-router";

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

然后,你可以导入 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>
  );
}

Cookies 有几个属性,用于控制它们的过期时间、访问方式和发送位置。这些属性中的任何一个都可以在 createCookie(name, options) 中指定,或者在生成 Set-Cookie 标头时在 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 进行签名,以便在收到 cookie 时自动验证其内容。由于伪造 HTTP 标头相对容易,因此对于任何你不希望别人能够伪造的信息(如身份验证信息(请参阅会话))来说,这是一个好主意。

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

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

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

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

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