跳到主要内容

第 13 章 认证与授权:业务的"门禁"

副题:Session vs JWT——凭证怎么生成、怎么验证、怎么吊销

第 12 章把服务端组织好了:分层清晰、链路通畅,鉴权中间件在管道里留好了位置(第 13 章主角)。现在补上这道门禁:这个请求是谁发的?他有资格吗?——HTTP 无状态(第 10 章说过"记不住用户"),但业务必须记住。本章跟着一次登录,把"记住用户"这件事做对,并用决策模型完整走一遍 Session vs JWT 的选择。

旅程图(图 8-1)的位置:浏览器 ✅ → 网络(上)✅ → 网络(下)✅ → 服务端 🔄(第 12 章"房子盖好",本章"装上大门")——服务端站第二章,请求第一次被"认出"

本章要建立的认知

  1. 登录的本质是发凭证:凭证怎么生成(无法伪造)、怎么验证(每次请求报上名来)、怎么吊销(退出登录立即失效)——Session 和 JWT 是两种凭证形态,解决的是同一个问题;
  2. 选 Session 还是 JWT,不是"哪个先进",是九步决策——业务约束(单机?多端?要不要踢人?)决定答案;
  3. 认证(你是谁,401)和授权(你能干什么,403)是两道不同的门——本章都讲,但先分清。

13.1 改一个数字就是另一个人

故事从一次"身份事故"开始。

先交代接口的契约(第 7 章定的,现在兑现):POST /v1/login——登录是个"动作"接口(第 7 章的 12 个接口里,它是为数不多的动作型例外),接受账号密码,返回凭证,401 表示凭证无效。第 7 章只把接口立起来,凭证怎么生成、怎么校验、怎么过期,全部是本章的事。

第一版后端要加登录。小明(兼职前端的第一个用户)图省事,凭证方案选了最简单的:登录成功后,把 userId 直接写进 Cookie——第 10 章说过,Cookie 是"给无状态协议补状态的第一块砖",那就在砖上写个数字:

// 第一版:登录成功就把 userId 写进 Cookie
app.post('/v1/login', (req, res) => {
const user = db.findUser(req.body.username, req.body.password);
if (!user) return res.status(401).json({ code: 'INVALID_CREDENTIALS' });
res.setHeader('Set-Cookie', `userId=${user.id}`); // 明文!谁都能看、谁都能改
res.json({ ok: true });
});
// 之后的请求:读 Cookie 里的 userId 就知道是谁

上线第三天,小明发现了一个让他后背发凉的事:他本来只是调试——在 DevTools 的 Application 面板里看 Cookie 有没有种上,结果看到 userId=2 明晃晃躺在那里。他把它改成 userId=1,刷新,自己就变成了老周——能看到老周的私信内容、能替老周发帖。改一个数字就是另一个人,整个社区没有秘密。

这个事故的根源一句话:凭证是"我是谁"的证明,而这段凭证没有任何防伪手段——它像一张只写名字不盖章的工作证,谁都能自己手写一张。

事故的"发现时刻"也顺带想一下:小明是调试时碰巧看到才发现的——如果没人碰巧看到,这个洞会一直开着:攻击者什么时候发现,只是时间问题,而攻击者发现的概率远比"自己人碰巧看到"高(他们专门找这个)。安全漏洞的生命周期是"开洞→被发现",不是"开洞→被修复"——本章事故是"自己人先发现"的幸运版本,第 19 章会看到"敌人先发现"的版本。

凭证这个物件的形态,人类已经换了好几代(一句话历史,帮助理解它在解决什么):令牌(古代——一块刻了字的牌子,验牌认人,服务端认"牌"不认"人",和 Session 的思路一模一样)→ 票据/印章(近代——纸上盖章,章就是防伪,和 JWT 的思路一模一样)→ 数字凭证(现在——Session 是电子令牌,JWT 是电子印章)。人类几千年都在解决同一件事:怎么让"证明我是我"的东西无法伪造——理解这一点,Session 和 JWT 就不再是陌生名词,而是两个古老的思路换了数字形态。

为什么会写成这样?和 12.1 的一坨代码同一个病根:先跑通后考虑安全——"先让登录能用,防伪以后再说"。但"以后"没有自动到来,事故先到了。这个教训先记住:凭证是系统里最不能"先凑合"的东西——因为它是信任的唯一来源:前端信任它(带它调接口)、服务端信任它(凭它放行)、用户信任它(凭它免登录)——三方都信一样东西,它伪造了,整个信任链就塌了。

伪造的后果也要分级看清楚(方便下次估风险):最低级——冒充看私信(隐私泄露,一次事故);中级——冒充发帖/评论(污染内容,影响所有读者);最高级——冒充下单/支付(阶段 3 开通后,冒充老周下单就是真金白银的损失)。同一个漏洞,业务越重,代价越大——所以第 4 章把交易后置,不只是砍功能,也是砍风险:阶段 1 的伪造事故赔得起,阶段 3 的赔不起。

