# 登录与会话

## 本章解决什么问题

前端知道如何保存登录态和跳转登录页，但服务端必须回答更具体的问题：怎样保存密码，怎样证明请求属于某个人，令牌泄漏后能否失效，退出是否真的撤销访问能力。本章目标是实现可验证的密码哈希与会话生命周期核心，理解浏览器 Cookie 的传输边界。前置是 HTTP、错误合同和数据存储；本章不要求自己实现 OAuth 协议。

把“用户已登录”拆成两步。认证是在某个时刻验证凭据，成功后创建会话；后续请求携带一个随机会话标识，服务器检查它是否存在、过期或撤销。Vue store 中的 user 对象只是界面状态，不能当成服务器身份依据。浏览器修改 localStorage 中的 role，不应改变后台任何权限。

## 密码、令牌和 Cookie 各自是什么

密码通常由人选择，熵低且可能重复；需要使用带随机盐、可调成本的密码哈希，增加离线猜测成本。普通 SHA-256 太快，不适合直接保存密码。本章使用 Node 自带的 scrypt，明确设置参数；实际产品也可选择维护良好的 Argon2id 实现，并跟随安全建议调整成本。[OWASP 密码存储说明](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) 给出了算法与参数建议。

会话令牌由密码学安全随机数生成，应该具有足够不可预测性。服务器可只保存令牌的 SHA-256 摘要：这里适合快哈希，因为令牌是高熵随机值，而不是人类密码。数据库泄漏时，原始令牌不应可以直接从会话表读出。密码哈希的盐并非秘密，参数与盐可以和哈希一起保存，便于未来迁移。

Cookie 是浏览器自动附带数据的机制，不是加密算法。HttpOnly 阻止网页 JavaScript 直接读取 Cookie；Secure 要求安全传输；SameSite 限制某些跨站请求中的附带行为；Path 约束发送路径。它们降低风险，但不能代替 HTTPS、XSS 防护、CSRF 验证和权限判断。`__Host-` 前缀要求 Secure、Path=/ 且不能设置 Domain，适合绑定单一主机的会话。

## 核心 API 合同

`randomBytes(size)` 返回指定字节数的随机 Buffer；本例用三十二字节生成会话值并编码为 base64url。`scrypt(password, salt, keylen, options, callback)` 通过回调返回派生密钥，可用 promisify 转成 Promise。N、r、p 控制成本，maxmem 限制内存；参数不合规或内存限制过低会抛错或拒绝。示例采用 N=131072、r=8、p=1，并给出足够 maxmem，不能直接套默认内存上限。

