Session 是网站的重要组成部分,它允许服务器识别来自同一用户的请求,尤其是在涉及服务器端表单验证或页面上没有 JavaScript 时。Session 是许多允许用户“登录”的网站(包括社交、电商、商业和教育网站)的基本组成部分。
在使用 React Router 作为你的框架时,session 是在你的 loader
和 action
方法中基于路由(而不是像 express 中间件那样)进行管理的,通过一个“session 存储”对象(实现了 SessionStorage
接口)。Session 存储知道如何解析和生成 cookie,以及如何将 session 数据存储在数据库或文件系统中。
这是一个 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。
你将使用方法在你的 loader
和 action
函数中访问 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
中。否则,你会让你的用户面临 跨站请求伪造 攻击。
由于嵌套路由,可能会调用多个 loader 来构建一个页面。在使用 session.flash()
或 session.unset()
时,你需要确保请求中的其他 loader 不会读取它,否则你会遇到竞态条件。通常,如果你使用 flash,你会希望只有一个 loader 读取它;如果另一个 loader 需要 flash 消息,请为该 loader 使用不同的键。
如果需要,React Router 可以轻松地将 session 存储在你自己的数据库中。createSessionStorage()
API 需要一个 cookie
(有关创建 cookie 的选项,请参阅 cookies)和一组用于管理 session 数据的创建、读取、更新和删除 (CRUD) 方法。cookie 用于持久化 session ID。
createData
从 commitSession
中执行readData
从 getSession
中执行updateData
从 commitSession
中执行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",
},
});
传递给 createData
和 updateData
的 expires
参数是 cookie 本身过期并失效的同一个 Date
。你可以利用此信息自动从数据库中清除 session 记录以节省空间,或确保你不会返回任何旧的、过期的 cookie 的数据。
如果需要,还有一些其他的 session 工具函数可用
isSession
createMemorySessionStorage
createSession
(自定义存储)createFileSessionStorage
(Node)createWorkersKVSessionStorage
(Cloudflare Workers)createArcTableSessionStorage
(Architect, Amazon DynamoDB)一个 cookie 是服务器在 HTTP 响应中发送给用户的一小块信息,用户的浏览器会在后续请求中将其发送回服务器。这项技术是许多交互式网站的基本组成部分,它添加了状态,以便你可以构建身份验证(参见 sessions)、购物车、用户偏好设置以及许多其他需要记住“谁已登录”的功能。
React Router 的 Cookie
接口 提供了一个逻辑的、可重用的 cookie 元数据容器。
虽然你可以手动创建这些 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" });
请阅读 有关这些属性的更多信息,以便更好地理解它们的作用。
可以对 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 文档。