06 JWT 鉴权中间件

登录接口签发 JWT 后,所有 Sakila 管理接口都要验证管理员身份。本章把重复验证提取为 Express 中间件,并使用 TypeScript declaration merging 扩展 req.admin

1. 中间件在请求链路中的位置

客户端携带 Authorization
        ↓
authenticateAdmin
  解析 Bearer Token
  verify 签名和标准声明
  校验 Payload 结构
  查询数据库管理员
  检查状态与 tokenVersion
  写入 req.admin
        ↓
业务 Router / Controller

PM2 不负责鉴权;Nginx 也不会自动理解你的管理员 JWT。认证逻辑属于 Express 应用。

2. 受保护接口契约

客户端请求:

GET /api/sakila/actors
Authorization: Bearer eyJ...

未提供 Token:

{
  "code": "AUTH_REQUIRED",
  "message": "请先登录",
  "data": null
}

Token 无效或过期:

{
  "code": "INVALID_ACCESS_TOKEN",
  "message": "登录状态无效,请重新登录",
  "data": null
}

账号已禁用:

{
  "code": "ACCOUNT_DISABLED",
  "message": "账号已被禁用",
  "data": null
}

这些响应按照项目约定仍是 HTTP 200。代价是 Nginx 和通用监控看不到 401/403,需要通过 JSON code 和结构化日志判断认证失败。

3. req.admin 的最小类型

不要把完整 Sequelize Model 挂在 Request 上。Controller 通常只需要:

export interface AuthenticatedAdmin {
  id: string;
  username: string;
  role: "manager";
}

这样可以避免后续代码意外读取或序列化:

passwordHash
tokenVersion
failedLoginCount
其他内部字段

4. 使用 types/express.d.ts 扩展 Request

建议创建:

types/express.d.ts

内容骨架:

import type { AuthenticatedAdmin } from "./auth";

declare global {
  namespace Express {
    interface Request {
      admin?: AuthenticatedAdmin;
    }
  }
}

export {};

为什么是可选属性:

确保 tsconfig.jsoninclude 能覆盖该 .d.ts 文件。不要为此随意修改模块模式;当前项目继续使用 TypeScript import,编译为 CommonJS。

如果 Controller 确定注册在鉴权中间件后,可以先显式保护:

if (!req.admin) {
  throw new AppError("AUTH_REQUIRED", "请先登录");
}

进阶阶段再设计专门的 AuthenticatedRequest,不要一开始到处写:

req.admin!

非空断言只让 TypeScript 闭嘴,不会让运行时数据真的存在。

5. Bearer Token 解析

合法形式:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

需要拒绝:

没有 Authorization
Basic abc
Bearer
Bearer token extra
Token 为空或空白分段数量不对

解析函数骨架:

export function parseBearerToken(
  authorization: string | undefined,
): string | null {
  // TODO 1:没有 Header 时返回 null
  // TODO 2:按空白分为 scheme 和 token
  // TODO 3:不区分大小写地检查 scheme 是否为 Bearer
  // TODO 4:确保只有一个非空 token
  return null;
}

不要用不受约束的字符串替换:

authorization.replace("Bearer", "")

它可能接受本不应该接受的格式。

HTTP authentication scheme 按规范不区分大小写,因此 BearerbearerBEARER 都应识别;Token 内容本身不能随意改变大小写。解析仍要严格保证只有 scheme 和一个非空 Token。

6. verify() 而不是 decode()

验证调用形状:

const decoded = jwt.verify(token, jwtSecret, {
  algorithms: ["HS256"],
  issuer: jwtIssuer,
  audience: jwtAudience,
});

verify() 会验证:

jwt.verify() 的返回类型可能是字符串或对象,不能直接断言为业务 Payload。你还需要运行时类型守卫。

export function isAdminAccessTokenPayload(
  value: unknown,
): value is AdminAccessTokenPayload {
  // TODO:检查 value 是非 null 对象
  // TODO:检查 sub、role、tokenVersion 的运行时类型和值
  return false;
}

TypeScript 类型只在编译期存在,不能阻止攻击者发送任意 Token 内容。

7. 为什么验证签名后还要查询数据库

JWT 签发后可能仍未过期,但管理员状态已经变化:

因此本练习要求每次鉴权根据 sub 查询管理员,并验证:

记录存在
accountStatus 是 Active
数据库 tokenVersion === JWT tokenVersion

