图 1:本文封面,会话与鉴权方案的四步决策链。
同批的 token 鉴权真题讲了 token 与 session 两种机制的逐项对比。这篇把视野拉宽:真实的系统里选项不止两个——本地 session、集中式 session、JWT、OAuth2 各占据选型坐标系的一个象限,选型就是按场景问题一步步收缩。
方案一:本地 session,单体应用的最优解。
用户登录后,服务端在内存里建一份会话对象,回给浏览器一个 session id(走 Cookie),之后每次请求按 id 查内存。查询就是一次哈希表查找,没有任何网络开销,修改、踢人下线都是改自己内存,即时生效。它的能力边界也清清楚楚:会话只存在这一台机器上,实例一扩容,请求落到别的机器就不认识了。所以它的适用面只有一条——单体应用,或能用会话粘滞(sticky session)把同一用户固定到同一实例的小集群。
方案二:集中式 session,用一次网络读换横向扩展。
把会话从各实例内存搬进共享存储(典型是 Redis):任何实例拿到 session id 都去中心存储校验,于是负载均衡可以随意调度,实例可以随时增减。会话依然是「有状态」的——存储里删掉这条记录,用户立即被踢下线,撤销能力完整保留。代价是每次请求多一次网络读(通常亚毫秒级,但要占用基础设施),以及中心存储本身要做高可用。多实例、又要随时踢人下线的后台系统,这是最稳的选择。
方案三:JWT,把会话塞进令牌自己。
JWT(RFC 7519,2015 年发布)的思路是反转:服务端不存任何会话,把用户身份、角色、过期时间编码进令牌本身,用密钥签名防篡改。令牌三段式结构 header.payload.signature,前两段只是 Base64URL 编码的 JSON——不加密,只签名,任何人都能解码看到内容,但改一个字符验签就失败。验证方只需要密钥,不需要查任何存储,所以天然无状态、天然跨服务跨域,网关验签零网络开销。代价是同一枚硬币的另一面:签发之后在过期之前难以单独撤销,踢人下线要靠额外引入黑名单(那就又把状态请回来了)或缩短有效期配刷新令牌。
方案四:OAuth2,解决的是另一个问题。
把 OAuth2 和上面三个并列容易误导,先划清边界:OAuth2(RFC 6749,2012 年发布)是授权框架,不是会话机制。它回答的问题是「怎么让第三方应用在不拿到用户密码的前提下,获得有限的访问授权」。框架定义了四种授权模式:授权码模式(最常用,第三方经重定向拿授权码再换令牌,令牌不经浏览器)、隐式模式(已不推荐用于新系统)、资源所有者密码模式(仅限高度信任的第一方)、客户端凭证模式(服务对服务,没有用户)。OAuth2 体系里签发的访问令牌常落地为 JWT,二者是配合关系;单点登录(SSO)也通常建在授权码流程之上。所以选型时先问「有没有第三方授权需求」——有才需要 OAuth2,没有就别把它请进来增加复杂度。
选型坐标系:两个问题定方向。
把所有方案放到两个轴上:横轴是有状态到无状态,纵轴是单体到分布式/跨域。决策链就是四个问题:单体应用?本地 session。多实例且要随时踢人?集中式 session。多实例且追求无状态、读多写少的校验路径?JWT。要开放能力给第三方应用?叠加 OAuth2。四个方案不是替代关系,而是逐级补上前面方案解决不了的问题。
用代码验证 JWT 的三个关键性质。
下面这段 Node.js 22.15.0 程序(node jwt.js)只用标准库手工实现签发与验签,演示「不加密可解码」「篡改即失效」「验签不查存储」:
const crypto = require('crypto');
const b64u = o => Buffer.from(JSON.stringify(o)).toString('base64url');
const secret = 'demo-secret';
const header = b64u({ alg: 'HS256', typ: 'JWT' });
const payload = b64u({ sub: 'user-42', role: 'admin', exp: 1893456000 });
const sign = data => crypto.createHmac('sha256', secret).update(data).digest('base64url');
const token = `${header}.${payload}.${sign(`${header}.${payload}`)}`;
console.log('签发的 token:', token);
// 性质一:payload 只是 Base64URL 编码,任何人都能读
console.log('任何人解码 payload:', Buffer.from(token.split('.')[1], 'base64url').toString());
// 性质二:验签能发现篡改
const verify = t => {
const [h, p, sig] = t.split('.');
return crypto.timingSafeEqual(Buffer.from(sign(`${h}.${p}`)), Buffer.from(sig));
};
console.log('原 token 验签:', verify(token));
const forged = `${header}.${b64u({ sub: 'user-42', role: 'root', exp: 1893456000 })}.${token.split('.')[2]}`;
console.log('篡改 role 后验签:', verify(forged));
实测输出:
签发的 token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxODkzNDU2MDAwfQ.I9myOyuALGau-l_HG_pu6FdQSnRNGY79PSlcXLLq7LA
任何人解码 payload: {"sub":"user-42","role":"admin","exp":1893456000}
原 token 验签: true
篡改 role 后验签: false
第一行说明 payload 里没有秘密(敏感数据绝不能放进来),第三、四行说明签名保证了完整性。整个验签过程没有读任何服务端存储——这就是无状态的来源,也是撤销难的来源。
图 2:四种方案在「有状态—无状态 × 单体—跨域」坐标系中的位置。
最后强调一句边界:这套选型框架管的是「用户登录态与授权」这一层,传输安全(TLS)、令牌存储位置(Cookie 还是 localStorage)、防 XSS 与 CSRF 是另外几层功课,选好方案不等于系统安全。你的系统现在跑在哪一档?有没有因为「想踢人踢不掉」从 JWT 退回集中式 session 的经历?欢迎聊聊。