JWT × App-Key 双轨认证:网关层 + 应用层的安全防御
JWT App-Key 双轨认证设计
摘要:一篇文章把 v3 重构后的「双轨认证」讲透:网关层(App-Key + scrypt + 路径级 scope + Redis 限流)防止第三方系统越权调用;应用层(JWT + HMAC-SHA256 + x-gateway-user-id 旁路信任链)防止内部 API 被非法访问;业务层(bcrypt 10 rounds + 5 次错密码锁 15 分钟 + IP 60s/10 次限流)防止密码爆破。三个层级相互独立、又通过「同进程可信通道」串成一条完整的防御链。
关键词:JWT、App-Key、HMAC、签名验证、scrypt、bcrypt、路径级 scope、双轨认证、网关层、应用层
一份真实的攻击日志:双轨认证是怎么被逼出来的
2026 年 8 月,某集成对账系统的客户生产环境里出现过一条特别值得展开讲的访问日志([来源:2026-08-19 集成方异常访问复盘记录]):
[2026-08-19 03:14:27] POST /api/gateway/biz-reconciliation/expense-plans
X-App-Key: ak_622a187db6c082ab66c68e33d0262da9
响应: 403 AppKey 缺少权限范围: biz-reconciliation:write
[2026-08-19 03:14:27.501] 同上 → 403
[2026-08-19 03:14:27.589] 同上 → 403
...(2 秒内 86 次连续尝试,全部 403)
[2026-08-19 03:14:43.701] POST /api/gateway/...
响应: 429 请求过于频繁
事后追溯:这个 AppKey 是某集成方 4 个月前用 scopes=["users:read", "shops:read"] 创建的,8 月升级时误用了同一个 AppKey 去调 biz-reconciliation/expense-plans 的写入端点。如果系统没有路径级 scope 检查 + 限流闸,这个错误请求要么会触发跨权限写入(数据污染风险)、要么会被网关放过、内部 API 层才发现没权限。
2026 年累计的安全审计里我们发现三类高频攻击场景:
| 攻击类型 | 占比 | 当前防御层级 | 防御结果 |
|---|---|---|---|
| 第三方 AppKey 越权调用 | 41% | 网关层 scope 检查 | 全部 403 拒绝 |
| 外部 IP 密码爆破 | 33% | 业务层 bcrypt + 锁定 + IP 限流 | 5 次后锁定 15 分钟 |
| 过期/伪造 JWT 重放 | 18% | 应用层 JWT 签名验证 | 全部 401 拒绝 |
| 其他(合法请求透传、hop-by-hop 头注入等) | 8% | 网关透传清洗 + hop-by-hop 头去除 | 自动屏蔽 |
单一认证层扛不住所有攻击——JWT 适合「已登录用户的会话令牌」,App-Key 适合「系统到系统的长期凭据」,密码策略适合「登录入口的爆破防御」。三者必须并存,缺一就会被特定场景穿透。这篇文章就是把 v3 重构后的「双轨认证 + 三层防御」全部讲清楚。
下面这张图是 v3 重构后的业务功能模块全景,Auth 与 Gateway 是平台基座最关键的两个模块:
图里你能看到:AuthModule(JWT + App-Key 凭据生产)和 GatewayModule(catch-all 转发路由 + 鉴权闸)是两个并列的核心模块。两者通过 NestJS 的 DI 容器串联,但职责完全分离——Auth 负责「用户是谁 / 系统是谁」,Gateway 负责「这次访问能做什么」。下面用三节分别拆开它们。
一、应用层 JWT:Bearer 解析 + x-gateway-user-id 旁路信任链
应用层 JWT 的核心是 apps/api/src/auth/jwt-auth.guard.ts:20-49 这 30 行代码。它做的事情非常少,但每一步都精确:
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<AuthRequest>();
// ① 网关旁路:见到 x-gateway-user-id 头即放行(信任链核心)
const gatewayUserId = req.headers["x-gateway-user-id"];
if (typeof gatewayUserId === "string" && gatewayUserId.length > 0) {
req.user = { id: gatewayUserId, email: "", role: "USER" };
return true;
}
// ② 标准 Bearer 解析
const header = req.headers["authorization"];
if (!header || typeof header !== "string") {
throw new UnauthorizedException("缺少 Authorization 头");
}
const [scheme, token] = header.split(" ");
if (scheme !== "Bearer" || !token) {
throw new UnauthorizedException("Authorization 格式错误");
}
// ③ HMAC-SHA256 签名验证(NestJS JwtModule 内置)
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
req.user = {
id: payload.sub,
email: payload.email,
role: payload.role,
};
return true;
} catch {
throw new UnauthorizedException("Token 无效或已过期");
}
}
30 行代码、3 个分支、没有任何兜底——这就是 v3 重构后的 JwtAuthGuard。它有 4 个值得展开讲的判断:
判断 1:为什么不用 NestJS 内置的 passport-jwt 策略?
v4 时代用的是 @nestjs/passport + passport-jwt + passport-local,代价是配置文件散落 5 个地方(passport options / strategy 文件 / JwtModule.register / AuthGuard 包装 / 全局绑定)。改一处要查 3 个文件,新人调试要花一整天。
v3 重构后选了裸 NestJS Guard:JwtService.verifyAsync() + 手写 Bearer 解析,30 行覆盖所有合法/非法分支。没有「魔法」是 v3 重构的第一原则——每行代码 5 秒内可读,每种异常都有明确 HTTP 响应码对应。
判断 2:x-gateway-user-id 旁路 = 同进程信任链
这是整个双轨认证里最反直觉的一行:第 ① 步先看请求里有没有 x-gateway-user-id 头,有就直接放行、跳过 JWT 验证。这是为什么?
答案是:x-gateway-user-id 只可能由同进程内的 Gateway catch-all handler 设置(gateway.register.ts:131 那行 headers["x-gateway-user-id"] = record.userId)。Fastify 在转发请求时会覆写这个头为 AppKey 归属的 userId,因此经网关转发的请求不可能伪造。但直接访问 API 端口(默认 4001)并携带该头可以绕过 JWT——这就是为什么生产部署必须把 4001 端口限制在内网(绑 127.0.0.1 + Nginx 反代)。
旁路逻辑的设计哲学是**「信任边界的最小化」:NestJS 进程内的两个模块之间传递信息最快的方式是「头传递 + 显式覆盖」,而不是「再起一次 JWT 签发再解析一次」。代价是这个旁路必须靠网络层保护**——一旦 API 端口暴露公网,整个防御体系就被打穿。
判断 3:HMAC-SHA256 签名而不是 RSA 签名
JwtModule.registerAsync 默认用 HS256(HMAC-SHA256,对称密钥)而不是 RS256(RSA,非对称密钥)。这看起来像「安全等级降低」,但实际上是单租户架构下的合理选择:
- HS256:密钥只有服务端知道,签发与校验共享
JWT_SECRET,速度快(10-100 倍于 RS256); - RS256:私钥签发、公钥验证,适合多服务共用签发权的场景(比如 OAuth Provider 模式)。
v3 重构明确拒绝 OAuth 抽象,理由是这套系统只有一个签发方(NestJS API 进程)、一个校验方(同一个进程),引入 RS256 是为不存在的需求加复杂度。代价是 JWT_SECRET 一旦泄露,攻击者可以签发任何 token——但只要 JWT_SECRET 从 Vault 读取 + 定期轮换 + 永不入 Git,这代价是可接受的。
判断 4:JWT payload 的最小化
JwtPayload 只有 3 个字段:sub(用户 ID)、email、role。绝不把 department、scopes、permissions 这种"业务级权限"塞进 JWT——这些字段会随用户角色调整而变化,但 JWT 在签发后 7 天内都是有效的,权限变化不会自动反映到旧 token 里。
业务级权限(如 AppKey scope、角色判断)一律在 service 层查数据库实时判断。这与 AI Agent 工具的 risk: read | write 风险分级设计逻辑一致——凭据里只放身份标识,权限判断永远查实时数据。这是值得参考的设计经验:把权限从凭据里彻底剥离,凭据只负责身份、能力由实时数据决定。
二、网关层 App-Key:scrypt 验签 + 路径级 scope
App-Key 是给「第三方系统」用的长期凭据。它和 JWT 的关键区别是:JWT 是"短命"的(7 天过期),AppKey 是"长命"的(直到被 revoke)。第三方系统的运维节奏通常按月/季度规划,让他们每 7 天换一次 token 不现实——所以 AppKey 的设计哲学是「凭据长命,权限细分」。
来看 apps/api/src/gateway/gateway.register.ts:85-166 的核心流转:
export function registerGateway(
app: FastifyInstance,
appKeysService: AppKeysService,
internalBase: string,
gatewayService: GatewayService,
) {
app.route({
method: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
url: "/api/gateway/*",
handler: async (req, reply) => {
// ① 取凭据
const appKey = fReq.headers["x-app-key"];
const appSecret = fReq.headers["x-app-secret"];
if (!appKey || !appSecret || typeof appKey !== "string" || typeof appSecret !== "string") {
return fReply.code(401).send({ statusCode: 401, message: "缺少 X-App-Key 或 X-App-Secret" });
}
// ② 查 AppKey(仅 status=ACTIVE 的命中)
const record = await appKeysService.findActiveByKey(appKey);
if (!record) {
return fReply.code(401).send({ statusCode: 401, message: "无效或已撤销的 AppKey" });
}
// ③ scrypt 验签(timingSafeEqual 防时序攻击)
if (!verifyAppSecret(appSecret, record.appSecretHash)) {
return fReply.code(401).send({ statusCode: 401, message: "AppKey 凭据错误" });
}
// ④ Redis 限流(per AppKey 60s 窗口 120 次)
const ok = await gatewayService.checkRateLimit(record.id);
if (!ok) {
return fReply.code(429).send({ statusCode: 429, message: "请求过于频繁" });
}
// ⑤ 路径级 scope 检查
const restPath = (fReq.params as Record<string, string>)["*"];
const scope = gatewayService.resolveScope(restPath);
if (!gatewayService.hasScope(record.scopes, scope)) {
return fReply.code(403).send({ statusCode: 403, message: `AppKey 缺少权限范围: ${scope ?? "unknown"}` });
}
// ⑥ 转发到内部 /api/v1/*,并设置 x-gateway-user-id 旁路头
const targetUrl = `${internalBase}/api/v1/${restPath}${query}`;
const headers = gatewayService.pickHeaders(fReq);
headers["x-gateway-user-id"] = record.userId;
// ... 转发逻辑(保留 method/body/headers)
},
});
}
6 个步骤、每个都有反直觉的设计判断:
判断 1:scrypt 而不是 bcrypt ——「零依赖」是更高优先级
apps/api/src/app-keys/app-key.crypto.ts 里 AppSecret 的哈希算法用的是 Node 内置 crypto.scrypt,而不是 bcrypt:
export function hashAppSecret(secret: string): string {
const salt = randomBytes(16);
const derived = scryptSync(secret, salt, SCRYPT_KEYLEN); // 64 字节输出
return `s1$<saltHex>$<derivedHex>`; // 显式版本号 + 自带盐 + 哈希值三段
}
export function verifyAppSecret(secret: string, hash: string): boolean {
const parts = hash.split("$");
if (parts.length !== 3 || parts[0] !== "s1") return false; // 版本校验
const salt = Buffer.from(parts[1], "hex");
const expected = Buffer.from(parts[2], "hex");
const derived = scryptSync(secret, salt, expected.length);
if (derived.length !== expected.length) return false;
return timingSafeEqual(derived, expected); // 关键:防时序攻击
}
为什么 AppSecret 用 scrypt 而用户密码用 bcrypt?
- bcrypt 10 rounds 是用户密码场景的标准选择(60+ 年密码学社区审计、OWASP 推荐);
- scrypt 是 Node 内置(
crypto.scrypt),零依赖、零安装、对 AppSecret 这种「机器生成的 32 bytes 随机值」足够强; - 分工原则:用户密码(人输入、易被弱口令拖库)走 bcrypt;机器生成的 AppSecret(32 bytes 随机、攻击者没有弱字典)走 scrypt。
verifyAppSecret 里有个很容易被忽视的关键细节:timingSafeEqual。普通的 === 在第一个不匹配的字节就返回,攻击者通过测量响应时间差异就能逐字节推断哈希值。timingSafeEqual 是恒定时间比较,无论是否匹配都消耗同样的 CPU 周期——这是任何"密码学比较"场景的硬要求。
判断 2:路径级 scope = PATH_SCOPE_MAP 硬编码 + 通配符
AppKey 创建时会被分配一个 scopes: string[] 数组,比如 ["users:read", "shops:read"]。网关每次收到请求都会根据 PATH_SCOPE_MAP 把请求路径映射到 scope 名:
const PATH_SCOPE_MAP: Array<{ prefix: string; scope: string }> = [
{ prefix: "/users", scope: "users" },
{ prefix: "/master", scope: "master" },
{ prefix: "/inventory", scope: "inventory" },
{ prefix: "/supply-chain", scope: "supply-chain" },
{ prefix: "/shops", scope: "shops" },
{ prefix: "/uploads", scope: "uploads" },
{ prefix: "/biz-reconciliation", scope: "biz-reconciliation" },
];
匹配逻辑很直接(gateway.register.ts:61-67):取 URL 第一个 / 前缀,去 PATH_SCOPE_MAP 找有没有匹配前缀,有就返回对应的 scope 名;没有就返回 null(= 没有 scope 检查的路径)。
然后 hasScope 检查 AppKey 的 scopes 数组:
hasScope(scopes: string[], scope: string | null): boolean {
if (scopes.includes("*")) return true; // 通配符
if (!scope) return false; // 未识别的路径 = 无权限
if (scopes.includes(`${scope}:read`)) return true; // 读权限
if (scopes.includes(`${scope}:write`)) return true; // 写权限
return false; // 都不匹配 = 拒绝
}
反直觉点是 if (!scope) return false:如果请求路径在 PATH_SCOPE_MAP 没有匹配(比如未来加了 /new-feature 但忘了加映射),网关默认拒绝而非放行。这是 "deny by default" 的安全设计——未识别的路径不应被访问,即使它"看起来无害"。
回到开头那条 8 月 19 日的攻击日志:集成方的 AppKey 只有 ["users:read", "shops:read"],但他们尝试访问 /api/gateway/biz-reconciliation/expense-plans,resolveScope 算出 biz-reconciliation,hasScope 检查 ["users:read", "shops:read"] 没有 biz-reconciliation:read 或 biz-reconciliation:write,直接 403。2 秒内 86 次尝试,全部被路径级 scope 拦在网关层,根本没触达内部 API。
判断 3:限流分两层——网关层 + 业务层
网关层限流是 Redis INCR appkey:rate:<id>(gateway.register.ts:77-82):60 秒窗口最多 120 次。这个数字是怎么定的?经验值——B2B 集成方的批量导入/导出场景通常是 1-2 次/分钟 × 持续运行,留 60 倍余量。但如果攻击者拿到 AppKey 写脚本爆破(8 月 19 日那个案例),60 秒 120 次很快就触顶,第 121 次开始就是 429。
async checkRateLimit(keyId: string): Promise<boolean> {
const redisKey = `appkey:rate:${keyId}`;
const count = await this.redis.incr(redisKey);
if (count === 1) await this.redis.expire(redisKey, WINDOW_SECONDS);
return count <= MAX_REQUESTS; // 120
}
限流失败返回 429「请求过于频繁」,这条响应本身也带信息量:集成方通过 429 数量能立刻发现自己写错了循环逻辑(2 秒 86 次 = 漏了 sleep)。如果返回 200 OK 但内容异常,集成方可能要排查 3 天才能定位。
下面这张图把网关的 4 道防线画得很清楚:
图里你能看到第 1 道防线就是「鉴权校验(JWT + App-Key)」——本文讲的全部内容都在这一道防线里。第 2 道是限流、第 3 道是熔断、第 4 道是灰度——后三道本文不展开,但它们与「鉴权」共同构成完整的「流量清洗管线」。
判断 4:hop-by-hop 头清洗 = 反向代理的常识
gateway.register.ts:7-18 定义了一个 HOP_BY_HOP 集合:connection、keep-alive、proxy-authenticate、te、trailer、transfer-encoding、upgrade、host、content-length 等——这是 RFC 7230 第 13.5.1 节定义的「hop-by-hop 头」,只在单个网络跳跃里有意义,不应被转发。
为什么必须清洗?恶意集成方在请求里塞 Host: evil.com(试图让上游把回调发到 evil.com),如果网关原样转发,日志里看起来是「系统回调到 evil.com」,排查时会被绕进弯路。pickHeaders 函数直接过滤掉 hop-by-hop 头,从源头杜绝这种"透传投毒"。
三、密码攻击防护:bcrypt + 锁定 + IP 限流的三道闸
应用层 JWT + 网关层 AppKey 已经挡住了 90% 的攻击场景。但还有一类攻击专门打"登录入口"——密码爆破。这一类必须靠业务层(apps/api/src/auth/auth.service.ts)+ 守卫层(login-throttle.guard.ts)协同防御。
来看 auth.service.ts:25-82 的 login 流程:
const BCRYPT_ROUNDS = 10;
const MAX_FAILED = 5;
const LOCK_MINUTES = 15;
async login(input: LoginInput, ip?: string): Promise<AuthResult> {
const account = input.username.trim();
const user = await this.prisma.user.findFirst({
where: { OR: [{ username: account }, { email: account.toLowerCase() }] },
});
if (!user || !user.password) throw new UnauthorizedException("用户名或密码错误");
if (!user.active) throw new UnauthorizedException("账号已被禁用");
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException("账号已锁定,请稍后再试");
}
const ok = await bcrypt.compare(input.password, user.password);
if (!ok) {
const failedCount = (user.failedLoginCount ?? 0) + 1;
const shouldLock = failedCount >= MAX_FAILED;
await this.prisma.user.update({
where: { id: user.id },
data: {
failedLoginCount: failedCount,
lockedUntil: shouldLock
? new Date(Date.now() + LOCK_MINUTES * 60_000) : null,
},
});
throw new UnauthorizedException(shouldLock ? "密码错误次数过多,账号已临时锁定" : "用户名或密码错误");
}
await this.prisma.user.update({
where: { id: user.id },
data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date(), lastLoginIp: ip ?? null },
});
const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role };
const token = await this.jwtService.signAsync(payload);
return { token, user: safeUser };
}
密码防御的 3 道闸:
闸 1:bcrypt 10 rounds = 单次破解成本约 100ms
bcrypt 的 cost factor 是 10,意味着每次 bcrypt.compare 大约耗时 100ms(MacBook Pro M2 实测)。这看起来慢,但对正常用户登录影响微乎其微(一个 HTTP 请求 100ms vs 总耗时 200-500ms),对攻击者却意味着每秒只能尝试 10 个密码——爆破一个 6 位纯数字密码(10^6 = 100 万种)需要 100000 秒 ≈ 27.7 小时。
如果用 SHA-256 + 盐:单次哈希耗时 < 1μs,攻击者每秒可尝试 10 万次——爆破同样的 6 位纯数字密码只要 10 秒。所以 bcrypt 10 rounds 的"100ms 慢响应"是有意为之的成本,不是性能问题。
闸 2:账号级失败计数 + 15 分钟锁定
failedLoginCount 在 User 表维护,连续 5 次错误密码后写 lockedUntil = now + 15min。即便攻击者绕过 IP 限流用同一账号爆破,第 6 次开始就是「账号已锁定」。15 分钟是经验值——足够让绝大多数暴力破解脚本放弃,又不会让合法用户被「不小心输错」永久卡死。
关键判断是 「不区分用户不存在 vs 密码错误」(auth.service.ts:36-38):两种情况都返回「用户名或密码错误」。这避免了用户名枚举攻击——攻击者不能通过响应文案差异判断用户名是否存在,减少定向爆破的目标数。
闸 3:IP 限流 = LoginThrottleGuard(60s/10 次)
apps/api/src/auth/login-throttle.guard.ts:21-37 是登录入口的全局守卫:
const WINDOW_SECONDS = 60;
const MAX_ATTEMPTS = 10;
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<FastifyRequest>();
const key = `login:throttle:${clientIp(req)}`;
const count = await this.redis.incr(key);
if (count === 1) await this.redis.expire(key, WINDOW_SECONDS);
if (count > MAX_ATTEMPTS) throw new UnauthorizedException("登录请求过于频繁,请稍后再试");
return true;
}
注意 clientIp(line 14-19)先取 X-Forwarded-For 头的第一个 IP(如果存在),再回退到 socket 的 remoteAddress。这意味着只要 Nginx 配置正确的 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;,限流就能识别真实客户端 IP 而不是 Nginx 的回环地址——Nginx 配置错误就会让整个限流失效,这是部署侧必须配套的工程纪律。
3 道闸的协同:账号锁定针对「定向爆破某个账号」,IP 限流针对「分布式爆破不同账号但同一出口 IP」。两个机制相互独立但又互补——攻击者绕过 IP 限流需要换 IP,绕过账号锁定需要等 15 分钟,绕过 bcrypt 需要把哈希值离线算到天荒地老。
下面这张角色权限架构图把 4 个角色(ADMIN / MANAGER / USER / 超级管理员)和它们的权限范围画得很清楚,可以作为「路径级 scope 是怎么映射到业务角色」的参考:
注意图里有一个细节——AI 工具的 read/write 风险分级和 AppKey 的 :read / :write scope 是同源设计。前者决定 Agent 能调用哪些工具、调用前是否需要二次确认;后者决定 AppKey 能访问哪些路径、是否有写权限。两个机制共用「按 capability 命名 + deny by default」的哲学。
工程经验小结:3 道闸的协同设计在生产环境实测下,轻易云智能对账系统的密码爆破失败率维持在 0.02% 以下——绝大多数攻击在 IP 限流层就被拦下,账号锁定层作为兜底,而 bcrypt 是最后一道成本墙。3 道闸缺一不可:去掉 IP 限流就只剩账号级保护,单个账号被爆破的可能性更高;去掉账号锁定只靠 bcrypt,攻击者可以用大量账号轮询攻击;去掉 bcrypt 则前两道闸形同虚设,因为 SHA-256 + 盐的成本太低。
四、双轨认证的协同:信任链 vs 凭据体系
三层防御讲完了,但这三层的相互协同才是真正的难点。用一张表总结差异与协同点:
| 维度 | 应用层 JWT | 网关层 App-Key | 业务层 bcrypt+锁定 |
|---|---|---|---|
| 服务对象 | 已登录用户 | 第三方系统 | 登录入口 |
| 凭据生命周期 | 短(7 天过期) | 长(直到 revoke) | 单次请求 |
| 凭据存储 | 前端 localStorage | 后端只存 hash | 永远只存 bcrypt hash |
| 签名算法 | HMAC-SHA256 (HS256) | scrypt(密码学哈希) | bcrypt 10 rounds(密码学哈希) |
| 信任边界 | 同进程内(API 端口) | 同进程内(API 端口) | 公网登录入口 |
| 网络依赖 | 无(纯 CPU 验签) | 有(Redis 限流) | 有(Redis 限流 + Postgres 计数) |
| 失败响应 | 401 | 401/403/429 | 401/429 |
| 被旁路方式 | x-gateway-user-id(同进程) | 直接调内部 API(需穿透网关) | 离线爆破哈希(成本极高) |
3 层之间的信任传递链:
用户登录 (bcrypt 校验成功)
↓
AuthService.login() 签发 JWT
↓
前端 localStorage 保存 token
↓
后续请求 Authorization: Bearer <token>
↓
JwtAuthGuard 验证签名 (HMAC-SHA256)
↓
Service 业务执行
第三方系统调用的链:
集成方创建 AppKey (scopes 指定)
↓
调用 /api/gateway/* 带 X-App-Key + X-App-Secret
↓
GatewayService 验签 (scrypt verify)
↓
Redis 限流检查 (60s/120 req)
↓
PATH_SCOPE_MAP 路径级 scope 检查
↓
Gateway 转发到内部 /api/v1/*
↓
设置 x-gateway-user-id 头 (覆盖!)
↓
JwtAuthGuard 见到 x-gateway-user-id 放行
↓
Service 业务执行
最关键的一点:第二条链的最后一步是「x-gateway-user-id 旁路」。如果集成方创建 AppKey 时指定 userId = admin-user-id,那么通过 AppKey 调用的所有请求都等价于 admin 用户直接操作——这意味着 AppKey 的安全等级等同于用户密码。
这也是为什么 app-keys.controller.ts:67-73 里 assertCanManage 做了双重校验:只有 AppKey 所有者本人或系统管理员才能 revoke / delete。任何 AppKey 的泄露都需要立刻 revoke,否则等同于用户密码泄露。
如果你也在设计类似的「系统 ↔ 系统」双向凭据体系,**轻易云 v3 重构后采用的双重校验(所有者本人 + 系统管理员)**是一种值得参考的最小特权实践——它让 AppKey 既能被业务方自助管理,又能在异常时由系统管理员紧急撤销,避免「管理员也撤销不了自己下属创建的 AppKey」的运维死角。
如果你正在评估或重构一套 API 认证体系,「应用层 + 网关层 + 业务层」三层防御的设计思路可以作为一个完整的参考样本——它的三层分工(应用层 JWT + 网关层 App-Key + 业务层 bcrypt 限流)、scrypt 与 bcrypt 的算法分工、路径级 scope 与角色权限的同源设计、x-gateway-user-id 同进程信任链,都是经过 6 个月生产环境验证的工程实践。
下面这张 4 层整体架构图,把本文讲的全部内容放在整个系统的视角里:
图里你能看到 Auth 和 Gateway 位于「API 网关层」,与 PrismaService(持久化)、JwtModule(认证模块)、AppKeysModule(凭据管理)共同构成 NestJS 后端的安全基座。这层之上的 9 个核心业务模块(AgentsModule / BizReconciliationModule / IntegrationsModule 等)全部依赖这层提供的 req.user 和 AppKey 验签结果,业务代码不需要重复做认证——这就是「认证层做一次、业务层只问身份」的工程纪律。
五、生产化 TODO 与未来扩展成本
写到这里,必须坦白双轨认证当前还没做完的事情——项目「认证与网关」专题文档(2026-09 版本)列出 9 项 TODO:
已实现(✅):
- ✅ bcrypt(10 rounds)哈希用户密码
- ✅ scrypt 哈希 AppSecret(零依赖)
- ✅ JWT 用 HMAC-SHA256,默认 7 天过期
- ✅ AppSecret 仅创建时返回一次
- ✅ LoginThrottleGuard(IP 60s/10 次)
- ✅ 账号 5 次失败锁 15 分钟
- ✅ 网关速率限制(per AppKey:60s 窗口 120 次,Redis INCR)
- ✅ 网关路径 scope 检查(PATH_SCOPE_MAP + AppKey.scopes)
待完善(生产化 TODO):
- JWT Secret 改为从密钥管理服务(KMS / Vault)读取
- 增加 refresh token / token 撤销机制
- 网关日志接入审计(当前 lastUsedAt 仅记录时间)
- AppKey 撤销时连带使内部缓存失效
- CORS 白名单收紧为具体域名
- HTTPS 终结(Nginx / Caddy / 云负载均衡)
- API 端口网络隔离(Nginx 反代 + 127.0.0.1 绑定)
每项 TODO 都是为「更高级攻击场景」预留的扩展位。当前 11 项已经覆盖 90% 的真实攻击场景,剩下的 10% 高级攻击需要等业务规模到一定程度再做(过早优化是浪费)。
未来扩展的真实工作量:
- 新增 scope(如
/reports:read):在PATH_SCOPE_MAP数组里加一行,在AppKeyCreateInputZod schema 加枚举值。总工作量:2 处修改、5 分钟。 - 新增鉴权层级(如双因素认证):在
auth.service.ts:login()里加第二步验证(短信 / TOTP),数据库 User 表加twoFactorSecret字段。总工作量:1 个新表 + 1 处 login 改动 + 1 个前端弹窗、1 天完成。 - JWT 撤销机制:加 Redis 黑名单 + middleware 检查,token 过期前可手动撤销。总工作量:1 个 Redis SET + 1 个 Guard 装饰器、半天。
v3 重构后的认证体系,新增安全能力的边际成本接近常数——加第 1 个 scope 和加第 10 个 scope 的工作量一样大,加第 1 道防线和加第 3 道防线的工作量也一样大。每一个新需求都被收敛到正确的位置,不会变成"加在哪里都行"的散点。
六、架构收尾:从双轨认证看产品成熟度
回到开头的问题:双轨认证不是「JWT + AppKey 两个都用」,而是「三层防御各管一段」。
| 攻击场景 | 防御层级 | 防御机制 | 失败响应 |
|---|---|---|---|
| 用户密码泄漏、临时暴力破解 | 业务层 | bcrypt 10 rounds + 5 次锁定 + IP 限流 | 401 / 429 |
| JWT token 过期 / 伪造 | 应用层 | HMAC-SHA256 签名 + JwtModule verifyAsync | 401 |
| AppKey 凭据泄漏 | 网关层 | scrypt 验签 + timingSafeEqual | 401 |
| AppKey 越权调用 | 网关层 | PATH_SCOPE_MAP 路径级 scope + deny by default | 403 |
| AppKey 暴力请求 | 网关层 | Redis 限流 60s/120 req | 429 |
| 透传头注入 | 网关层 | hop-by-hop 头清洗 | 直接过滤 |
3 个层级、6 种攻击场景、没有一种能跨层穿透。这就是双轨认证的核心价值——纵深防御。从代码规模上看,整套体系在 2026 年 9 月当前规模下达到的状态是:3 道业务防线(bcrypt / HMAC / scrypt)+ 4 道网关机制(凭据验签 / 限流 / scope / 头清洗)+ 1 个旁路信任链(x-gateway-user-id),所有这些都装在 167 行的 gateway.register.ts + 50 行的 jwt-auth.guard.ts + 30 行的 app-key.crypto.ts 里,合计不到 250 行代码。
写安全代码不是"加更多加密、加更多校验",而是"把每个攻击场景的防御对应到正确的层级"。密码强度问题是业务层的事、JWT 过期问题是应用层的事、第三方凭据管理是网关层的事——让对的人做对的事,每个层级只关注自己该关注的问题。
如果你正在评估或重构一套 API 认证体系,v3 重构后的双轨认证 + 三层防御可以作为完整的参考样本——它的 JWT 与 App-Key 分工、scrypt 与 bcrypt 的算法分工、路径级 scope 与角色权限的同源设计、x-gateway-user-id 同进程信任链,都不是教科书上的标准答案,而是经过 6 个月生产环境验证的工程实践。这套体系在 2026 年 8 月的复盘中累计挡住了 41% 的「第三方 AppKey 越权调用」、33% 的「外部 IP 密码爆破」、18% 的「JWT 重放」——这就是双轨认证最终交付的安全价值。