事故后的处置顺序也顺带走一遍(它是第 25 章排障流程的雏形):先止血——把明文凭证方案立刻换掉(哪怕先换最朴素的随机串,13.2 两条路之一);再定位——确认影响面:日志里有没有异常 userId 的请求?谁可能看过或改过 Cookie?(第 25 章可观测性的第一次应用);最后根治——用本章的决策流程选正式方案。止血不能等根治:安全事故的第一原则是"先关水龙头,再修水管"——不少团队栽在"想一步到位,结果洞多开了一天"。

还有一个心理细节要承认:当时觉得明文没关系,因为**"我在本地跑,没人攻击我"**——开发期只有自己一个用户,当然没人改 Cookie。但系统一上线,用户就不是"自己人"了:老周会试、小明会试、路过的任何人都会试——信任模型必须从第一行代码就假设"对方不怀好意",这正是第 19 章安全章的第一课(本章先立事故,安全章再立防线)。

13.2 凭证必须防伪:两条思路

事故之后,防伪成了硬需求。怎么让凭证无法伪造?翻遍方案,其实只有两条路:

思路 A:凭证本身不含信息,服务端记着"凭证↔用户"的对应。凭证是一个随机长字符串a1b2c3…,没人猜得到),服务器在自己的存储里记一行:凭证 a1b2c3 ↔ 用户 2。请求来了,浏览器报上凭证,服务端查表——查得到,就知道是谁。凭证像餐厅的等位号:号本身没意义,意义在服务员(服务端)手里的登记表。

思路 B:凭证自己带着信息,但加上"盖章"防伪。凭证里直接写 userId=2,但后面附一段签名——签名是用只有服务器知道的密钥算出来的:内容 + 密钥 → 签名。请求来了,服务端用密钥重新算一遍签名,对得上=内容没被改过=可信。凭证像盖了公章的公文:内容是明文,谁都能读,但公章(密钥)只有发证机关有——想伪造,得先偷到公章。

两条路的本质区别一句话:A 把"这个人是谁"记在服务端(查表),B 把"这个人是谁"写在凭证里(验签名)——一个靠存储防伪,一个靠签名防伪。

两条路还有一个共同前提:凭证必须随每个请求自动携带——浏览器自动带 Cookie(第 10 章的"第一块砖",现在真的用上了)。没有这个自动携带,每次请求都要手动报身份,系统没法用。所以两条路的工程形态都依赖同一块地基:Cookie 是凭证的运输工具,凭证是 Cookie 里装的东西——这一章就是"砖上盖什么房子"的决策。

携带方式还有一个选择要交代(它是 JWT 话题里常被问的):凭证不一定走 Cookie——也可以走请求头Authorization: Bearer <token>,13.5 的代码里就是)。什么时候用头不用 Cookie?没有浏览器的时候——App、服务间调用没有"浏览器自动带 Cookie"这件事,只能手动放请求头。这就是"Session 跨端要适配"的含义:Session 依赖 Cookie 这个浏览器机制,离开浏览器就要自己实现携带;JWT 本来就放在请求里,端不端无所谓。Cookie 是浏览器的便利,也是 Session 的绑定——这个绑定在阶段 1 是免费的,在 App 时代是成本(第 26 章的演进条件之一)。

两条路的防伪原理也不难直觉理解(不涉及数学):A 靠"猜不到"——随机串的取值空间大到穷举不现实(256 位随机数,取值空间和宇宙原子总数同一数量级);B 靠"算不出"——签名是单向函数(从内容算签名容易,从签名反推内容/伪造签名,只有拿到密钥才行)。一个赌空间,一个赌函数——都是概率上的"不可能",不是绝对意义上的"不可能"。

注意:上一节的"明文 userId"不是第三条路——它只是 B 忘盖章的残缺版。B 的正确形态必须有签名,这是不可省的部分。

两条路都成立,选哪条?这正是第 5 章决策模型的第一次完整实战——下一节,用案例把九步走完。

两条路还有一个共同的敌人要先认识(它决定"防伪"的边界):凭证会被偷,和伪造是两回事。防伪防的是"伪造"(自己做一张假的),但凭证还可能被"偷"(XSS 脚本偷 Cookie、网络中间人截获)——偷走的真凭证,Session 和 JWT 都拦不住(它们只能靠吊销止损)。所以防伪解决"假证"问题,防盗解决"真证被偷"问题——防盗的仗在第 19 章安全章打(XSS 防护、HTTPS 已经修了路,第 10 章)。本章先把"假证"堵死。

13.3 九步决策完整走一遍:Session vs JWT

