17.1 客户会话状态
把会话状态编码在客户端,由请求携带回服务器,减轻服务端会话存储。
学习目标
- 能解释客户会话状态如何在客户端保存、随请求往返,并指出服务器仍必须掌握哪些业务权威
- 能修改 TypeScript 会话编码与校验代码,补上签名、过期和错误回退,并保持敏感状态不由客户端决定
- 能根据大小、隐私、扩缩容和重放风险的运行证据,判断订单向导是否应采用或拒绝客户会话状态
为什么 17.1 客户会话状态值得单独学习
订单向导经常需要记住当前步骤、购物车草稿和语言偏好。最直接的做法是把这些信息放进浏览器的 Cookie、隐藏字段或 URL,让每个请求自己带上上下文;服务器因此不必为每个访客维护一份会话表。但“能从请求恢复状态”不等于“请求里的每个字段都可信”。
↡把跨请求的少量会话信息编码在客户端,并由客户端在后续请求中带回服务器的会话状态方案。的真正决策,是划清谁保存什么、谁能作出业务决定,以及完整性、大小、过期和隐私由谁负责。订单金额、库存和权限仍应由服务器或其权威数据源裁决;客户端只适合携带可验证、可丢弃或可重新计算的上下文。
本章以 2024 年中文版公开目录限定 17.1 客户会话状态 的范围,并依据 Martin Fowler 的作者图书页和公开模式目录独立重写。案例、代码、实验、判断题和答案均为本课程原创,不复现原书正文、插图或代码。
先建立直觉:状态跟着请求走
把一次订单向导请求拆成五个问题:状态放在哪里、请求带什么、服务器验证什么、状态何时过期、下一次请求如何得到更新后的版本。若答案只是“把对象序列化到 Cookie”,却没有限制大小和可信范围,方案实际上把安全与恢复责任悄悄交给了浏览器。
客户端携带的通常是↡随请求传输、描述会话上下文的有限序列化数据,例如步骤、草稿键和过期时间。,而不是整个订单。服务器收到载荷后要验证格式、↡附在数据上的校验值,用来证明载荷由持有密钥的一方生成且未被改动。和过期时间,再把价格、库存、权限等权威字段从服务器侧重新查出。签名能发现篡改,却不能把敏感数据变得适合放在客户端,也不能替代权限检查。
另一个边界是↡攻击者把曾经合法的旧请求再次发送,试图重复执行或恢复过期状态的行为。。如果“确认付款”之类的命令只依赖一个长期有效的载荷,攻击者可能重复提交旧请求;因此高风险操作应使用一次性令牌、服务端幂等键或服务器会话,而不是把所有责任压给客户会话状态。
目录单元到教学证据
17.1 客户会话状态
本章精确对应 manifest 单元 poeaa24-pattern-38-client-session-state,学习边界是:用客户端承载小型会话上下文,同时把验证、过期和业务权威留在服务器。订单向导贯穿正文,用同一份状态观察“请求是否携带了不该携带的字段”“节点是否能独立处理请求”“篡改、过期和重放是否有可解释结果”。
完成本单元要交出三样证据:一张标明客户端与服务器责任的图;一段可测试的 TypeScript 编解码与校验草图;一个故障样本,证明载荷过大、签名失效或重复提交时系统会在明确边界拒绝请求。只展示最终页面而说不出哪一方拥有金额和权限,就没有完成模式级判断。
专属案例:带购物车草稿的订单向导
假设访客在订单向导中选择商品、填写配送偏好并逐步前进。适合放在客户端的是 step、短期 cartDraftId 和语言偏好;不适合放在客户端自行决定的是商品单价、库存、折扣资格和“是否已经付款”。请求可携带这份小型草稿,但服务器必须重新读取权威数据,并在更新时发回一份新的、有限期限的载荷。
评审卡可以这样记录:单元键为 poeaa24-pattern-38-client-session-state;模式族为 session;采用条件是“状态小、可验证、可过期且不承载不可替代的业务权威”;观测项包括载荷字节数、签名失败率、过期率、节点独立处理率和重复请求率;拒绝条件是“客户端开始决定金额、权限或不可逆操作”。
专属可视化实验:看状态如何越过请求边界
主图把同一个订单向导载荷拆成三个阶段:先在客户端形成有限状态,再由服务器验证并重建权威上下文,最后返回新的签名载荷。先预测:如果用户把 role=user 改成 role=admin,哪一步应拒绝?如果服务器节点完全不保存会话,为什么任意节点仍能处理下一次请求?点击阶段并注入篡改,再观察可信边界如何变化。
三个阶段快照
下面的 Stepper 把主图拆成三个确定性快照。每一步都保留客户端载荷、验证责任和服务器权威,读者可以先写下预测,再用图核对。
1. 客户端形成有限载荷
先预测:客户端可以保存哪些字段?它只形成短小草稿和过期时间,并携带签名结果;价格、库存和权限不应由这份载荷自行决定。
代码实践:让载荷可验证、可过期
下面的 TypeScript 草图把“客户端承载”限制在编码层,把“业务权威”留在服务器侧。真实项目应使用成熟的签名库和安全密钥管理;这里的 sign、verify、encode 和 decode 是可替换、可测试的边界。
type ClientSession = {
cartDraftId: string;
step: 1 | 2 | 3;
locale: "zh-CN" | "en-US";
expiresAt: number;
};
type SignedSession = {
payload: string;
signature: string;
};
function issueSession(
input: Omit<ClientSession, "expiresAt">,
now: number,
sign: (payload: string) => string,
): SignedSession {
const session: ClientSession = {
...input,
expiresAt: now + 15 * 60_000,
};
const payload = encodeBase64Url(JSON.stringify(session));
return { payload, signature: sign(payload) };
}
function readSession(
token: SignedSession,
now: number,
verify: (payload: string, signature: string) => boolean,
):
| { ok: true; session: ClientSession }
| { ok: false; reason: "tampered" | "expired" | "invalid" } {
if (!verify(token.payload, token.signature)) {
return { ok: false, reason: "tampered" };
}
const session = decodeClientSession(token.payload);
if (!session) return { ok: false, reason: "invalid" };
if (session.expiresAt <= now) return { ok: false, reason: "expired" };
return { ok: true, session };
}
function addItem(
token: SignedSession,
productId: string,
now: number,
): { status: "ok" | "rejected"; token?: SignedSession } {
const result = readSession(token, now, verifySignature);
if (!result.ok) return { status: "rejected" };
// 价格与库存从服务器权威数据源读取,不接受客户端传来的金额。
const product = catalog.find(productId);
if (!product || !product.inStock) return { status: "rejected" };
return {
status: "ok",
token: issueSession(
{ ...result.session, step: 2 },
now,
signWithServerKey,
),
};
}这里有四个可验收的代码边界:签名覆盖完整载荷;过期检查发生在业务逻辑之前;解码失败不会进入默认状态;商品价格和库存从服务器查询。把 price 加进 ClientSession 并直接使用,或让解码失败时返回 { step: 1 },都应被测试标成拒绝样本。
容量、隐私与替代方案
客户端方案把存储压力从服务器转移到每次请求的带宽和浏览器限制。载荷越大,Cookie 头越容易触及大小上限,代理和日志也可能复制这份数据;应记录字节数分布,而不是只看平均值。即使内容经过加密,也要把“客户端可看到并可丢失”当作基本假设,不要把身份证号、长期秘密或完整订单放进去。
它适合小型、可重新计算、短期且能验证的上下文,并且多节点处理请求时不需要粘性会话。它不适合长流程草稿、强隐私数据、需要服务端撤销的权限,以及确认付款这种不可逆操作。服务器会话状态或数据库会话状态可以更好地承担撤销、审计和大对象存储,但要额外面对共享存储、失效清理和多节点故障转移。
| 评审问题 | 采用客户会话状态的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 权威 | 服务器重查金额、库存和权限 | 直接相信请求中的价格、角色或付款状态 |
| 完整性 | 签名、密钥轮换和失败指标清晰 | 只做 Base64 编码,或签名失败仍继续执行 |
| 生命周期 | 有过期、刷新、失效和恢复路径 | 永不过期,或只能靠清空浏览器恢复 |
| 规模 | 载荷有字节预算,节点无需共享会话表 | 载荷接近头部上限,或数据含大量敏感字段 |
| 重复执行 | 高风险命令有幂等键或一次性令牌 | 重发旧请求会重复付款、发货或授予权限 |
常见误区
本章小结
- 客户会话状态适合承载小型、短期、可验证的上下文,不等于把业务权威交给浏览器。
- 会话载荷要有签名、过期和大小预算;服务器必须重新决定金额、库存、权限和不可逆操作。
- 多节点扩展是收益之一,但重放、日志泄露、Cookie 上限和密钥轮换是必须观测的代价。
- 当状态敏感、对象很大、需要服务端撤销或命令不可重复时,应拒绝客户端承载,改用服务器或数据库会话状态。
可验证练习
练习
问题 1:修复校验顺序。 readSession 已验证签名,但代码在检查过期时间前就调用了 checkout。请说明应把哪个判断移到业务调用之前,以及为什么。
问题 2:找出不该信任的字段。 客户端提交 { cartDraftId, step, price, role }。请指出至少两个不能直接使用的字段,并写出服务器应如何替代它们。
问题 3:选择或拒绝方案。 一个结算流程平均会产生 12KB 的载荷,包含收货人电话和折扣资格,而且“确认付款”可以被重试。请给出三个拒绝信号,并提出更合适的拆分方向。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 客户会话状态
- 把少量跨请求信息放在客户端,下一次请求再带回来;服务器仍要验证并决定真正的业务结果。
- 会话载荷
- 请求里携带的那小段会话数据,例如当前步骤、草稿键和过期时间。
- 签名
- 由服务器密钥生成的校验值;数据被改过时,服务器可以发现它不再匹配。
- 重放攻击
- 把以前合法的旧请求重新发送,试图重复执行操作或恢复过期状态。
- 可信边界
- 系统愿意相信某个输入到什么程度,以及超过边界时由谁验证、拒绝和记录的界线。
前后导航
参考资料
- Martin Fowler 作者图书页:核对全书主题、章节范围和模式参考结构。
- Martin Fowler 模式目录:核对客户会话状态的公开模式定位与模式族。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。