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 {};
为什么是可选属性:
- 登录接口和健康检查不会经过鉴权;
- 鉴权中间件执行前,Request 确实没有管理员;
- 全局强行声明为必填会制造虚假的类型安全。
确保 tsconfig.json 的 include 能覆盖该 .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 按规范不区分大小写,因此 Bearer、bearer 和 BEARER 都应识别;Token 内容本身不能随意改变大小写。解析仍要严格保证只有 scheme 和一个非空 Token。
6. verify() 而不是 decode()
验证调用形状:
const decoded = jwt.verify(token, jwtSecret, {
algorithms: ["HS256"],
issuer: jwtIssuer,
audience: jwtAudience,
});
verify() 会验证:
- 签名是否匹配;
exp是否过期;- 当前时间是否早于
nbf(如果存在); - 算法是否在白名单;
- issuer 是否匹配;
- audience 是否匹配。
jwt.verify() 的返回类型可能是字符串或对象,不能直接断言为业务 Payload。你还需要运行时类型守卫。
export function isAdminAccessTokenPayload(
value: unknown,
): value is AdminAccessTokenPayload {
// TODO:检查 value 是非 null 对象
// TODO:检查 sub、role、tokenVersion 的运行时类型和值
return false;
}
TypeScript 类型只在编译期存在,不能阻止攻击者发送任意 Token 内容。
7. 为什么验证签名后还要查询数据库
JWT 签发后可能仍未过期,但管理员状态已经变化:
- 管理员被禁用;
- 管理员被删除;
- 管理员修改密码;
- 管理员执行“退出所有设备”;
tokenVersion已递增。
因此本练习要求每次鉴权根据 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. 验收清单
- [ ] 未携带 Authorization 时返回
AUTH_REQUIRED; - [ ] 非 Bearer 格式被拒绝;
- [ ] 使用
jwt.verify(),没有用decode()代替验证; - [ ] 固定允许的 algorithm、issuer 和 audience;
- [ ] 过期 Token 被拒绝;
- [ ] 修改 Payload 后验证失败;
- [ ] 数据库账号禁用后,旧 Token 立即失效;
- [ ]
tokenVersion不匹配时旧 Token 失效; - [ ]
req.admin不含敏感字段; - [ ] 所有 Sakila Router 都位于鉴权中间件之后。
14. 练习题
- 为什么
Authorization解析不能只调用replace("Bearer", "")? - TypeScript 已声明 Payload 类型,为什么仍要运行时类型守卫?
- JWT 签名正确为什么仍可能不允许访问?
- 每次鉴权查数据库的优点和成本分别是什么?
- 为什么
req.admin应只保存最少字段? - 数据库断线时为什么不能返回
INVALID_ACCESS_TOKEN? - Router 中鉴权中间件注册在业务路由后会发生什么?
- 项目统一 HTTP 200 后,如何统计鉴权失败率?