认证落地 — 注册登录、中间件栈、令牌生命周期与安全加固
认证落地 — 注册登录、中间件栈、令牌生命周期与安全加固
这一章讲什么: 上一章的概念在这里全部变成代码:验证中间件、 认证中间件、授权中间件、刷新端点、登出端点,以及把它们排好顺序的 「完整中间件栈」。读完你能照着搭出一套带角色权限、令牌旋转、 安全头部的认证系统,并说清楚每个零件为什么在那个位置。
1. 入口把关:Zod 与「一处定义,两种类型」
注册/登录的输入校验交给 Zod。密码规则按 OWASP 2026 口径,简单得出人
意料:最小 8 位(推荐 12)、上限放宽到 64+ 就停——不要强制大小写、
特殊符号、数字:复杂度规则(必须含大小写/符号之类的要求)逼用户造出 P@ssw0rd! 这种可预测的密码,
而不是 beam me up scotty 这种长而易记的口令1。
Zod 的高明处在同一个 schema 同时是运行时校验器和编译期类型:
z.infer<typeof registerSchema> 直接导出 RegisterInput 类型——
前端表单(第 15 章)复用同一份定义,前后端校验永远一致1。
配套一个可复用的 validate(schema) 中间件:schema.safeParse(req.body)
不抛异常地校验;失败回 400 并带逐字段错误;成功则把 req.body 替换
成 parse 后的数据——Zod 做的转换(如去空格)在路由处理器跑之前生效1。
2. 数据模型:密码、角色、刷新令牌,以及「默认隐藏密码」
User 模型加 password(存 Argon2 哈希)和 role(枚举默认 USER,
新用户不自动是管理员);新建 RefreshToken 模型:token @unique
(唯一索引顺带有了)、expiresAt、@@index([userId])(登出全部会话
时按用户查)和 @@index([expiresAt])(清理过期用),onDelete: Cascade
连用户清令牌2。为什么 refresh 存库而 access 不存:access 的设计
就是无状态(验签即可,快,但单个不可吊销);refresh 必须可吊销——登出删它,
改密码删该用户全部令牌,逼所有设备重新认证3。
全局 omit(全局省略)是本章最优雅的防呆:创建 client 时声明
omit: { user: { password: true } },之后每条 User 查询默认不含
password——API 响应、调试日志、join 全都不会漏它;只有登录时要显式
omit: { password: false } 才拿得到。「危险数据默认隐藏,要看得
consciously 申请」远比「每个查询记得 select」可靠4。
3. 注册与登录:业务在 service,路由只调度
层次刻意分明:
路由(薄) → validate 中间件 → authService → prisma + argon2 + jwt
POST /api/auth/register
① Zod 校验(失败 400)
② registerUser:argon2.hash(password, argon2id)
③ prisma.user.create(哈希入库)
④ 签发 access(含 role)与 refresh
⑤ refreshToken 入库(expiresAt = 7 天后)
⑥ setRefreshTokenCookie(res, refreshToken) —— 只走 cookie,不进响应体
⑦ 响应体回 { user, accessToken }(201)refresh 永不出现在响应体里——它住在 httpOnly cookie,JS 读不到5。
cookie 的每个旗标都有职务:httpOnly 防 XSS 偷令牌;secure 生产环境
只走 HTTPS;sameSite: 'strict' 防 CSRF;path: '/api/auth' 让它
只随认证端点发送,不骚扰普通请求5。
一个字的防枚举:登录失败时,「邮箱不存在」和「密码错误」回同一句
Invalid email or password。分开回 = 告诉攻击者哪些邮箱注册了
(用户枚举),等于替暴力破解和钓鱼筛选目标6。重复邮箱走 P2002 →
try/catch 转成友好的 AuthError;其它错误照旧上抛——Express 5 自动接
异步错误,路由里不用 try/catch6。
工具层两个细节:access 和 refresh 用两把不同的密钥(一把泄露,
另一把还活着);每个令牌都 setJti(randomUUID())——没有 jti,
同一用户同一秒签的两个 refresh 载荷完全相同,签名也相同,
入库时第二个撞唯一约束7。
4. 双中间件与权限矩阵:authenticate 在前,authorize 在后
authenticate(src/middleware/authenticate.ts):从
Authorization: Bearer <token> 抽 token,jwtVerify 验签;
把 { id, role } 挂到 req.user,调 next()。缺失/无效/过期一律 401,
但错误码分开:TOKEN_EXPIRED 前端应静默刷新,INVALID_TOKEN
前端该跳登录页——同一个 401,两种处置8。
TypeScript 怎么认识 req.user:用声明合并扩展 Express 的
Request 接口,且 user 是可选字段——未认证的路由里它就该可能是
undefined8。
authorize 是个高阶函数:authorize('ADMIN', 'MODERATOR')
返回一个中间件,查 req.user.role 在不在允许列表,不在回 4039。
两者自由叠放,叠出一张权限矩阵10:
| 路由 | 公开 | USER | MODERATOR | ADMIN |
|---|---|---|---|---|
| GET /posts | ✓ | ✓ | ✓ | ✓ |
| POST /posts | ✗ | ✓ | ✓ | ✓ |
| PUT/DELETE /posts/:id | ✗ | 仅自己的 | 任意 | 任意 |
| GET /users、DELETE /users/:id | ✗ | ✗ | ✗ | ✓ |
顺序是硬规则:authenticate 必须在 authorize 之前——否则 authorize
去读的 req.user 根本还不存在10。所有权检查(改别人的帖子)
是第三种手段,直接在路由里比 post.authorId !== req.user.id && role !== 'ADMIN'
——不是作者也不是管理员 → 403。这正是 401 与 403 的分界成真的地方:
没 token 在前面已经 401;有 token 但不是你的 → 403,服务器知道你是谁、
只是不批10。
5. 令牌生命周期:刷新(全场最敏感)、登出、大扫除
刷新端点:五步设防,一步都不能少
① 读 cookie → 没有?401
② jwtVerify 验签 → 假令牌/过期?清 cookie,401(不是 500!)
③ 查库 → 不存在或已过期?清 cookie,TOKEN_REVOKED 401
④ 取用户,签发新 access + 新 refresh
⑤ $transaction:删旧 refresh、建新 refresh —— 要么都成,要么都滚
⑥ 新 refresh 进 cookie,新 access 进响应体为什么③必不可少:refresh 是可吊销的,吊销的唯一实现就是它不在库里。 为什么⑤必须用事务:删旧建新之间崩溃,用户会话就孤儿了——这正是第 10 章 交互事务的用武之地11。旋转的检测价值:被偷的旧 refresh 再出现,库里 查无此 token,请求失败;更讲究的实现可以顺势吊销该用户全部令牌11。
前端还有一个隐藏雷:access 过期瞬间,多个组件同时发请求,每个都去
刷新——第一个旋转成功,其余全拿旧 token 撞 401。解法请求去重:
全前端共享一个 refreshInFlight promise,后到者直接 await 同一个;
这也顺带避免并发刷新撞上限流器11。
登出与全端登出
登出:按 cookie 里的 token 删库(deleteMany 而非 delete——
令牌可能已过期或被旋转消费掉,delete 找不到会抛),清 cookie,20412。
「所有设备登出」:按 userId 删该用户全部 refresh token——其它设备上
access 一过期,刷新必败,强制重登12。定时清理:deleteMany 里
expiresAt < now 的,挂在登录时或定时任务上,防 RefreshToken 表无限膨胀12。
6. 加固:Helmet、限流、存储策略与中间件顺序
Helmet 两行代码,每个响应带上 15 个安全头,最重要的三个:
Content-Security-Policy 规定浏览器允许从哪加载脚本/样式——防 XSS
的主防线;Strict-Transport-Security 让浏览器只许 HTTPS 连你的域;
X-Content-Type-Options: nosniff 阻止浏览器把文本猜成 JS 执行13。
限流分两级:普通 API 每 15 分钟 100 次;认证端点 15 分钟 5 次失败,
且 skipSuccessfulRequests: true——正常用户随便登,爆破者每 15 分钟
只配猜 5 次。刷新端点同样挂严限流:它和被偷的 refresh 换 access
一样敏感14。
令牌存储三策略的全景对照(本章最重要的决策表)15:
| hybrid(本书) | 全进 cookie | BFF | |
|---|---|---|---|
| XSS 能偷 access? | 能(内存里) | 不能(httpOnly) | 不能(服务端) |
| XSS 能偷 refresh? | 不能(httpOnly) | 不能 | 不能 |
| API 有 CSRF 风险? | 无(走 Authorization 头,跨站伪造不了头) | 有,需 double submit cookie | 仅会话级 |
| 页面刷新后会话还在? | 需静默刷新一次 | 在 | 在 |
| 复杂度 | 低 | 中 | 高 |
选 hybrid 的理由:最好教、React/Node 生态最常见、不用 CSRF 基建; 处理敏感数据(金融/医疗)的生产系统,值得上 BFF——这也是 IETF 《OAuth 2.0 for Browser-Based Applications》草案和 OWASP 的推荐15。 SameSite=strict 不是 CSRF 的完整答案:各浏览器行为不一、兄弟子域 可绕过、Lax 模式(浏览器默认)对顶层 GET 导航仍放行——所以策略 2 必须另配 double submit cookie 做纵深,不能只靠 SameSite16。
完整中间件栈的顺序(请求自上而下)17:
1. helmet() ← 安全头最先,罩住所有响应
2. rate limiters ← 解析之前拒,省得为恶意请求花解析的钱
3. express.json({limit:'10kb'}) + cookieParser()
4. 各路 routes ← 认证按路由点配
5. prismaErrorHandler → generalErrorHandler ← 永远最后10kb 的 body 上限防「发 2 GB JSON 撑爆内存」这种朴素攻击;生产必须
全站 HTTPS(令牌和密码在途全靠它);Prisma 全参数化查询 + Zod 双层
把关,SQL 注入从结构上被排除18。
7. 测试认证流程
三个集成测试用例,各钉住一个最容易谎报的承诺19:
- 注册:201;响应体里没有 password;
set-cookie含HttpOnly。 - 401/403 之分:无 token 打
/api/v1/users→ 401;普通用户带 token → 403。 - 登出即失效:登出后再拿旧 cookie 刷新 → 401(吊销的是服务端状态, 不是客户端表演)。
单测沿用 mockDeep 套路,连 argon2 也 mock 掉(避开 CI 里的原生编译);
createTestUser 帮手一次注册拿全 user/accessToken/cookie 三件套19。
8. 作者的判断与证据
- 「hybrid 策略为教学默认,BFF 才是敏感数据的终点」是明确的分层建议, 附 IETF/OWASP 背书15。
- 「刷新端点是全场最敏感环节」贯穿五步设防的写法,判断与实现一致11。
- 「OPAQUE/零知识证明是方向」(挑战响应、SPA 直存 access 终将过时) 写进了展望段——这是 2026 年的前沿口径,不是定论20。
- 限流参数(5 次/15 分钟)是经验值,按业务风险自调。
9. 边界与局限
- CSRF 全文只在策略层面出现,double submit cookie 给了概念没给实现;
- 密钥管理(JWT 签名密钥怎么轮换、怎么进密钥管理系统)只说了「生产用密码学 随机生成」,操作细节留给部署章;
- OPAQUE 只是展望——落地需要另起炉灶;
- 「15 分钟/7 天/5 次」都是可调的通行默认,不是标准。
判断(我们的,不是书里的): 「global omit 默认藏密码」和 「401 分 TOKEN_EXPIRED / INVALID_TOKEN 两码」是本章两处「小代码、 大安全」的典范——它们防的不是高深攻击,而是人一定会犯的健忘。 好的安全设计假设操作者会忘事,并把默认值调到忘事也不出事。 如果错,会错在: hybrid 策略的「教学默认」身份意味着它正在变老; 若 2026 之后 BFF 模式因框架支持(Next.js 的中间件、各自的会话方案) 而大幅降本,hybrid 的最佳教学案例地位会被取代——但其概念地图 (XSS 偷什么、CSRF 防什么)依然成立。
10. 可带走的
- Zod:一处 schema,运行时校验 + 编译期类型兼得;validate 中间件把 parse 后的 body 交下去。
- 密码规则:最小 8 位,别搞复杂度军备赛。
- RefreshToken 存库为吊销;global omit 默认藏 password,登录显式解禁。
- access/refresh 双密钥;jti 防撞唯一约束。
- 登录失败永远一句「Invalid email or password」——防用户枚举。
- authenticate(验身份,挂 req.user)必须在 authorize(查角色)之前。
- 刷新端点:验签→查库→事务内旋转;前端刷新要请求去重。
- 登出删库(用 deleteMany);改密码=删该用户全部 refresh。
- helmet 三主力:CSP 防 XSS、HSTS 强制 HTTPS、nosniff;认证端点限流 5 次/15 分钟且跳过成功请求。
- 存储三策略按 XSS/CSRF/刷新存活三轴选型;敏感业务上 BFF。
11. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| OWASP 密码规则与 Zod | Password validation rules | text/95-fm-password-validation-rules.txt:7(搜「OWASP's 2026」) · text/95-fm-password-validation-rules.txt:41(搜「export type RegisterInput = z.infer<type」) |
| validate 中间件 | Password validation rules | text/95-fm-password-validation-rules.txt:69(搜「const result = schema.safeParse(req.body」) |
| OPAQUE 展望 | Password validation rules | text/95-fm-password-validation-rules.txt:169(搜「The OPAQUE protocol (RFC 9807, July 2025」) |
| User/RefreshToken 模型 | The RefreshToken model | text/97-fm-the-refreshtoken-model.txt:5(搜「need to store refresh tokens in the data」) · text/97-fm-the-refreshtoken-model.txt:45(搜「Why store refresh tokens」) |
| global omit | Prisma global omit for password | text/98-fm-prisma-global-omit-for-password.txt:75(搜「With this configuration, every query on 」) |
| 双密钥与 jti | Token utility functions | text/100-fm-token-utility-functions.txt:52(搜「separate secrets」) · text/100-fm-token-utility-functions.txt:70(搜「unique constraint violation」) |
| 防用户枚举 | The auth service | text/101-fm-the-auth-service.txt:140(搜「enumeration,」) |
| cookie 四旗标 | The auth routes | text/102-fm-the-auth-routes.txt:79(搜「Every flag」) |
| authenticate 与错误码 | The authenticate middleware | text/105-fm-the-authenticate-middleware.txt:43(搜「error: 'TOKEN_EXPIRED',」) |
| ownership 403 | Protecting ownership | text/107-fm-protecting-ownership.txt:32(搜「403 Forbidden」) |
| authorize 高阶函数 | The authorization middleware | text/108-fm-the-authorization-middleware.txt:23(搜「higher-order」) |
| 权限矩阵与顺序 | Applying role-based rules | text/109-fm-applying-role-based-rules.txt:73(搜「must always come before」) |
| 刷新五步与事务 | The refresh endpoint | text/111-fm-the-refresh-endpoint.txt:64(搜「defensive at every step」) · text/111-fm-the-refresh-endpoint.txt:74(搜「orphan」) |
| 并发刷新去重 | The refresh endpoint | text/111-fm-the-refresh-endpoint.txt:96(搜「deduplication」) |
| 登出/全端登出/清理 | Log out all sessions | text/113-fm-log-out-all-sessions.txt:26(搜「forced to log in again」) · text/114-fm-cleaning-up-expired-tokens.txt:9(搜「A cleanup function prevents this:」) |
| Helmet 三主力 | Helmet for security headers | text/115-fm-helmet-for-security-headers.txt:23(搜「Content-Security-Policy」) |
| 认证限流 | Rate limiting | text/116-fm-rate-limiting.txt:22(搜「skipSuccessfulRequests: true,」) |
| 三策略对照 | Token storage strategies | text/117-fm-token-storage-strategies.txt:9(搜「three viable strategies」) · text/117-fm-token-storage-strategies.txt:59(搜「IETF」) |
| SameSite 不够 | SameSite cookies and why they are not enough | text/118-fm-samesite-cookies-and-why-they-are-not-enough.txt:11(搜「known limitations」) |
| 中间件栈顺序 | The complete middleware stack | text/119-fm-the-complete-middleware-stack.txt:31(搜「before the server spends resources」) |
| 10kb 与 HTTPS | Additional security notes | text/120-fm-additional-security-notes.txt:7(搜「Always use HTTPS」) |
| 认证集成三测 | Integration tests for auth endpoints | text/123-fm-integration-tests-for-auth-endpoints.txt:70(搜「password never leaks」) |
Footnotes
-
出处:「Password validation rules」第 3–13 段与 27–31 段(
text/95-fm-password-validation-rules.txt:9,搜「stop there」;:31,搜「z.infer」)。 ↩ ↩2 ↩3 -
出处:「The RefreshToken model」第 3–25 段(
text/97-fm-the-refreshtoken-model.txt:5,搜「need to store refresh tokens in the data」;:13,搜「Cascade」)。 ↩ -
出处:「The RefreshToken model」第 23–29 段(
text/97-fm-the-refreshtoken-model.txt:45,搜「Why store refresh tokens」)。 ↩ -
出处:「Prisma global omit for password」第 21–37 段(
text/98-fm-prisma-global-omit-for-password.txt:75,搜「With this configuration, every query on 」;:29,搜「consciously opt in」)。 ↩ -
出处:「The auth routes」第 21–45 段(
text/102-fm-the-auth-routes.txt:56,搜「not in the response body」;:31,搜「Every flag」;:41,搜「path」)。 ↩ ↩2 -
出处:「The auth service」第 33–45 段与「Handling duplicate emails」(
text/101-fm-the-auth-service.txt:140,搜「enumeration,」;text/103-fm-handling-duplicate-emails.txt:7,搜「P2002」)。 ↩ ↩2 -
出处:「Token utility functions」第 31–41 段(
text/100-fm-token-utility-functions.txt:52,搜「separate secrets」;:39,搜「unique constraint violation」)。 ↩ -
出处:「The authenticate middleware」第 3–45 段与「Extending the Express Request type」(
text/105-fm-the-authenticate-middleware.txt:43,搜「error: 'TOKEN_EXPIRED',」;text/104-fm-extending-the-express-request-type.txt:13,搜「declaration merging」)。 ↩ ↩2 -
出处:「The authorization middleware」第 3–15 段(
text/108-fm-the-authorization-middleware.txt:23,搜「higher-order」)。 ↩ -
出处:「Applying role-based rules」第 39–47 段与「Protecting ownership」第 17–25 段(
text/109-fm-applying-role-based-rules.txt:73,搜「must always come before」;text/107-fm-protecting-ownership.txt:32,搜「403 Forbidden」)。 ↩ ↩2 ↩3 -
出处:「The refresh endpoint」第 27–67 段(
text/111-fm-the-refresh-endpoint.txt:64,搜「defensive at every step」;:37,搜「orphan」;:47,搜「deduplication」)。 ↩ ↩2 ↩3 ↩4 -
出处:「The logout endpoint」第 3–13 段、「Log out all sessions」第 15–19 段、「Cleaning up expired tokens」第 3–13 段(
text/112-fm-the-logout-endpoint.txt:7,搜「deleteMany」;text/113-fm-log-out-all-sessions.txt:26,搜「forced to log in again」)。 ↩ ↩2 ↩3 -
出处:「Helmet for security headers」第 5–25 段(
text/115-fm-helmet-for-security-headers.txt:23,搜「Content-Security-Policy」;:15,搜「Strict-Transport-Security」)。 ↩ -
出处:「Rate limiting」第 3–25 段(
text/116-fm-rate-limiting.txt:22,搜「skipSuccessfulRequests: true,」;:21,搜「just as sensitive」)。 ↩ -
出处:「Token storage strategies」第 1–51 段(
text/117-fm-token-storage-strategies.txt:9,搜「three viable strategies」;:45,搜「IETF」;:47,搜「worth the added complexity」)。 ↩ ↩2 ↩3 -
出处:「SameSite cookies and why they are not enough」第 3–15 段(
text/118-fm-samesite-cookies-and-why-they-are-not-enough.txt:13,搜「inconsistently」)。 ↩ -
出处:「The complete middleware stack」第 3–31 段(
text/119-fm-the-complete-middleware-stack.txt:31,搜「before the server spends resources」)。 ↩ -
出处:「Additional security notes」第 3–15 段(
text/120-fm-additional-security-notes.txt:7,搜「Always use HTTPS」;:11,搜「parameterized queries」;:15,搜「10kb」)。 ↩ -
出处:「Integration tests for auth endpoints」第 27–53 段与「Unit tests for the auth service」(
text/123-fm-integration-tests-for-auth-endpoints.txt:70,搜「password never leaks」;:39,搜「401」;:49,搜「invalidates」)。 ↩ ↩2 -
出处:「Password validation rules」第 55–89 段(
text/95-fm-password-validation-rules.txt:169,搜「The OPAQUE protocol (RFC 9807, July 2025」;:87,搜「inherently immune」)。 ↩