第 5 章说过,决策模型九步,方法论版在第 5 章走完了(怎么选语言、怎么选数据库)。这一节是业务实战版——一个真实业务问题(凭证方案)完整走一遍,每一步都有案例的约束撑着。走之前先看一眼这九步的问题链(它是本章的骨架,也是任何决策的骨架):凭什么记住你(目标)→ 在什么条件下做(约束)→ 要解决什么(问题)→ 有哪些路(候选)→ 按什么比(维度)→ 各自几分(打分)→ 选哪条(选择)→ 得到什么、付出什么(收益/代价)→ 什么时候重选(演进)。九步不是表格填空,是一条追问链。

① 业务目标:登录后 7 天内记住用户(第 4 章定的);凭证无法伪造;退出登录立即失效;接口分级(有的要登录、有的不用、有的只有本人能动)。

② 业务约束:阶段 1——单人开发、一台服务器、没有 App(只有浏览器)、登录态 7 天、前后端是两个域名(api 域和前端域,第 12 章 CORS 说过)。约束里最关键的一条:只有浏览器一个端,一台服务器

约束分析的价值就在这一步体现:JWT 的名声远大于它在阶段 1 的价值——市场上铺天盖地的"JWT 教程",没有一条约束(单机、单端、要吊销)是为阶段 1 说话的。决策模型的第一个作用,是挡住"名声最好的方案"——名声是别人的约束养出来的,你的约束要自己看。

③ 技术问题:凭证怎么生成、怎么验证、怎么吊销?

④ 候选

  • A 明文 userId:现状,已证明会被伪造(13.1)——直接排除,不用再评;
  • B Session(思路 A 的工程化):随机串凭证 + 服务端存储对应关系;
  • C JWT(思路 B 的工程化):自包含凭证 + 签名防伪。

⑤ 评价维度(从业务约束里长出,不是拍脑袋):实现复杂度(单人开发,复杂是成本)、可控性(能不能立即吊销——7 天登录态意味着凭证活得久,活得久的东西必须能随时作废)、跨端支持(将来会不会有 App/多服务)、安全面(凭证泄露/被伪造的风险面)。四个维度各自对应一条约束,对照着看:复杂度←单人开发(一个人写不完复杂方案);可控性←7 天登录态+退出(活得久的凭证必须能作废);跨端←"将来有没有 App"(现在没有,所以这维度现在打不了高分);安全面←"凭证是信任来源"(13.1 事故的直接教训)。维度不是模板,是从约束翻译出来的——约束变,维度就变。

⑥ 逐项打分(先自己估一遍再对答案):

评价维度B(Session)C(JWT)
实现复杂度存表+查表,略简单签名库两行调用,也简单
可控性(吊销/踢人)删记录即生效管不到,要黑名单打折
跨端支持(App/多服务)依赖 Cookie,要适配天然支持,优势在将来
安全面凭证泄露可吊销止损密钥泄露全盘崩溃

四行看下来:B 两胜一平一负,唯一输的那项(跨端支持)在阶段 1 用不上——结论已经很明显,但按流程把⑦ 走完(流程感本身就是决策训练)。

⑦ 方案选择B(Session)。理由用一句话收:阶段 1 的全部约束(单机、单端、要吊销、要简单)都指向 Session,JWT 的优势(跨端、无状态)在阶段 1 没有用武之地——选 B 不是 B 更先进,是 B 更匹配。

⑧ 收益:防伪(随机串不可猜)、可控(删记录即踢人)、简单(查表验证)、安全责任清晰(都在服务端)。

⑨ 代价与演进条件:代价是服务端要存凭证——每登录一个用户多一行记录(7 天有效期内占着)。单机时代无所谓;将来两个信号出现,重新评估:一是多台服务器(Session 表要共享——这就是第 16 章 Redis 登场的第二个理由:缓存之外,会话共享也要它);二是多端/多服务(App 或微服务——JWT 的跨端优势开始值钱,第 26 章再评估)。

决策记录的实际样子(第 5 章的表格,现在填一行):决策:会话方案=Session;理由:单机单端要吊销;代价:服务端存储;演进条件:集群共享存储(第 16 章 Redis)或多端出现(第 26 章)——这一行写进决策记录(第 5 章那张表),将来任何人对方案有疑问,翻记录看到的是"当时为什么选"而不是"当时选了啥"——决策记录的第八次填写(前七次:语言/数据库/React/CDN/DNS/框架/分层),也是本章作为"决策模型完整实战"的落点

这一遍走完,结论不是"Session 比 JWT 好",是"在阶段 1 的约束下,Session 比 JWT 合适"——约束变了,答案可能变。这就是第 5 章模型存在的意义:它不是帮你选一次,是帮你每次约束变化时重选一次

选完之后还有一个动作,是决策记录的第 9 步该有的样子:两周后回访——登录功能上线两周,回头对一遍:当时说"可控性重要",这两周里真的踢过人吗?(如果一次都没踢过,说明"要吊销"这条约束当时被高估了——回访的意义是校准,不是自我表扬。)决策记录的价值不在写下的那一刻,在回访的那一次——它能让你看见"当时的理由"和"后来的事实"之间的偏差,下次决策就更准。