这会增加一次数据库查询,但换来较及时的账号禁用和 Token 撤销能力。以后可以讨论短 TTL、缓存或会话表,现在先保证行为清楚。

8. 中间件骨架

import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

export async function authenticateAdmin(
  req: Request,
  res: Response,
  next: NextFunction,
): Promise<void> {
  try {
    const token = parseBearerToken(req.get("authorization"));

    if (token === null) {
      throw new AppError("AUTH_REQUIRED", "请先登录");
    }

    // TODO 1:使用 verify,固定 algorithms/issuer/audience
    // TODO 2:用类型守卫校验 Payload
    // TODO 3:按 payload.sub 查询管理员,只选必要字段
    // TODO 4:检查账号状态
    // TODO 5:比较数据库与 Token 的 tokenVersion
    // TODO 6:只挂载最小 req.admin

    next();
  } catch (error: unknown) {
    // TODO:将 TokenExpiredError、JsonWebTokenError 等映射为公开业务错误
    // 未知数据库错误应交给全局错误处理器,不要伪装成 Token 错误
    next(error);
  }
}

一个常见错误是:

catch {
  next(new AppError("INVALID_ACCESS_TOKEN", "登录状态无效"));
}

这会把数据库断线等服务器故障也伪装成 Token 错误。只映射明确认识的 JWT 错误,未知错误继续向后传递。

9. Router 注册顺序

公开接口和受保护接口要分清:

app.use("/api/auth", authPublicRouter);
app.use("/api/auth", authenticateAdmin, authProtectedRouter);
app.use("/api/admin", authenticateAdmin, adminRouter);
app.use("/api/sakila", authenticateAdmin, sakilaRouter);

也可以在 Router 内统一保护:

const router = Router();

router.use(authenticateAdmin);
router.get("/actors", listActorsController);
router.post("/actors", createActorController);

错误顺序:

router.get("/actors", listActorsController);
router.use(authenticateAdmin);

Express 中间件从上到下执行,已经注册在前面的路由不会被后面的鉴权保护。

10. Controller 中使用管理员身份

export async function createActorController(
  req: Request<
    Record<string, never>,
    ApiResponse<ActorDetailDto>,
    CreateActorBody
  >,
  res: Response<ApiResponse<ActorDetailDto>>,
): Promise<void> {
  if (!req.admin) {
    throw new AppError("AUTH_REQUIRED", "请先登录");
  }

  const actor = await createActor(
    {
      // TODO:白名单提取业务输入
    },
    {
      operatedByAdminId: req.admin.id,
    },
  );

  res.json({ code: "OK", message: "创建成功", data: actor });
}

这里的 adminId 可用于结构化日志或审计日志,不应强行写入 Sakila 原始 actor 表。

11. 前端调用示例

先登录并保存响应中的 Token,然后:

curl -i \
  -H 'Authorization: Bearer eyJ...' \
  http://127.0.0.1:8080/api/sakila/actors

测试缺少 Token:

curl -i http://127.0.0.1:8080/api/sakila/actors

测试伪造 Token:

curl -i \
  -H 'Authorization: Bearer not-a-jwt' \
  http://127.0.0.1:8080/api/sakila/actors

12. 新人常见问题

只调用 decode()

结果:任何人都能伪造管理员 Payload。

只验证签名,不校验算法、issuer、audience

结果:其他环境或其他系统签发的 Token 可能被误用。

只验证 JWT,不查数据库

结果:账号禁用或修改密码后,旧 Token 在过期前仍可使用。

把整个 AdminUser 实例挂到 Request

结果:敏感字段更容易被错误序列化或记录。

next() 后继续发送响应

next();
res.json(...);

结果:后续 Controller 也可能发送响应,造成 headers already sent。调用 next() 后应结束当前分支。

把所有错误都改成登录失效

结果:数据库故障被掩盖,客户端和日志得到错误结论。

13. 验收清单

14. 练习题

  1. 为什么 Authorization 解析不能只调用 replace("Bearer", "")
  2. TypeScript 已声明 Payload 类型,为什么仍要运行时类型守卫?
  3. JWT 签名正确为什么仍可能不允许访问?
  4. 每次鉴权查数据库的优点和成本分别是什么?
  5. 为什么 req.admin 应只保存最少字段?
  6. 数据库断线时为什么不能返回 INVALID_ACCESS_TOKEN
  7. Router 中鉴权中间件注册在业务路由后会发生什么?
  8. 项目统一 HTTP 200 后,如何统计鉴权失败率?