SaaS 后台的 DSL 改造(三):BFF 防御体系——zod 声明式校验、签名与责任链
上一篇讲了框架底座:BFF 层是被不规范的后端接口逼出来的,它负责统一出入参、聚合数据、隐藏实现细节,跑在手写的 puzzles-core 上。
BFF 要当后端和前端之间的闸门,光会转发不够,得先保证放进来的请求是合法的、来源是可信的。这一篇讲闸门的防御体系:routerSchema 声明式参数校验、签名校验,以及把这两者串起来的责任链。照例,代码按真实实现精简过,结构是真的,细节不全。
1. 中间件链:五道关卡按序执行
防御不是在某个单点做的,是一串中间件各管一段。请求进来到路由执行,依次过这五道:
flowchart LR R[请求] --> M1[errorHandler<br/>兜底异常] M1 --> M2[matchRouter<br/>提前解析路由参数] M2 --> M3[apiParamsVerify<br/>参数校验] M3 --> M4[apiSignVerify<br/>签名校验] M4 --> M5[injectProjectParams<br/>注入券商标识] M5 --> RT[路由执行]
顺序有讲究。errorHandler 必须最前:Koa 是洋葱模型,先注册的中间件包住后注册的,它的 try/catch 要罩住后面所有环节,任何一关炸了都有统一出口。matchRouter 紧随其后,因为后面的校验要用到路由参数(第三节讲这个坑)。然后是参数校验和签名,最后注入券商标识——这个标识是 DSL 机制的关键输入,第四篇展开。
2. routerSchema:按路由和 method 声明规则
参数校验的第一问题是规则写在哪。散写在每个 controller 里,就是旧方案的老路:每个人写法不一样,漏写了也没人知道。
我们的做法是把规则集中到 app/routerSchema/ 目录,按"路由 × HTTP method"声明,用 zod 描述:
// app/routerSchema/bussiness.ts
export default {
[PRODUCT_MAIN]: generateRouterSchema({
post: {
body: z.object({
name: z.string(),
description: z.string(),
price: z.coerce.number(),
}),
header: z.object(getSignVerifyRules()).merge(z.object(getProjectKeyVerifyRules())),
},
delete: {
body: z.object({ id: z.string() }),
header: z.object(getSignVerifyRules()).merge(z.object(getProjectKeyVerifyRules())),
},
}),
}几个设计点。规则按 method 分:同一个路由,POST 要校验 name/description/price,DELETE 只要 id,互不干扰。公共规则抽成函数:getSignVerifyRules() 产出签名相关的 header 规则,哪个接口要签名就 merge 进去,签名规则只维护一份。z.coerce.number() 处理 query 传过来全是字符串的现实,校验通过的同时完成类型转换。
routerSchema 文件本身也被 loader 管着:丢进 routerSchema/ 目录就自动加载合并,和上一篇的目录即注册表是同一套机制。
3. 踩坑:中间件阶段拿不到 ctx.params
校验中间件要做的事是"拿当前请求的路由,去 routerSchema 里查规则,再校验"。听起来简单,实际一脚踩进 Koa 的坑里。
Koa 的中间件链和路由是两层。router.get('/api/product/:id', handler) 这种路径里的 :id,是路由层的 routes() 中间件执行时才解析出来挂到 ctx.params 上的。而 routes() 在所有自定义中间件之后才执行。也就是说,在我的校验中间件里,ctx.params 是空的,ctx.routePath 也不存在——我既不知道参数值,也不知道该拿哪条规则来校验。
第一反应是把校验挪到路由层做,每个路由 handler 前面挂一个。试了一下就放弃了:规则和应用彻底散掉,等于回到旧方案。
最后的解法是给中间件链最前面加一道 matchRouter:手动调 router 的 match 方法,提前把路由解析跑一遍,把结果挂到 ctx 上:
export default (app) => {
return async (ctx, next) => {
const matched = app.routerMiddleware.match(ctx.path, ctx.method)
if (matched?.route) {
const layer = matched.path[0]
ctx.captures = layer.captures(ctx.path)
ctx.params = layer.params(ctx.path, ctx.captures, ctx.params)
ctx.routerPath = layer.path // 关键: 拿到的是 /api/product/:id 这个模式
}
await next()
}
}最有价值的是 ctx.routerPath:它是 /api/product/:id 这样的路由模式,不是 /api/product/123 这种实际路径。routerSchema 的 key 是路由模式,有了它才能查出对应的规则。同一个 match 结果顺带把 ctx.params 也解析好了,后面的中间件和路由层都能用。
要点:
- Koa 的路由参数在
routes()执行时才解析,中间件阶段默认拿不到 - 提前 match 一次,把路由模式和参数都挂到 ctx 上,一次解析全链复用
- 校验规则查的是路由模式,不是实际路径
4. 责任链:校验规则的执行引擎
拿到规则之后,一个请求最多要过四道校验:header、params、query、body。任意一道失败就熔断,没必要继续。
最开始写的是四个 if 串行,能用,但每加一种校验位置就要改主流程。后来抽了一个通用的责任链:
export class ResponsibilityChain<T extends (...args: any[]) => MaybePromise<boolean>> {
private handlers: T[] = []
public add(handler: T) {
this.handlers.push(handler)
return this
}
public async handle(...args: Parameters<T>): Promise<void> {
for (const handler of this.handlers) {
if (await handler(...args)) // 返回 true = 已处理 中断链条
break
}
}
}契约很简单:handler 返回 true 表示"这事我处理了",链条中断;返回 false 表示"不归我管",交给下一个。校验中间件里的用法:
await new ResponsibilityChain()
.add(validateHeader) // 规则里没有 header 就放行 校验失败就中断
.add(validateParams)
.add(validateQuery)
.add(validateBody)
.handle(ctx, schema)每个 handler 内部是同一个模式:规则里没有自己负责的部分,返回 false 放行;有就 safeParse,失败时记日志、标记校验不通过、返回 true 熔断。
这个链条后来在 errorHandler 里也复用了:模板渲染错误(template not found)由专门的 handler 接管,302 到 404 页;其他异常落到兜底 handler,统一返回 500。一个模式两处用,加新的错误类型就是 add 一个 handler 的事。
5. 校验失败,只说"参数错误"
有一个细节是特意这么做的:校验失败时,具体的 zod 错误信息(哪个字段、期望什么类型)只写进服务端日志,返回给前端的永远是干巴巴的两个字:
// 详细错误只进日志
app.logger.error('[-- body error --]', formatZodError(error))
// 返回给前端的只有这个
Object.assign(ctx, getErrorResponse('参数错误', ResponseCode.PARAM_ERROR))原因是防探测。详细的校验错误等于把接口的入参结构免费告诉调用方:哪个字段必填、类型是什么、取值范围多少。对正常用户这些信息前端表单早就约束了,用不上;对恶意调用方,这就是枚举接口的说明书。内外信息要分开,这是 BFF 当闸门的基本自觉。
6. 签名:防君子不防小人的一道锁
最后一道是签名校验,防的是接口被绕开前端直接乱调。机制很朴素:
const { s_sign, s_t } = ctx.headers
// 服务端用同一个 key 和时间戳重算签名
const signature = sign(`${SIGN_KEY}_${s_t}`)
if (!s_sign || !s_t
|| signature.toLowerCase() !== s_sign.toLowerCase()
|| Number(s_t) + SIGN_EXPIRED_TIME < Date.now()) {
return Object.assign(ctx, getErrorResponse('Invalid signature', ResponseCode.SIGN_ERROR))
}前端请求带两个 header:时间戳 s_t 和签名 s_sign。签名是 key + 时间戳 的 md5。服务端重算比对,不一致拒绝;一致还要检查时间戳没超过有效期(我们设了 10 分钟),防的是签名被截获后反复重放。
为什么不用更复杂的方案(HMAC、非对称、nonce 防重放)?因为这套后台是 B 端内部系统,威胁模型到不了那一步。这道锁的目标是抬高乱调接口的门槛,顺便让所有请求带上有用的时间戳,不是为了对抗专业攻击者。安全方案的强度要和威胁模型匹配,超配也是成本。
7. 类型联动:写错路由名,编译期就报错
routerSchema 集中到一处之后,类型上可以白捡一个好处。所有 schema 文件合并出的对象,它的 key 就是全部受保护路由的集合:
// app/routerSchema/index.ts
const _rules = { ...project, ...bussiness }
export type RulesTypeKeys = keyof typeof _rules校验中间件查规则时,用 RulesTypeKeys 约束 key 的类型。路由常量写错了、拼写和 schema 里声明的不一致,编译期直接红线,不用等到运行时。
这是上一篇类型生成思路的延续:框架能自动知道的,就别靠人自觉。
小结
这一篇讲了 BFF 的防御体系:五道中间件按序执行、routerSchema 按路由和 method 集中声明 zod 规则、责任链做校验熔断、校验失败详情只进日志、签名加时间戳过期防乱调和重放,以及中间件拿不到 ctx.params 那个 Koa 坑的解法。下一篇进全系列的核心:DSL 的 model/project 双层模型和那个合并算法。