走九步之前还有两个实务技巧,顺手记下(第 5 章方法论没展开的):先问三个问题再打分——"谁在用(端)?能用多久(生命周期)?失效了怎么办(吊销)?",三个问题的答案直接决定候选的去留(本例:"能用多久"=7 天×要吊销 → C 的短板直接暴露);打分表要写下来——脑子里打分会漏维度,写下来(像上面的表)才能发现"跨端支持"其实在阶段 1 是零分。决策模型的用法,比模型本身更需要练。

13.4 Session 原理:等位号与登记表

方案定了,看它内部怎么工作——把一次登录到一次请求的完整流程走一遍(图 13-1):

登录(POST /v1/login)
│ ① 校验账号密码(密码哈希比对,见下节)
│ ② 生成随机凭证 session_id(不可猜测的长字符串)
│ ③ 服务端存储:session_id ↔ userId(有效期 7 天)
└ ④ Set-Cookie: session_id=…(浏览器保存)

▼ 之后每个请求都自动带上 Cookie
请求(POST /v1/contents)
│ ⑤ 鉴权中间件读 Cookie 里的 session_id(第 12 章留的位置)
│ ⑥ 查服务端存储:找得到且没过期 → 有效,req.userId = 2
│ ⑦ 找不到/过期 → 401,前端跳登录页(第 7 章的约定)
└ ⑧ 有效 → 放行到控制器
图13-1 图稿占位
Session 流程
登录→Cookie→服务端会话存储

这张图里有一个反直觉的点,在脑子里多放一秒:浏览器里的凭证,主人不是浏览器——Cookie 是浏览器帮用户存的,但它证明的是"持有者=某个用户",浏览器自己不能决定谁用它。反过来说:任何人拿到这台电脑(或偷到 Cookie),就能用老周的凭证——凭证的持有权在"拿着它的人"手里,认证系统只验证"凭证有效",不验证"持有者是谁"。所以吊销机制(删表)就是丢证后的挂失——凭证丢了可以挂失,身份丢了不行:这正是 Session 可控性的日常形态。

几个容易混的细节,停下来说清楚:

Session 的完整代码形态(第 12 章的分层代码延续,一眼看懂它住哪):

// 登录:生成凭证 + 存表(业务层编排,数据访问层落库)
const sessionId = crypto.randomBytes(32).toString('hex');
sessionRepo.save({ sessionId, userId: 2, expiresAt: now + 7*24*60*60*1000 });
res.setHeader('Set-Cookie', 'session_id=' + sessionId);

// 鉴权中间件:查表(第 12 章留的位置,现在填充)
const row = sessionRepo.find(req.cookies.session_id);
if (!row || row.expiresAt < now) return res.status(401).json({ code: 'UNAUTHORIZED' });
req.userId = row.userId; // 挂到 req,之后的控制器/业务层都能用

这一段把第 12 章的几个概念串起来了:中间件(鉴权在管道的位置)、业务层编排(登录是个动作)、数据访问层(sessionRepo 管表)、req.userId(链路图里画好的字段)。认证不是独立系统,是第 12 章分层架构里的一个普通功能——这个认知比任何认证框架都重要。

**凭证是随机串,不是加密的 userId。**Session 的凭证里没有任何用户信息——它只是一把钥匙,信息在服务端的表里。所以它泄露了也只是泄露一把钥匙(可以吊销重发),不像明文 userId 那样泄露了就直接是身份。

**随机串怎么生成?**用密码学随机数(crypto.randomBytes(32)),不要自己拼(时间戳+用户名+固定尾巴这种"伪随机"是可以被猜出来的——攻击者知道生成规则就能预判凭证,等于没有防伪)。这是"别自己造轮子"在认证领域的第一课:随机数、哈希、签名,都用成熟库,只写业务。

**服务端的"存储"用什么?**第一版直接放内存(一个 Map,第 12 章分层后的数据访问层管着)——单机、单人,够用。更正规一点是建一张 session 表(session_iduser_idexpires_at——第 6 章的建表技能在这里复用,第 12 章的数据访问层管这张表的读写,分层的边界在这里又验证一次:业务层说"记住这个登录",数据访问层负责存哪张表)。不要为了"正规"一开始就上 Redis:那是阶段 2 的决策(第 16 章),现在上了只是白付复杂度。第 4 章的话再说一遍:复杂度预算,花在刀刃上。

过期怎么算?登录时记 expires_at,每次查表比对——7 天一到,记录作废。过期不是删除凭证(凭证在浏览器手里),是服务端表里的那行失去效力——所以 Session 的"过期"完全在服务端控制,这是它可控性的来源。

