SPA guest checkout 如何识别同一匿名用户 session
结论
不要让 SPA 使用固定的 username/password 创建 token。更安全、更通用的方案是:
- 用户首次进入结账流程时,由服务端创建一个随机且不可预测的 guest session。
- 服务端通过带有
HttpOnly和Secure属性的 Cookie,将 session 标识交给浏览器。 - 浏览器在后续 API 请求中自动携带该 Cookie。
- 每个 API 都根据 session 在服务端查找对应的 guest checkout,并检查资源归属。
这个标识可以是传统的随机 session ID,也可以是服务端签发的短期 token。对于 guest checkout,不透明的 session ID 通常更简单。浏览器只保存随机标识,姓名、地址和订单状态等数据仍保存在服务端。
推荐的数据模型
不要只创建一个笼统的“匿名用户”。结账场景更适合将 guest session 与 checkout 分开:
guest_sessions
- id
- token_hash
- created_at
- expires_at
- revoked_at
checkouts
- id
- guest_session_id
- status
- name
- address
- other_details
- expires_at
guest_sessions.token_hash 保存 session ID 的哈希值,而不是 Cookie 中的原始值。即使数据库内容泄露,攻击者也不能直接使用数据库里的值冒充该 session。
每次操作 checkout 时,都必须检查:
checkout.guest_session_id == current_session.id
客户端提交 checkoutId,并不代表它有权修改对应的 checkout。
建立 guest session
服务端可以在用户首次访问结账 API 时自动创建 session,也可以提供明确的初始化接口,例如:
POST /api/guest-sessions
下面以 Node.js 和 Express 为例。代码省略了数据库实现,但保留了主要的安全逻辑:
import crypto from "node:crypto";
import express from "express";
import cookieParser from "cookie-parser";
const app = express();
app.use(express.json());
app.use(cookieParser());
function hashToken(token) {
return crypto
.createHash("sha256")
.update(token, "utf8")
.digest("hex");
}
app.post("/api/guest-sessions", async (req, res) => {
const sessionToken = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(sessionToken);
const session = await db.guestSessions.create({
tokenHash,
expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000)
});
res.cookie("guest_session", sessionToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/api",
maxAge: 2 * 60 * 60 * 1000
});
res.status(201).json({
sessionExpiresAt: session.expiresAt
});
});
服务端使用密码学安全的随机数生成 session ID,不需要用户名和密码。HttpOnly 可阻止前端 JavaScript 读取 Cookie,降低 token 被 XSS 直接窃取的风险。
生产环境只能通过 HTTPS 使用 Secure Cookie。
在不同 API 中识别同一个 guest
可以编写统一的认证中间件,从 Cookie 读取 session ID,再到服务端存储中查询:
async function requireGuestSession(req, res, next) {
const sessionToken = req.cookies.guest_session;
if (!sessionToken) {
return res.status(401).json({
error: "Guest session is missing"
});
}
const session = await db.guestSessions.findByTokenHash(
hashToken(sessionToken)
);
if (
!session ||
session.revokedAt ||
session.expiresAt.getTime() <= Date.now()
) {
return res.status(401).json({
error: "Guest session is invalid or expired"
});
}
req.guestSession = session;
next();
}
创建 checkout 时,归属关系由服务端写入,不接受客户端指定的 guestSessionId:
app.post(
"/api/checkouts",
requireGuestSession,
async (req, res) => {
const checkout = await db.checkouts.create({
guestSessionId: req.guestSession.id,
name: req.body.name,
address: req.body.address,
status: "pending"
});
res.status(201).json({
checkoutId: checkout.id
});
}
);
更新其他结账信息时,查询条件必须同时包含 checkout ID 和当前 session:
app.patch(
"/api/checkouts/:checkoutId/details",
requireGuestSession,
async (req, res) => {
const checkout = await db.checkouts.findOne({
id: req.params.checkoutId,
guestSessionId: req.guestSession.id
});
if (!checkout) {
return res.status(404).json({
error: "Checkout not found"
});
}
const updatedCheckout = await db.checkouts.update(checkout.id, {
otherDetails: req.body.otherDetails
});
res.json({
checkoutId: updatedCheckout.id,
status: updatedCheckout.status
});
}
);
这样,即使有人猜到或获得了另一个 checkout 的 ID,也无法通过自己的 guest session 修改它。
SPA 如何发送请求
如果 SPA 和 API 位于同一个站点,浏览器通常会自动携带匹配的 Cookie。也可以显式设置:
await fetch("/api/checkouts/123/details", {
method: "PATCH",
credentials: "same-origin",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
otherDetails: {
deliveryNote: "Leave at reception"
}
})
});
如果 SPA 与 API 跨域,例如:
https://shop.example.com
https://api.example.net
客户端需要使用:
await fetch("https://api.example.net/api/checkouts/123/details", {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
otherDetails: {
deliveryNote: "Leave at reception"
}
})
});
服务端也必须返回精确的 CORS 来源,并允许携带凭据:
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Credentials: true
请求携带凭据时,不能使用:
Access-Control-Allow-Origin: *
跨站 Cookie 通常还需要以下设置:
{
httpOnly: true,
secure: true,
sameSite: "none"
}
不过,跨站 Cookie 会受到浏览器隐私策略的影响。条件允许时,最好将 SPA 和 API 部署在同一站点下,或通过反向代理把 API 暴露为 /api。
Cookie 方案必须考虑 CSRF
HttpOnly 能阻止 JavaScript 读取 Cookie,但浏览器仍可能在跨站请求中自动发送 Cookie,因此还要防范 CSRF。
可以组合使用以下措施:
- 优先设置
SameSite=Lax,业务允许时也可使用SameSite=Strict。 - 校验
Origin,必要时同时校验Referer。 - 为创建、修改和提交订单等请求配置 CSRF token。
- 不使用
GET请求修改服务器状态。 - CORS 只允许明确、可信的来源。
CSRF token 不是身份凭证。页面可以读取它并将其放入请求头,而真正的 guest session Cookie 仍保持 HttpOnly。
例如:
await fetch("/api/checkouts/123/submit", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({})
});
是否必须使用 JWT
不必须。JWT 只是 token 的一种格式,本身不能解决 guest 身份识别问题。
对于 guest checkout,不透明的 session ID 往往更合适:
- 可以立即撤销。
- 服务端容易控制过期时间。
- Cookie 不会暴露业务数据。
- guest 状态本来就需要保存在数据库或缓存中。
- 无需额外处理 JWT 的状态更新、吊销和密钥轮换。
如果系统已有成熟的身份服务,也可以让该服务签发短期 anonymous access token。不过,这类 token 仍须由可信的服务端签发,并包含随机身份、明确的 audience、较短的过期时间和受限权限。不要在 SPA 中保存固定的 client secret,因为公开 SPA 无法可靠地保守 secret。
其他注意事项
- session ID 应具有足够的随机强度,例如使用
crypto.randomBytes(32),不要使用自增 ID、时间戳或普通随机函数。 - guest session 和未完成的 checkout 都应设置过期时间,并由后台任务清理。
- 支付成功、订单取消或超时后,应撤销 session,或禁止继续修改 checkout。
- 不要把姓名、地址、金额或权限信息直接写入可由客户端修改的 Cookie。
- 金额、折扣、库存、运费和订单最终状态都必须由服务端计算或验证。
- 为创建 checkout、应用优惠和提交订单等接口增加限流和幂等控制。
- 防止 session fixation。服务端必须创建新的随机 session,不能直接采用客户端提供的 session ID。
- XSS 仍可能借助用户的浏览器发起请求,因此需要正确转义输出,并配置合适的 Content Security Policy。
- 如果需要将 guest 升级为注册用户,应在登录成功后重新生成 session,再由服务端安全地迁移或绑定 checkout。
客户端可以持有服务端颁发的随机标识,但不能决定该标识代表谁、拥有哪些资源或具有什么权限。所有 session 验证和 checkout 归属判断都必须在服务端完成。
备注:内容仅供参考。
