认证的地基 — JWT 双令牌、Argon2 密码哈希与数据模型
认证的地基 — JWT 双令牌、Argon2 密码哈希与数据模型
这一章讲什么: 上一章的 API 没有任何门禁。这一章先打地基: 认证和授权到底差在哪,JWT 长什么样、凭什么可信,为什么密码要哈希、 哈希为什么还得加盐和「内存困难」。读完你能向任何人解释: 为什么 token 过期要分长短两种,为什么数据库被盗也不能直接拿密码登录。
1. 三个缺口与一对必须分清的概念
第 10 章的 API 有三个裸奔证据:任何人能读全部数据;任何人能改、甚至删号、 自我提升为管理员;发帖时的 authorId 是客户端自己填的,冒充别人没有任何障碍1。 真实系统要补三样东西:身份核验(你是谁)、访问控制(你许不许做这件事)、 审计痕迹(谁何时做了什么)——本章解决前两个1。
两个词,先分死:
认证永远在前:问「这人能进管理员面板吗」,第一步总得先知道「这人是谁」。 它们对应我们已认识的两个状态码:401 = 认证失败(token 缺失、过期、 无效——服务器不知道你是谁);403 = 授权失败(服务器清楚你是谁, 但你不许做这事)。名字有点骗人(401 其实该叫「未认证」),但这就是 通行标准,到处都会撞见3。
2. 四条认证路线,为什么选 JWT
| 路线 | 怎么做 | 代价 |
|---|---|---|
| 会话(session) | 登录后服务器建会话存内存/库,客户端拿 cookie 里的 session ID,每次请求服务器查会话 | 有状态:要存、要查,多实例还要共享会话存储 |
| JWT 令牌 | 登录后服务器签发一个签名过的、自包含的令牌;客户端每次请求带上,服务器只验签名 | 令牌不可吊销(见第 4 节的解药) |
| OAuth 2.0 / OIDC(把登录委托给 Google 等第三方账号的开放协议) | 「用 Google 登录」:重定向到 Google,用户在那里认证,你的服务器拿授权码(一次性凭证)换 token | 实现复杂(回调、PKCE、各家怪癖);它替代的是密码,不是 JWT——你自己的系统照样要发 JWT4 |
| 托管服务(Auth0、Clerk…) | 整个认证外包,SDK(服务商提供的开发工具包)一接就完 | 硬依赖第三方; outages 传染、迁移痛苦;而且学不到东西——本书选择自己造4 |
选 JWT 的理由:无状态(任意服务器实例拿同一把密钥都能验,水平扩展零负担);
token 走 Authorization 头,天然适配 REST;Node/TypeScript 生态里
最主流4。
3. JWT 解剖:三段、两个「不是」
JWT(JSON Web Token,JSON 形式的网络令牌)就是三个 Base64Url 段,点号分隔5:
eyJhbGciOiJIUzI1NiIs…(header)
.eyJzdWIiOiIxMjMiLCJyb2xlIjoiVVNFUiIs…(payload)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c(signature)
header = { "alg": "HS256", "typ": "JWT" } ← 用什么算法签的
payload = { "sub": "123", "role": "USER",
"iat": 1714000000, "exp": 1714000900 } ← 关于用户的主张(claims)
signature = HMACSHA256(header + "." + payload, secret)两个「不是」,每个都要记牢:
第一,payload 是 Base64Url 编码,不是加密。 任何人拿到 token 都能解码 读到内容——jwt.io 上贴进去就现形。签名防的是篡改,不防偷看。 所以密码、卡号、隐私绝不进 payload(token 的载荷部分);payload 只放每次请求免查库就要用的 小东西(用户 ID、角色、租户 ID),而且越小越好——它跟着每个请求走5。
第二,签名的验证不需要查库。 服务器收到 token,拿 header+payload 用自己的密钥重算一遍签名,和 token 里的比对:一致=没被改过; 不一致=有人动过,拒。验 token 的全部所需就是密钥本身—— 这就是「无状态」的确切含义6。
4. 双令牌(token)与旋转:给「偷了就永远有效」打补丁
无状态有个致命短板:JWT 签出去就吊销不了——没有会话可销毁, 签名一直有效,被偷的 token 就是永久通行证7。解药是把令牌拆成 两个寿命、两种职责7:
| access token | refresh token | |
|---|---|---|
| 寿命 | 短,通常 15 分钟 | 长,通常 7 天 |
| 出现在哪 | 每个 API 请求的 Authorization: Bearer 头 | 只去刷新端点 |
| 存哪 | JavaScript 内存 | httpOnly cookie(JS 读不到,抗 XSS) |
| 能吊销吗 | 不能(等自然过期) | 能(从数据库删掉) |
| 装什么 | 用户 ID + 角色 | 只有用户 ID |
access 过期了,客户端拿 refresh 去刷新端点换新的。关键是旋转(rotation): 每次刷新,服务器同时发新的 access + 新的 refresh、删掉旧 refresh—— 每个 refresh 只能用一次。旧 refresh 再出现,说明它被偷过(或重放过), 服务器可以吊销该用户全部令牌作为安全反应7。 存储为什么较劲:refresh 值 7 天的信任,若能被前端 JS 读到, 一个 XSS 漏洞就全完——httpOnly cookie 从机制上断了这条路7。
5. 密码怎么存:哈希 + 盐 + 胡椒 + 慢
绝不存密码,只存哈希。 哈希是单向运算:书上拿「任何输入都吐 A–Z 一个字母」的玩具函数讲透不可逆——只看输出「W」,原输入有无穷多种可能, 倒推在数学上就不成立8。注册时哈希入库;登录时把候选密码再哈希一遍, 比对是否一致。库被盗,攻击者看到的也是一堆无法反推的哈希8。
但裸哈希挡不住彩虹表:「password1」这种万人用的密码,攻击者可以 事先把所有常见密码的哈希算好备查,盗库后逐个查表即可。 盐(salt) 是解药:每个用户随机一段盐,和密码拼在一起再哈希—— 同密码不同盐,哈希完全不同,预计算表报废8。
盐还有盲区:它就和哈希存在同一个库里,定向爆破单个用户依然可行 (拿盐配着字典算)。胡椒(pepper) 补上这一层:一个全站共享的密钥, 放在环境变量(从程序外注入的配置)或密钥管理服务里,不进数据库。库被盗=有盐没胡椒,连定向爆破 都算不出合法哈希8。为什么盐不能省?没有盐,两个同密码用户哈希相同, 攻击者一眼认出「这批人用的是同一个弱密码」——盐保唯一,胡椒保库不够8。
最后还有速度问题:哈希函数太快,GPU(显卡,成千上万个小核并行计算)每秒能试亿级候选。密码专用 算法要故意慢(每算一次刻意拖几百毫秒)8。
6. 为什么 2026 年选 Argon2id 而不是 bcrypt
bcrypt 二十年来的默认答案,防线是「算得慢」。但它设计时没料到 GPU: 现代显卡(GPU,图形处理器,特点是成千上万个小核)可以并行(同时多路)跑 bcrypt,一块游戏显卡每小时能试百万级 密码9。Argon2 的杀招是内存困难(memory-hard):每次哈希默认要吃 64 MB 内存。GPU 核多但每个核分到的显存(显卡上的内存)有限——8 GB 显存同时只能跑约 128 路, ASIC 专用芯片同样受制于内存的物理成本9。Argon2 2015 年赢得密码哈希 竞赛,是 OWASP 首推9。三个变体里 Argon2id 是混合推荐; 它的代价也要知道:原生编译依赖(极简 Docker 镜像里可能装不上)、 高并发下内存尖峰(每次哈希 64 MB)、比 bcrypt 年轻9。
实战两个调用就够:argon2.hash(password, { type: argon2id }) 和
argon2.verify(storedHash, candidate)。产出的哈希字符串自描述——
$argon2id$v=19$m=65536,t=3,p=4$… 里算法、版本、内存/时间/并行参数、
盐、哈希值全在里面:盐自动生成内嵌(不用单独管理),将来调参数升强度,
老哈希照样能验10。verify 内部用常数时间比较——无论密码对错
耗时一致,堵死「掐表猜匹配位数」的定时攻击10。
7. 作者的判断与证据
- 「托管认证服务 vs 自建」的对照(含「学了才有意义」的立场)是明确的价值 判断,理由(黑盒 vs 知情决策)说得直白4。
- 「payload 永不放敏感信息」是硬性安全口径,配 jwt.io 可亲手验证的说法5。
- 「Argon2id 是 2026 年的默认选择,bcrypt 仍算合格」符合 OWASP 通行口径9。
- 「jose 替代 jsonwebtoken」的理由(ESM 原生、零依赖、维护者是 OAuth/OIDC 规范作者)客观可查11。
8. 边界与局限
- 非对称签名(RS256/ES256)没讲:本章只用对称密钥(HS256)—— 多服务验签场景该用公私钥,是常见进阶缺口。
- pepper 提到了但没落地实现(本章实现只有盐,靠 Argon2 内嵌); 严格按自己讲的威胁模型,这是留白。
- OAuth/OIDC 只是导览:PKCE、token exchange 细节指向了库, 没给代码(本书明确不实现社交登录)。
- 「15 分钟 / 7 天」是通行默认值而非标准,按风险自定(本节为 2026 年 中时点口径)。
判断(我们的,不是书里的): 本章把「签名防篡改、不防偷看」和 「盐保唯一、胡椒保库不够」这两对对称的「XX 防 A 不防 B」讲在机制层, 是全书安全素养的核心一节——多数安全事故不是没加密,而是把编码当加密、 把库当全部。这样的「能力边界」表述比清单式安全建议耐用得多。 如果错,会错在: 双令牌模型在 2026 年正被挑战——浏览器侧趋向 BFF(后端为前端)模式把 refresh 完全收回服务器,SPA 直存 access 的方案 越来越被视为反模式;本书模型仍是教学最优解,但不是行业唯一答案。
9. 可带走的
- 认证=你是谁(401),授权=你能做什么(403);认证永远在前。
- 四路线:会话(有状态)、JWT(自包含)、OAuth(替密码不替 JWT)、托管(外包)。
- JWT 三段:header(算法)、payload(claims)、signature;Base64Url 可解码 ≠ 加密。
- 验签只需密钥,不需查库——这是无状态的确切含义。
- 双令牌:access 15 分钟走内存,refresh 7 天住 httpOnly cookie; 旋转 = 一次一换,旧 refresh 复现即警报。
- 密码存哈希;盐防彩虹表(保唯一),胡椒防「库即全部」(存库外)。
- 哈希要慢;Argon2id 靠吃显存/GPU 的内存瓶颈取胜,OWASP 首推。
- argon2 哈希串自描述(参数+盐内嵌),verify 常数时间比较。
10. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| API 的三个缺口 | Security gap in our API | text/85-fm-security-gap-in-our-api.txt:23(搜「No questions asked」) |
| authn vs authz 与 401/403 | Authentication versus authorization | text/86-fm-authentication-versus-authorization.txt:11(搜「who are you」) · text/86-fm-authentication-versus-authorization.txt:49(搜「401 Unauthorized」) |
| 无状态验签 | How verification works | text/89-fm-how-verification-works.txt:11(搜「does not need a database lookup」) |
| 四路线对照 | Common authentication approaches | text/87-fm-common-authentication-approaches.txt:7(搜「Session-based」) · text/87-fm-common-authentication-approaches.txt:57(搜「does not replace JWT」) · text/87-fm-common-authentication-approaches.txt:109(搜「Once you understand the fundamentals, ad」) |
| JWT 三段结构 | Structure of a JWT | text/88-fm-structure-of-a-jwt.txt:5(搜「three parts」) |
| 编码非加密 | Structure of a JWT | text/88-fm-structure-of-a-jwt.txt:38(搜「The header is Base64Url encoded, not enc」) |
| 签名公式 | Structure of a JWT | text/88-fm-structure-of-a-jwt.txt:133(搜「HMACSHA256」) |
| 不可吊销问题 | Access tokens and refresh tokens | text/90-fm-access-tokens-and-refresh-tokens.txt:7(搜「cannot revoke」) |
| 双令牌对照表 | Access tokens and refresh tokens | text/90-fm-access-tokens-and-refresh-tokens.txt:21(搜「short-lived, typically 15 minutes.」) |
| 旋转与警报 | Access tokens and refresh tokens | text/90-fm-access-tokens-and-refresh-tokens.txt:95(搜「rotation」) |
| jose 选型 | Why jose instead of jsonwebtoken? | text/91-fm-why-jose-instead-of-jsonwebtoken.txt:11(搜「CommonJS-only」) |
| 哈希单向性 | Why hash passwords? | text/92-fm-why-hash-passwords.txt:9(搜「A hash function is a one-way mathematica」) |
| 彩虹表与盐 | Why hash passwords? | text/92-fm-why-hash-passwords.txt:55(搜「rainbow」) · text/92-fm-why-hash-passwords.txt:81(搜「This is where pepper adds value.」) |
| Argon2 内存困难 | Why Argon2 over bcrypt? | text/93-fm-why-argon2-over-bcrypt.txt:29(搜「The key innovation is that Argon2 is mem」) |
| 哈希串自描述 | Using Argon2 in practice | text/94-fm-using-argon2-in-practice.txt:39(搜「self-describing」) |
| 常数时间比较 | Using Argon2 in practice | text/94-fm-using-argon2-in-practice.txt:89(搜「constant-time」) |
Footnotes
-
出处:「Security gap in our API」第 3–21 段(
text/85-fm-security-gap-in-our-api.txt:27,搜「read all data」;:19,搜「no concept of identity」)。 ↩ ↩2 -
出处:「Authentication versus authorization」第 3–11 段(
text/86-fm-authentication-versus-authorization.txt:11,搜「who are you」;:13,搜「what can you do」)。 ↩ ↩2 -
出处:「Authentication versus authorization」第 19–27 段(
text/86-fm-authentication-versus-authorization.txt:49,搜「A 401 Unauthorized response means authen」;:23,搜「403」;:25,搜「misleading」)。 ↩ -
出处:「Common authentication approaches」第 3–71 段(
text/87-fm-common-authentication-approaches.txt:15,搜「stateful」;:39,搜「does not replace JWT」;:53,搜「Managed authentication services」;:65,搜「black box」)。 ↩ ↩2 ↩3 ↩4 -
出处:「Structure of a JWT」第 3–55 段(
text/88-fm-structure-of-a-jwt.txt:5,搜「three parts」;:31,搜「not encrypted」;:47,搜「tampering」)。 ↩ ↩2 ↩3 -
出处:「How verification works」第 3–15 段(
text/89-fm-how-verification-works.txt:11,搜「does not need a database lookup」;:13,搜「all share the same secret key」)。 ↩ -
出处:「Access tokens and refresh tokens」第 3–31 段(
text/90-fm-access-tokens-and-refresh-tokens.txt:7,搜「cannot revoke」;:17,搜「15 minutes」;:29,搜「rotation」;:31,搜「invalidate all tokens」)。 ↩ ↩2 ↩3 ↩4 -
出处:「Why hash passwords?」第 3–55 段(
text/92-fm-why-hash-passwords.txt:9,搜「A hash function is a one-way mathematica」;:25,搜「rainbow table」;:33,搜「salt」;:35,搜「pepper」;:55,搜「deliberately slow」)。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
出处:「Why Argon2 over bcrypt?」第 3–27 段(
text/93-fm-why-argon2-over-bcrypt.txt:19,搜「millions of bcrypt passwords」;:13,搜「memory-hard」;:17,搜「OWASP」;:23,搜「Argon2id」)。 ↩ ↩2 ↩3 ↩4 ↩5 -
出处:「Using Argon2 in practice」第 13–33 段(
text/94-fm-using-argon2-in-practice.txt:39,搜「self-describing」;:27,搜「old hashes」;:31,搜「constant-time」)。 ↩ ↩2 -
出处:「Why jose instead of jsonwebtoken?」第 3–15 段(
text/91-fm-why-jose-instead-of-jsonwebtoken.txt:11,搜「CommonJS-only」;:9,搜「IETF」)。 ↩