过期记录还有个运维细节(第 14 章数据层会看到完整版):过期记录不删,表会一直长——登录 7 天前的记录已经没用了还占着行。两个清理办法:登录时顺手删该用户旧记录(懒清理)、定时任务批量删过期行(周期清理)——第一版用前者,一行代码,够用。存储的每一行都是成本,过期数据是第一批该清的(第 14 章的数据生命周期从这里开始)。

Cookie 的域和 SameSite(阶段 1 就要配对的一个小坑):案例前后端是两个域名(api 域和前端域),浏览器跨域带 Cookie 需要 Set-Cookie 配上 DomainSameSite——配错了,登录成功但之后的请求都不带凭证(症状:登录永远"瞬间掉线")。这一行配置是 Session 方案里新手最常踩的坑,记住症状和方向即可(具体属性值跟框架文档走)。

7 天登录态的产品逻辑:为什么是 7 天不是永久?凭证活得越久,被偷的窗口越大(13.2 说过,偷走的真凭证防不住,只能吊销止损)——安全上希望它短,体验上希望它长(老周不想每周输密码),7 天是第 4 章砍需求时定下的平衡点。登录态时长是产品决策,不是技术默认值——这也是第 4 章"需求清单"里非功能需求落到技术层的一个实例。

一个容易忽略的产品细节:滑动过期。"7 天"有两种算法:固定过期(登录起 7 天整,不管用不用)和滑动过期(每次活跃就顺延 7 天——老周今天用过,明天还是 7 天)。案例用滑动:固定过期会出现"昨天还在用,今天突然要重新登录"的突兀感(老周以为是 bug);滑动的前提是每次请求都更新 expires_at(Session 表的一行更新)。登录态的产品体验,藏在过期算法的选择里——"非功能需求也是需求"(第 4 章)的又一个小实例。

密码怎么存?(这是认证的一半)验证账号密码时,密码绝不明文存储、绝不可逆加密"解密出来"——数据库被拖走(第 19 章),明文密码就是全部用户的灾难。正确做法是哈希 + 加盐:存 hash(密码 + 随机盐),验证时对输入重新算一遍哈希比对——哈希不可逆,数据库泄露了,攻击者拿到的也只是哈希(还要逐个撞)。"撞"的成本可以直觉理解:加盐让"一次算好、处处比对"的预计算失效——没有盐,攻击者可以提前算好常见密码的哈希表(彩虹表),拿到数据库直接对号入座;每个用户一个随机盐,同一密码在不同用户那里哈希值都不一样,预计算表作废,只能对每个用户单独算——盐是让"撞库"从秒级变成天级的那个小设计。案例的 user 表里,password_hash 列存的就是哈希(第 6 章建表时那一列,现在知道它为什么不是明文了)。哈希带来一个业务后果:忘记密码只能"重置",不能"找回"——因为没人知道原密码是什么。这不是产品缺陷,是安全方案的必然代价。

登录接口还有一个细节(第 7 章契约的补充):"账号不存在"和"密码错误"应该返回同一个错误(统一 401 INVALID_CREDENTIALS)——分开返回,等于告诉攻击者"这个账号存在",注册枚举的入口就开了(枚举攻击的完整形态第 19 章展开)。接口的错误信息,也是攻击面的一部分

13.5 JWT 原理:盖了章的公文

Session 讲完了。备选方案 JWT 为什么要懂?两个理由:它是"将来可能换过去"的方案(演进条件里写着),而且它是面试和团队讨论里绕不开的名词——不懂原理,讨论时只能点头。

JWT(JSON Web Token)的结构是三段式,用点连接:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjIsImV4cCI6MTc1MDAwMDAwMH0.签名
└───── Header ─────┘ └────── Payload ──────┘ └──────── 签名 ────────┘
算法类型(HS256) 载荷(userId、过期时间等) HMAC(前两段, 密钥)
  • Header:用的什么算法(HS256);
  • Payload:业务信息(userId=2、过期时间)——Base64 编码,不是加密,谁都能解码看
  • 签名HMAC(Header.Payload, 密钥)——用服务器密钥对前两段算指纹。这就是 13.2 说的"盖章"

验证过程(图 13-2):收到凭证 → 用密钥重新算签名 → 比对:一致 = 前两段没被改过 = 可信;不一致 = 伪造。注意:验证不需要查任何表——信息都在凭证里,这就是"无状态"的含义:服务端不需要存储,凭证自己证明自己。代码形态一句话(第 12 章的鉴权中间件在这里填充完整):

// JWT 版鉴权中间件:验签,不查表
app.use((req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
try {
req.userId = jwt.verify(token, SECRET).userId; // 重算签名比对,抛错=伪造/过期
next();
} catch { res.status(401).json({ code: 'UNAUTHORIZED' }); }
});

对比 13.4 的 Session 版(查表),差别就一眼:一个查库,一个验签——其他一模一样(都是中间件、都是 401 兜底)。这也是"机制学会,切换只是换实现"的又一次应用(第 9 章那句话)。