`timingSafeEqual(a, b)` 比较等长字节数组，长度不同会报错；它只保护这一步比较，不自动让整个登录流程具有相同时间。未知账号也进行一次预设哈希验证，有助于避免非常明显的用户名枚举差异，但仍需要限速、统一错误和监控。[Node Crypto](https://nodejs.org/docs/latest-v22.x/api/crypto.html) 是参数与返回值的正式说明。

## 完整示例：认证核心与会话失效

环境：Node 22.22 或 24，无依赖；由于 scrypt 参数包含内存成本，运行需留有足够内存。保存为 `auth-session.mjs`，执行 `node auth-session.mjs`。这是一份本地核心逻辑自检，不启动 HTTP 服务，不声称已经验证浏览器 Cookie 行为。示例密码只属于练习用户，不能作为部署默认账号。

```js auth-session.mjs
// auth-session.mjs
import { randomBytes, scrypt, timingSafeEqual, createHash } from 'node:crypto';
import { promisify } from 'node:util';
import assert from 'node:assert/strict';

const derive = promisify(scrypt);
const policy = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const TTL_MS = 15 * 60 * 1000;
const sessions = new Map(); // 教学存储；生产需要共享且可清理的会话存储。

async function hashPassword(password) {
  const salt = randomBytes(16);
  const hash = await derive(password, salt, 64, policy);
  return `scrypt$131072$8$1$${salt.toString('hex')}$${hash.toString('hex')}`;
}

async function verifyPassword(password, stored) {
  if (typeof password !== 'string' || Buffer.byteLength(password) > 128) return false;
  const [algorithm, n, r, p, saltHex, hashHex, extra] = stored.split('$');
  // 只接受当前支持的参数，避免坏记录触发无限制资源消耗。
  if (algorithm !== 'scrypt' || n !== '131072' || r !== '8' || p !== '1' || extra !== undefined
    || !/^[a-f0-9]{32}$/.test(saltHex ?? '')
    || !/^[a-f0-9]{128}$/.test(hashHex ?? '')) return false;
  const actual = await derive(password, Buffer.from(saltHex, 'hex'), 64, policy);
  return timingSafeEqual(actual, Buffer.from(hashHex, 'hex'));
}

function tokenDigest(token) {
  return createHash('sha256').update(token).digest('hex');
}

function createSession(userId, now = Date.now()) {
  const token = randomBytes(32).toString('base64url');
  const csrfToken = randomBytes(32).toString('base64url');
  sessions.set(tokenDigest(token), { userId, csrfToken, expiresAt: now + TTL_MS });
  const cookie = `__Host-session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=900`;
  return { token, csrfToken, cookie };
}

function readSession(token, now = Date.now()) {
  if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
  const digest = tokenDigest(token);
  const session = sessions.get(digest);
  if (!session) return null;
  if (session.expiresAt <= now) {
    sessions.delete(digest);
    return null;
  }
  return session;
}

function logout(token) {
  if (typeof token === 'string') sessions.delete(tokenDigest(token));
  return '__Host-session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0';
}

const user = {
  id: 'user-1', email: 'learner@example.test',
  passwordHash: await hashPassword('Lesson-only-password-42!'),
};
const dummyHash = await hashPassword('Unknown-user-dummy-password');

async function login(email, password, previousToken) {
  const found = email === user.email ? user : null;
  const valid = await verifyPassword(password, found?.passwordHash ?? dummyHash);
  if (!found || !valid) throw Object.assign(new Error('账号或密码错误'), { code: 'AUTH_FAILED' });
  // 认证成功后创建全新会话，避免沿用登录前不可信的会话标识。
  if (previousToken) logout(previousToken);
  return createSession(found.id);
}

const issued = await login(user.email, 'Lesson-only-password-42!');
assert.equal(readSession(issued.token).userId, user.id);
assert.ok(issued.cookie.includes('HttpOnly; Secure; SameSite=Lax'));
assert.equal(readSession(issued.token, Date.now() + TTL_MS + 1), null);
const next = await login(user.email, 'Lesson-only-password-42!');
logout(next.token);
assert.equal(readSession(next.token), null);
await assert.rejects(() => login('unknown@example.test', 'wrong'), { code: 'AUTH_FAILED' });
console.log('密码验证、会话过期、退出失效与统一登录错误检查通过');
```

预期输出一条检查通过说明，运行时间取决于本机 scrypt 成本。不要把耗时较长立即当成 bug，密码哈希的计算成本正是设计的一部分；但需要用部署硬件测量并限制并发，避免登录洪峰耗尽线程池与内存。

## 关键代码逐段说明

hashPassword 每次生成独立盐，因此同一密码两次注册也应得到不同存储字符串。verifyPassword 从存储记录读取算法标记与参数，限制为本实现支持的版本；未来升级参数时，可在成功验证旧记录后重新哈希，而不是要求所有用户同一天改密码。

readSession 验证令牌格式、查摘要、检查服务端过期时间。Cookie 的 Max-Age 只控制客户端保存行为，不能替代服务端过期判断；攻击者可以绕过浏览器直接发送旧 Cookie。logout 同时删除服务端会话并生成删除 Cookie 的响应值，只有删除浏览器值而保留服务端会话，会让已复制的令牌继续有效。

csrfToken 在例子中已生成，但没有假装完成请求层 CSRF 防护。真正接入 HTTP 时，写操作应检查可信 Origin，并按会话验证自定义 CSRF 头中的令牌，配合 SameSite；登录接口本身也应考虑登录 CSRF。跨站部署需要重新设计 Cookie、CORS 和凭据策略，不能随手改成 SameSite=None 后宣布完成安全配置。

## 把登录看成凭据交换，而不是保存一个 user 对象

“团队文档与任务助手”登录成功时，服务器验证了一次密码，然后发出一份可撤销、可过期的会话凭据。浏览器之后不应在每个文档请求里再次发送密码，而是携带会话。服务端用会话恢复用户编号，再到成员关系中判断当前团队权限。这条链把密码验证、会话有效性和资源授权分开，每一层都有不同的失效条件。

Vue store 中保存的昵称、头像和角色用于展示，刷新后可以通过受保护的当前用户接口重新获取。不能因为 localStorage 里还存在一个 user 就跳过服务器认证，也不能因为前端路由判断为登录就让后端信任 body.userId。客户端状态是方便交互的缓存，服务器才负责确认凭据是否仍有效。

认证失败与服务暂时不可用也应区分。密码错误可以返回统一的凭据错误；数据库连接失败不应被翻译成“密码错误”，否则用户会不断修改正确密码，监控也看不见服务故障。公开错误可以保持简洁，内部日志仍应记录失败阶段和请求标识，同时排除明文密码与会话值。

## 密码哈希输入的细节会影响长期兼容

密码是用户选择的秘密文本，不应像标题一样随手 trim。注册时去掉空格、登录时保留空格，或者某次升级改变 Unicode 归一化方式，都会让用户无法用原密码登录。产品可以明确输入长度和允许策略，但注册、修改和验证必须一致。长度限制应说明按字节还是字符计算，避免多字节密码在不同实现里被悄悄截断。

前端先对密码做普通哈希，并不能代替 HTTPS 与服务端慢哈希。如果服务器把这个前端哈希直接当凭据，窃取它的人仍可重放登录，它只是换了一种形状的密码。服务端保存的密码哈希应具有独立随机盐和可调成本；浏览器传输仍要通过安全连接，日志与错误上报也不能记录输入秘密。

scrypt 的 N、r、p 共同影响计算与内存成本，不能只看一个数字大不大。教材使用固定参数让读者能够理解与复现，不代表所有部署硬件都适合同样并发。应测量目标机器上一次验证的耗时与内存，再限制同时进行的密码验证。否则为了提高离线猜测成本而增加的资源消耗，也可能被大量登录请求利用。

密码哈希记录需要版本信息。未来调整成本或算法时，先按照记录中的旧规则验证，成功后再用新规则重新生成；不应把旧哈希当作明文重新哈希，否则会改变验证语义。对损坏或不支持的存储格式，应拒绝验证并记录内部异常，而不是把未知参数无上限交给密码函数执行。

## API 细读：随机数、比较与会话存储

`randomBytes(size)` 同步形式返回 Buffer，也有接受回调的异步形式；size 以字节计量，必须是有效范围内的整数。会话令牌选择三十二随机字节，再编码成 base64url，这个编码不是加密，只是让字节适合 Cookie 与 URL 安全字符集。不能用 Date.now、Math.random 或连续数据库编号生成登录令牌，因为可预测性会直接破坏身份边界。

`createHash('sha256').update(token).digest('hex')` 适合把高熵随机令牌转换成数据库查找键。摘要不可逆不意味着令牌可以很短，随机空间仍要足够大。数据库记录可保存 user_id、创建时间、过期时间、最近使用时间与撤销状态；原始令牌只在签发时交给客户端，不应再次从会话表直接查询出来。

`timingSafeEqual(a,b)` 要求长度一致；长度不同时应先返回失败，不能让异常变成服务错误。它仅比较字节，前面的账号查询、格式检查与慢哈希成本仍可能形成时间差异。未知用户使用固定虚拟哈希可以减少明显差异，但不是声称整个系统完全恒时。限速、统一公开消息和异常监控依然是必要部分。

会话读取函数的返回值应清楚区分有效身份和无会话，例如 Session 或 null；内部存储故障则应抛出可观察的服务错误，不能悄悄当成匿名。有效期没有“浏览器替我验证”的默认保证，每次服务端读取都要检查；客户端可以直接构造请求继续发送已经过期的 Cookie。

## Cookie 的几个属性分别约束什么

HttpOnly 限制网页脚本直接读取 Cookie，但浏览器仍会在符合条件的请求中自动附带它，所以不能阻止脚本借用当前会话发起操作。Secure 限制安全传输，生产应使用 HTTPS；本地 HTTP 的浏览器特殊处理不适合作为部署依据。Path 决定发送路径范围，通常不是隔离同源不同应用的强安全边界。

SameSite 的“站点”与浏览器同源策略中的“源”不是同一个概念。两个子域可能同站却不同源，CORS 与 SameSite 解决的限制也不同。Lax 可以减少部分跨站写操作携带会话，但不能替代完整 CSRF 设计；跨站嵌入场景若确实需要 None，还要满足 Secure 并重新验证请求来源与凭据策略。不要仅为了消除一次前端跨域报错就放宽所有来源。

`__Host-` 前缀适合主机限定会话，要求安全属性、根路径且不设置 Domain。清除 Cookie 时也要匹配名称和相关作用范围，否则浏览器可能保留原来的那一份。服务端撤销与浏览器清理应同时发生：只让浏览器忘记，不能阻止别人使用复制的令牌；只撤销不清理，则界面可能持续携带失效值。

## 最小实验：CSRF 令牌与 Origin 必须匹配会话

保存为 `csrf-contract.mjs`，Node 22.22 或 24 执行 `node csrf-contract.mjs`，无依赖。它验证请求检查逻辑，不启动浏览器，也不声称字符串测试已经证明 Cookie 在跨站环境中的行为。真实服务器应从 Cookie 恢复会话后再调用同类检查。

```js csrf-contract.mjs
// csrf-contract.mjs
import { randomBytes,timingSafeEqual } from 'node:crypto';
import assert from 'node:assert/strict';
const allowedOrigin='https://assistant.example.test';
function requireWrite(request,session){
  if(!session)throw Object.assign(new Error('未登录'),{status:401});
  if(!['POST','PUT','PATCH','DELETE'].includes(request.method))throw new Error('本函数仅检查写操作');
  if(request.headers.get('origin')!==allowedOrigin)throw Object.assign(new Error('来源不允许'),{status:403});
  const supplied=request.headers.get('x-csrf-token')??'';
  if(!/^[A-Za-z0-9_-]{43}$/.test(supplied))throw Object.assign(new Error('CSRF 无效'),{status:403});
  const expected=Buffer.from(session.csrfToken,'utf8');const actual=Buffer.from(supplied,'utf8');
  if(expected.length!==actual.length||!timingSafeEqual(expected,actual))throw Object.assign(new Error('CSRF 无效'),{status:403});
  return session.userId;
}
const session={userId:'user-1',csrfToken:randomBytes(32).toString('base64url')};
function request(origin,token){return new Request(`${allowedOrigin}/documents`,{
  method:'POST',headers:{origin,'x-csrf-token':token},body:'{}',
});}
assert.equal(requireWrite(request(allowedOrigin,session.csrfToken),session),'user-1');
assert.throws(()=>requireWrite(request('https://other.example.test',session.csrfToken),session),{status:403});
assert.throws(()=>requireWrite(request(allowedOrigin,randomBytes(32).toString('base64url')),session),{status:403});
assert.throws(()=>requireWrite(request(allowedOrigin,session.csrfToken),null),{status:401});
console.log('合法会话与来源通过；错误来源、错误 CSRF 和未登录均拒绝');
```

预期输出一条说明。Origin 不是身份凭据，非浏览器客户端可以自行发送它；CSRF 防护的目的，是阻止另一网站借用浏览器自动附带的会话，不是替代密码或会话验证。若同源页面已经有 XSS，脚本可能读取 CSRF 值并执行操作，所以输出编码、内容安全和依赖安全仍然重要。

## 会话期限、续期与退出全部设备

绝对过期表示会话从创建时起最多有效多久，空闲过期表示距离最近活动多久以后失效。只采用每次访问续期的空闲策略，持续活动的被盗令牌可能长期有效；可以同时设置绝对上限，并对敏感操作要求重新验证。过期时间应由服务端可信时钟判断，不能接受客户端说“我还没过期”。

单设备退出可以删除一条会话，全部设备退出则要撤销同用户所有会话。会话版本是一种实现：用户记录保存版本号，每个会话带签发版本，退出全部设备递增用户版本，后续读取比较是否一致。它简化撤销动作，但读取版本时若使用长缓存，就会引入撤销延迟，因此一致性合同仍需明确。

登录成功后应创建新会话标识，避免沿用登录前可被攻击者固定的会话。权限提升、密码修改或可疑活动之后，也可以按策略轮换或撤销。轮换不是简单把旧令牌永久保留为备用，否则实际攻击面会增长；旧值什么时候失效、并发请求如何处理，需要结合产品体验设计。

## 原练习的完整参考：撤销一个用户的全部会话

保存为 `session-revoke.mjs`，无依赖，执行 `node session-revoke.mjs`。以下用内存模拟用户版本和会话表，完整验证两台同用户设备失效、另一用户保持有效。生产必须把版本与会话放入可共享存储，并验证多实例缓存策略。

完整代码已收录在本章末尾的练习参考答案中；可先阅读说明，再展开复制运行。


## 登录防护与可恢复的账号流程

慢哈希应在合理的请求限速之后执行。可以同时限制来源和账号尝试，避免一个来源尝试大量账号，或多个来源集中攻击同一个账号；但策略不能轻易让攻击者通过反复失败把别人永久锁死。拒绝时提供清楚且不泄漏账号存在性的反馈，记录异常模式，必要时要求额外验证。

密码重置令牌与登录会话也应分开，具有独立目的、短有效期和一次性消费语义。重置成功是否撤销所有会话，应写进产品策略。不要把重置链接里的令牌记录在普通访问日志、分析平台或错误上报中；链接属于临时凭据，不是普通页面参数。

如果产品选择外部身份提供商，应使用维护中的 OIDC/OAuth 客户端并验证回调、状态和令牌用途，不能把“拿到一个 access token”直接等同于本地用户已经安全登录。外部身份与内部租户成员关系仍然不同，登录来源认证了某个人，不代表自动加入任何团队。

## 有状态会话与 JWT 的取舍

不透明随机会话标识容易集中撤销和查看设备，代价是后续请求需要访问会话存储或受控缓存。签名令牌可以让某些服务本地验证身份声明，但退出、权限变化与签发密钥轮换会增加设计复杂度。令牌里携带的角色属于签发时的声明，不会随着数据库成员变化自动更新。

JWT 不是加密的同义词，普通签名载荷可以被读取，不应放秘密文档、密码或模型密钥。验证必须使用预期算法、签发者、受众和期限，并明确是否需要撤销机制。对于规模不大的团队文档助手，清楚的服务器会话往往更容易起步；跨服务场景需要什么能力，再有针对性选择令牌方案。

## 调试与真实浏览器验收

浏览器中先看登录响应是否包含 Set-Cookie，再看后续请求是否实际携带 Cookie；如果没有，检查域名、协议、Path、SameSite 与跨域凭据配置。不要在控制台打印原始会话值来调试，可以观察是否存在、是否过期和服务器关联的匿名标识。Node fetch 不会自动变成完整浏览器 Cookie 管理器，本章本地字符串断言不能替代这一步。

验收应包含正确登录、错误密码、未知账号、会话过期、单设备退出、全部设备退出、密码修改后策略以及跨站写请求。还要测试存储不可用时的服务响应，确保它不会意外降级成绕过认证。只有登录成功这一条路径通过，距离可靠身份系统仍有很大差距。

## 敏感操作可以要求最近重新认证

用户拥有一个仍有效的会话，不一定意味着适合立刻更改密码、导出全部私有文档或移交团队所有权。产品可以对这些高影响动作要求最近一次密码验证或额外因子，并在会话中记录服务器确认的认证时间。客户端传来的“刚验证过”标记不能作为依据，也不应仅因为页面显示过一次确认弹窗就认为已经重新认证。

账号被禁用、组织安全策略变化或发现令牌泄漏时，会话读取还需考虑账户当前状态。只有 expiresAt 的记录不能表达所有撤销原因。可以使用账户状态与会话版本配合，并保留不含秘密的审计事件，让用户知道哪些设备被退出、为什么需要重新登录。安全流程应让正常用户能够恢复工作，同时不给失效凭据留下隐蔽的继续访问路径。


## 边界、练习与参考解答

本例 Map 在多实例之间不共享，未访问的过期记录也不会主动消失，必须替换为有过期清理和撤销能力的共享存储。生产登录还需要账号与来源限速、异常监控、密码重置、凭据泄漏响应和可能的多因素认证。密码、原始会话令牌、CSRF 值都不应出现在普通日志中。

练习：实现“退出全部设备”。要求同一用户其他会话立即失效，不影响别的用户。提示：可以删除该用户的所有会话，或为用户增加会话版本；说明哪种方案便于多实例一致性。

<details><summary>参考答案（含完整可运行实现）</summary>

小规模共享数据库可在事务中删除 user_id 对应全部 sessions。版本方案是在用户表维护 session_version，会话记录保存创建时版本，每次认证时比较；退出全部设备递增版本。但如果服务端把版本缓存很久，撤销就不会立即生效，必须明确缓存失效策略。测试创建两份同用户会话和一份另一用户会话，执行撤销后前两份均失败，后一份仍可使用。本例可先用 Map 遍历实现逻辑，再迁移到共享存储。





```js session-revoke.mjs
// session-revoke.mjs
import { randomBytes,createHash } from 'node:crypto';
import assert from 'node:assert/strict';
const users=new Map([['alice',{version:1}],['bob',{version:1}]]);
const sessions=new Map();
const digest=token=>createHash('sha256').update(token).digest('hex');
function issue(userId,now=Date.now()){
  const user=users.get(userId);if(!user)throw new Error('用户不存在');
  const token=randomBytes(32).toString('base64url');
  sessions.set(digest(token),{userId,version:user.version,expiresAt:now+900000});
  return token;
}
function resolve(token,now=Date.now()){
  if(typeof token!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(token))return null;
  const key=digest(token);const row=sessions.get(key);
  if(!row)return null;
  const user=users.get(row.userId);
  if(!user||row.expiresAt<=now||row.version!==user.version){sessions.delete(key);return null;}
  return {userId:row.userId};
}
function revokeAll(userId){
  const user=users.get(userId);if(!user)throw new Error('用户不存在');
  user.version+=1;
}
const alicePhone=issue('alice');const aliceWeb=issue('alice');const bobWeb=issue('bob');
assert.equal(resolve(alicePhone).userId,'alice');
revokeAll('alice');
assert.equal(resolve(alicePhone),null);assert.equal(resolve(aliceWeb),null);
assert.equal(resolve(bobWeb).userId,'bob');
const renewed=issue('alice');assert.equal(resolve(renewed).userId,'alice');
assert.equal(resolve(renewed,Date.now()+900001),null);
console.log('Alice 两台设备被撤销，Bob 不受影响，新会话按新版本签发并能过期');
```


</details>

## 可验证的验收标准

密码不以明文或快哈希保存；相同密码不同盐产生不同记录；错误账号与错误密码返回相同公开错误；过期令牌和退出令牌均不能恢复身份；Cookie 包含所需属性且没有 Domain。上线前还需真实 HTTPS 浏览器验证，不能用字符串断言代替端到端 Cookie 验收。

## 三个自测问题与答案

1. 为什么密码用 scrypt，随机会话令牌却可用 SHA-256 摘要存储？答案：人类密码可猜测，需要提高猜测成本；随机高熵令牌的威胁模型不同。
2. HttpOnly 能完全阻止 XSS 带来的操作风险吗？答案：不能，脚本虽然读不到 Cookie，仍可能利用浏览器已有会话发请求。
3. 只清空前端 store 是否完成退出？答案：没有，必须让服务端会话失效并清理客户端凭据。


## 本章验证记录

编写时使用 Node 22.22.0 对本章全部 3 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过：`auth-session.mjs`、`csrf-contract.mjs`、`session-revoke.mjs`。本机脚本检查不代表已经完成真实 HTTPS 浏览器、Cookie 或跨域部署验收。

## 本章官方参考

- [Node Crypto](https://nodejs.org/docs/latest-v22.x/api/crypto.html)：scrypt、随机数与定时安全比较。
- [OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)：密码哈希与成本选择。
- [OWASP Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)：会话创建、Cookie 与撤销。
- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)：写操作的请求来源与令牌防护。
