SaaS 后台的 DSL 改造(三):BFF 防御体系——zod 声明式校验、签名与责任链

6 分钟阅读

上一篇讲了框架底座: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 描述:

typescript
// 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 上:

typescript
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 串行,能用,但每加一种校验位置就要改主流程。后来抽了一个通用的责任链:

typescript
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 表示"不归我管",交给下一个。校验中间件里的用法:

typescript
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 错误信息(哪个字段、期望什么类型)只写进服务端日志,返回给前端的永远是干巴巴的两个字:

typescript
// 详细错误只进日志
app.logger.error('[-- body error --]', formatZodError(error))

// 返回给前端的只有这个
Object.assign(ctx, getErrorResponse('参数错误', ResponseCode.PARAM_ERROR))

原因是防探测。详细的校验错误等于把接口的入参结构免费告诉调用方:哪个字段必填、类型是什么、取值范围多少。对正常用户这些信息前端表单早就约束了,用不上;对恶意调用方,这就是枚举接口的说明书。内外信息要分开,这是 BFF 当闸门的基本自觉。

6. 签名:防君子不防小人的一道锁

最后一道是签名校验,防的是接口被绕开前端直接乱调。机制很朴素:

typescript
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 就是全部受保护路由的集合:

typescript
// 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 双层模型和那个合并算法。

系列 · SaaS 后台的 DSL 改造 · 第 3 篇 / 共 5 篇
01SaaS 后台的 DSL 改造(一):分支地狱——我们为什么推翻重写02SaaS 后台的 DSL 改造(二):框架底座——手写一个 egg 风格的 Koa 加载器
03SaaS 后台的 DSL 改造(三):BFF 防御体系——zod 声明式校验、签名与责任链本篇
04SaaS 后台的 DSL 改造(四):一套代码服务 N 个券商——DSL 继承与合并算法05SaaS 后台的 DSL 改造(五):渲染引擎——一份 DSL 生成整个后台
评论