图13-2 图稿占位
JWT 结构
Header.Payload.Signature 三段

JWT 的代价,在 13.3 打分时已经埋了伏笔,现在落到机制层面看:

吊销难。Session 退出登录=删表里那行;JWT 退出登录=凭证还在用户手里,有效期内依然能用(服务端不知道它"应该"失效)。要让它失效,只能:等它自然过期(exp 到了)、或者上黑名单(服务端记"这些凭证已吊销"——一上黑名单,服务端又要存东西了,"无状态"名存实亡)。7 天登录态 + 要能踢人,是阶段 1 选 Session 的硬理由。

黑名单的代价,具体化一下(它是"JWT 无状态"神话的真相):黑名单=服务端存储+每次验证先查黑名单——存储回来了、查表也回来了,唯一省下的只是"凭证↔用户"的对应关系。也就是说:需要吊销的 JWT,和 Session 的差别只剩"载荷里多写几个字段"。所以"JWT 无状态"只在"不需要吊销、凭证短命(比如微服务之间的一次性调用凭证)"的场景里才是真的——这个场景第 26 章会见到。

JWT 在案例里什么时候用上?两个触发点都写在决策记录里了:一是多端(阶段 2 若做 App,App 不自动带 Cookie,JWT 放请求头更方便——但也可以做"Cookie 适配",所以是"重新评估"不是"必然换");二是服务间调用(第 26 章拆服务后,服务 A 调用服务 B 时,用 JWT 做"一次性服务凭证"是常见做法——凭证短命、不需要吊销,正是 JWT 的舒适区)。

密钥是命门。签名的安全性完全押在密钥上:密钥泄露 = 所有人都能伪造凭证 = 整个认证系统崩塌。所以用 JWT 的团队,密钥管理是安全审计的第一项(第 19 章会看到密钥泄露的真实案例)。

一个常见的混淆要澄清:JWT 和 Session 不是"有没有加密"的区别——JWT 的 Payload 是明文,Session 的凭证也是随机串,两者都不靠"藏"来防伪,JWT 靠签名、Session 靠查表。混淆"JWT=加密的、所以安全"是最常见的误解:JWT 是"可读的+防伪的",Session 是"不可读的+可吊销的",各有取舍,没有谁天生更安全。

还有一个稳定性的细节:无论选 Session 还是 JWT,"无效凭证→401"的契约不变——第 7 章的 401 语义(前端跳登录页)不随方案换而变,换方案时前端一行不用改。接口契约的稳定性,正是第 7 章"接口是承诺"的日常回报:方案在底下换,承诺在上面不动。

Payload 别放敏感信息(JWT 可读的必然推论):userId 没问题,但密码、手机号、身份证号绝不能进 Payload——任何拿到凭证的人(哪怕不是伪造,只是偷看)都能直接解码读出明文。写进凭证的信息,默认等于公之于众——JWT 的"自包含"是便利,也是信息泄露面

13.6 授权:401 与 403 是两道不同的门

门禁装了,但"进门"和"进哪个房间"是两件事——认证(authentication)回答"你是谁"(401),授权(authorization)回答"你能干什么"(403)。第 12 章说过安全审查按层:认证在中间件(谁能进来),授权在业务层(谁能发、谁能删)。现在兑现第 7 章留的作业——接口权限标注表,第 13 章照着做

接口认证要求授权要求
POST /v1/login无(登录本身)
GET /v1/contents无(公开内容)
POST /v1/contents要登录(401)无(登录即可发)
POST /v1/orders要登录(401)
DELETE /v1/contents/{id}要登录(401)只有作者本人(403)

这张表的实现,正好用上第 12 章的两块积木:

认证(401)在中间件authMiddleware 验证凭证(13.4 的查表),有效则把 userId 挂到 req 上(第 12 章的链路图里这一步已经画好了),无效直接返回 401——前端收到 401 跳登录页(第 7 章的约定)。哪些接口过这道中间件?照着权限表:要登录的接口挂,公开接口不挂——中间件的顺序(日志→解析→鉴权→路由)在这一刻有了具体意义。挂载方式也是第 12 章留下的积木:app.use(authMiddleware) 挂全部(登录接口要在它之前,否则登录请求也过不了鉴权——登录接口是唯一"不用凭证"的入口,这个例外要在路由顺序里留好);app.post('/v1/orders', authMiddleware, handler) 挂单个(只保护需要登录的接口)——中间件是"按需挂"的,不是"全挂上再豁免",第二种写法是常见的坑(全挂上容易漏豁免,漏了就"登录也 401")。

"不用凭证的入口"不止登录一个:注册POST /v1/users,第 7 章清单里的)和将来的密码重置也是——无认证入口的清单要显式维护(登录、注册、密码重置),漏挂一个在鉴权后面,新用户就永远 401;多挂一个在外面,公开接口就裸奔。权限表维护的不只是"要登录的",还有"不要登录的"——两列都要数清楚。

