跳转至

认证落地 — 注册登录、中间件栈、令牌生命周期与安全加固

这一章讲什么: 上一章的概念在这里全部变成代码:验证中间件、 认证中间件、授权中间件、刷新端点、登出端点,以及把它们排好顺序的 「完整中间件栈」。读完你能照着搭出一套带角色权限、令牌旋转、 安全头部的认证系统,并说清楚每个零件为什么在那个位置。

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:

  1. 注册:201;响应体里没有 password;set-cookie 含 HttpOnly。
  2. 401/403 之分:无 token 打 /api/v1/users → 401;普通用户带 token → 403。
  3. 登出即失效:登出后再拿旧 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. 可带走的

  1. Zod:一处 schema,运行时校验 + 编译期类型兼得;validate 中间件把 parse 后的 body 交下去。
  2. 密码规则:最小 8 位,别搞复杂度军备赛。
  3. RefreshToken 存库为吊销;global omit 默认藏 password,登录显式解禁。
  4. access/refresh 双密钥;jti 防撞唯一约束。
  5. 登录失败永远一句「Invalid email or password」——防用户枚举。
  6. authenticate(验身份,挂 req.user)必须在 authorize(查角色)之前。
  7. 刷新端点:验签→查库→事务内旋转;前端刷新要请求去重。
  8. 登出删库(用 deleteMany);改密码=删该用户全部 refresh。
  9. helmet 三主力:CSP 防 XSS、HSTS 强制 HTTPS、nosniff;认证端点限流 5 次/15 分钟且跳过成功请求。
  10. 存储三策略按 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」)


  1. 出处:「Password validation rules」第 3–13 段与 27–31 段(text/95-fm-password-validation-rules.txt:9,搜「stop there」;:31,搜「z.infer」)。 ↩↩↩

  2. 出处:「The RefreshToken model」第 3–25 段(text/97-fm-the-refreshtoken-model.txt:5,搜「need to store refresh tokens in the data」;:13,搜「Cascade」)。 ↩

  3. 出处:「The RefreshToken model」第 23–29 段(text/97-fm-the-refreshtoken-model.txt:45,搜「Why store refresh tokens」)。 ↩

  4. 出处:「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」)。 ↩

  5. 出处:「The auth routes」第 21–45 段(text/102-fm-the-auth-routes.txt:56,搜「not in the response body」;:31,搜「Every flag」;:41,搜「path」)。 ↩↩

  6. 出处:「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」)。 ↩↩

  7. 出处:「Token utility functions」第 31–41 段(text/100-fm-token-utility-functions.txt:52,搜「separate secrets」;:39,搜「unique constraint violation」)。 ↩

  8. 出处:「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」)。 ↩↩

  9. 出处:「The authorization middleware」第 3–15 段(text/108-fm-the-authorization-middleware.txt:23,搜「higher-order」)。 ↩

  10. 出处:「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」)。 ↩↩↩

  11. 出处:「The refresh endpoint」第 27–67 段(text/111-fm-the-refresh-endpoint.txt:64,搜「defensive at every step」;:37,搜「orphan」;:47,搜「deduplication」)。 ↩↩↩↩

  12. 出处:「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」)。 ↩↩↩

  13. 出处:「Helmet for security headers」第 5–25 段(text/115-fm-helmet-for-security-headers.txt:23,搜「Content-Security-Policy」;:15,搜「Strict-Transport-Security」)。 ↩

  14. 出处:「Rate limiting」第 3–25 段(text/116-fm-rate-limiting.txt:22,搜「skipSuccessfulRequests: true,」;:21,搜「just as sensitive」)。 ↩

  15. 出处:「Token storage strategies」第 1–51 段(text/117-fm-token-storage-strategies.txt:9,搜「three viable strategies」;:45,搜「IETF」;:47,搜「worth the added complexity」)。 ↩↩↩

  16. 出处:「SameSite cookies and why they are not enough」第 3–15 段(text/118-fm-samesite-cookies-and-why-they-are-not-enough.txt:13,搜「inconsistently」)。 ↩

  17. 出处:「The complete middleware stack」第 3–31 段(text/119-fm-the-complete-middleware-stack.txt:31,搜「before the server spends resources」)。 ↩

  18. 出处:「Additional security notes」第 3–15 段(text/120-fm-additional-security-notes.txt:7,搜「Always use HTTPS」;:11,搜「parameterized queries」;:15,搜「10kb」)。 ↩

  19. 出处:「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」)。 ↩↩

  20. 出处:「Password validation rules」第 55–89 段(text/95-fm-password-validation-rules.txt:169,搜「The OPAQUE protocol (RFC 9807, July 2025」;:87,搜「inherently immune」)。 ↩