授权(403)在业务层DELETE /v1/contents/{id} 的"只有作者"——中间件只知道"你是谁"(req.userId),不知道"这条内容是谁的"(要查内容表)——所以这个检查在业务层deleteContent(userId, contentId) 先查 content 的 author_id,不是本人就返回 403。第 12 章 12.6 说"业务层看授权规则(谁能发、谁能删)"——这里就是它。权限表的演进也预告一句(第 4 章的节奏):阶段 3 上线管理员功能时,表里加一行"管理接口→只有管理员",实现是业务层加一个"角色检查"(本章后面的 RBAC 一句话)——权限表是活文档,跟着业务长

401 和 403 别用反(第 7 章的错误码表里都是见过的):凭证没带/无效 = 401("你没进门"),前端跳登录页;凭证有效但没权限 = 403("你进门了,但这个房间你不能进"),前端提示无权限。用反了,前端的行为就全错了——401 会错误地让用户反复登录,403 会错误地让已登录用户"被登出"。(403 的另一个常见场景预告:阶段 3 管理员功能上线后,"普通用户调管理接口"也是 403——同一个状态码,两个时代都会用到。)

前端那一半(第 8 章留的作业,现在补上):登录成功后前端拿到凭证(Cookie 自动存),之后的请求浏览器自动带上——前端代码只需要在收到 401 时跳登录页、收到 403 时提示无权限,不需要自己管凭证的携带(Session 模式);这是 Session 对前端最友好的一点:凭证管理对前端透明。第 8 章说的"登录态判断",在 Session 模式下就是"有没有收到过 401"。

前端怎么"统一处理 401"?——第 9 章说过前端框架内建常见问题的答案,请求层的统一拦截就是其中之一(框架/HTTP 库的拦截器:所有响应先过一遍,401 就跳登录页,业务代码不用每个接口都写一遍)——这是"横切关注点"思想的前端版(第 12 章中间件的孪生兄弟):日志、鉴权、错误处理这些横切的事,前端后端各自抽成一层,机制一模一样

授权模型一句话(不展开,角色授权阶段 3 再引入):案例现在的权限表只有"登录即可/只有作者/公开"三种——这是最简单的授权模型。等业务复杂了(阶段 3 交易后),会引入角色(管理员/普通用户):"谁有权限"从"逐人判断"变成"按角色判断"(RBAC)——第 7 章的权限标注表到时候加一列"角色"就行。现在不用做,知道"授权将来会变复杂"即可——权限模型是跟着业务长出来的,不是一开始设计出来的

越权测试(第 17 章的预告):权限表是最好的测试用例清单——每个接口三问:没登录调用(应 401)?登录了但没权限调用(应 403)?有权限正常调用(应 200/201)?一个接口三行断言,权限表的每一行都是测试用例——第 18 章测试体系的第一批素材,在第 7 章埋下、本章点亮。

13.7 OAuth 一句话 + 代价与演进

OAuth 一句话定位(不展开,展开是另一本书):"用微信登录"是 OAuth 的活——OAuth 解决的是"第三方应用能不能读取我的身份"(你把身份授权给第三方,微信发一个授权凭证),它和"登录后怎么记住你"(本章的会话凭证)是两个层次:OAuth 管"怎么拿到身份",Session/JWT 管"拿到身份后怎么保持会话"。将来接入第三方登录,是"OAuth 拿身份 + 自己的 Session 保持会话"的组合——OAuth 不会替代本章的凭证。

引入时机也预告一句(第 4 章"业务逼出技术"的又一次预演):阶段 2 用户增长期,老周邀请来的朋友不愿意再注册一套账号("用微信直接进")——"第三方登录"被这个诉求逼出来时,它仍是"OAuth 拿身份 + 已有 Session 保持",本章的方案只是多了一个"身份来源",结构不动。

本章的代价清单(决策地图的第四步不能省):

  • Session 的代价:服务端存凭证(每登录一人一行,7 天占着);多台服务器时要共享凭证表——这就是第 16 章 Redis 登场的第二个理由(第一个是缓存);凭证在浏览器 Cookie 里,有被偷的风险面(13.2 说过偷与伪造的区别——偷走的真凭证只能吊销止损,第 19 章展开;所以登录态别留太长,7 天是产品权衡的结果,不是技术默认值);
  • JWT 的代价:吊销难、密钥是命门——阶段 1 不选它,但这笔账要记着;
  • 认证系统的共同代价:凭证失效要处理(过期、被吊销、多设备互踢)——Session 模式下这些都是服务端表里的一行操作,这也是它适合小团队的原因。

多设备互踢单独说一句(它是"吊销"的产品形态):一个用户可以在多台设备登录,Session 表里就有多行(每台设备一个凭证)。"退出登录"只删当前设备那行;"改密码后踢掉所有设备"就删这个用户的所有行——踢人粒度的选择(踢单设备还是全踢)是产品决策:登录页常有"在其他设备登录"的提示,背后就是这两行删除的差别。

演进回顾(第 2 章循环):明文(事故)→ Session(本章,被防伪逼出)→ 集群共享/多端(第 16/26 章评估)——每一次演进都是"原方案无法承受"逼出来的,和前面章节完全同一个节奏。

把全章收成一条线(凭证的完整生命周期):生成(登录:随机串/签名,13.4/13.5)→ 携带(Cookie/请求头,浏览器自动带,13.2)→ 验证(鉴权中间件:查表/验签,13.6)→ 失效(过期 7 天/退出删除/改密码全踢,13.4/13.7)——四个环节,任何一环断了,登录态就断;本章每节的标题,其实都是这条线上的一站。

13.8 本章对应表

业务诉求技术选择为什么代价/取舍
登录后 7 天记住用户会话凭证(Session)HTTP 无状态需补丁(第 10 章"第一块砖"兑现)服务端要存凭证,凭证有被偷风险
凭证防伪随机串 + 服务端存储(Session)/ 签名(JWT)明文凭证已证明可伪造(13.1 事故)靠存储或靠密钥,各有命门
密码安全存储哈希 + 加盐明文/可逆加密都是灾难(第 19 章)忘记密码只能重置不能找回
退出登录立即失效Session 吊销(删记录)7 天登录态必须能随时作废JWT 要黑名单才做得到(无状态打折)
接口分级(谁能用)认证中间件(401)+ 业务层授权(403)第 7 章权限标注表兑现;分层各司其职(第 12 章)权限表要维护,401/403 用反前端全错
第三方登录OAuth(一句话定位)不自己造身份授权轮子引入外部依赖,两个层次别混
Session vs JWT九步决策(★完整实战)约束决定答案:单机单端要吊销选 Session演进条件:集群共享/多端再评估

每一行都在本章正文里有完整论证:事故在 13.1,防伪思路在 13.2,九步在 13.3,Session 原理在 13.4,JWT 原理在 13.5,授权在 13.6,OAuth 与代价在 13.7。和前面章节一样,先看"业务诉求"列——这一章的每一行,都是从"改一个数字就是另一个人"那场事故里长出来的

本章对两类读者的收益(和第 12 章同款):前端读者看到了"登录"的完整后台——401/403 为什么这样设计、Cookie 里到底装了什么、"登录态判断"(第 8 章)的真相;后端读者第一次看到九步决策的完整实战——Session vs JWT 不是背结论,是约束推导。认证是前后端共同的地基:前端处理 401,后端发凭证,错一步,体验就崩。

本章小结

认证速查(第三部分回指本章时翻回这里):

  1. 凭证两条思路:服务端存储(Session,查表)/ 自包含+签名(JWT,验签名)——明文凭证是第三条废路;
  2. 九步决策选 Session:单机、单端、要吊销、要简单——约束指向 Session,JWT 的优势用不上;
  3. Session 流程:登录生成随机串→存表→Set-Cookie→请求带 Cookie→查表→放行(图 13-1);密码存哈希+加盐,忘记只能重置;
  4. JWT 三段式:Header.Payload.签名——验签不查表(无状态),代价是吊销难、密钥是命门(图 13-2);
  5. 认证 401 / 授权 403 两道门:认证在中间件(验凭证),授权在业务层(查归属)——权限表照着第 7 章做;
  6. OAuth 管"怎么拿到身份",本章管"拿到后怎么保持"——两个层次。 (六条速查对应认知结构:1-2 是"凭证是什么、怎么选",3-4 是"两个方案怎么工作",5 是"门禁怎么分级",6 是"边界在哪"——第 14 章回指本章时,重点翻 3(Session 流程)和 5(401/403)。)

旅程进度:服务端站三章,已过两章——第 12 章盖了房子(分层/链路),第 13 章装了大门(认证/授权),下一章是房子里的仓库(数据层)。


  • 登录的本质是发凭证:生成(防伪)、验证(报上名来)、吊销(随时作废)——Session 和 JWT 是两种凭证形态,解决同一个问题;
  • 选 Session 不是它先进,是九步决策在阶段 1 约束下的答案——集群、多端出现时重选(第 16/26 章);
  • 认证(401)和授权(403)是两道门:中间件管进门,业务层管进房间——第 7 章权限表兑现;
  • 密码存哈希、Cookie 里的凭证会被偷(第 19 章展开)——认证的安全账,本章先立起。

下一章

门禁装好了:谁是谁(认证)、谁能干什么(授权)都清楚了。请求终于可以一路走到业务层,干真正的活了——但业务干的活,全都围着一样东西转:数据

内容存哪张表、怎么查得快、事务怎么保证"要么都成功要么都回滚"(下单扣库存那一步,第 17 章的主角之一)——第 14 章,数据层:业务的记忆——身份确认了,接下来看业务怎么记住它自己。