GraphQL 认证与授权:从 JWT 到字段级权限

GraphQL 认证与授权实战:认证(AuthN)与授权(AuthZ)的区别、JWT/Session/OAuth 在 GraphQL 中的落地、context 中的用户解析、字段级与行级权限(RBAC/ABAC)、resolver 层的授权模型、Federation 场景的跨子图授权、深度查询的成本控制、GraphQL 特有的安全攻击面(复杂查询/别名滥用/信息泄露)与授权测试策略。

认证(Authentication,你是谁)与授权(Authorization,你能做什么)是每个 GraphQL 服务的第一道安全门。REST 时代我们在路由层按 URL 配权限,但 GraphQL 只有一条路由——权限判断下沉到了 resolver 层和字段层。这个差异是 GraphQL 安全设计的地基:没有「按字段授权」的 GraphQL 服务,就像在 REST 里只保护了 /users 却没保护返回的用户列表字段。

本文从认证凭证在 GraphQL 中的传递讲起,深入授权模型(RBAC/ABAC)、resolver 层授权、字段级权限、Federation 跨子图授权,最后覆盖 GraphQL 特有的攻击面与授权测试。

一、认证凭证的传递与 context

1.1 凭证从哪来

GraphQL 请求是单个 POST 端点,凭证通过 HTTP 头传递:

# 三种主流凭证
# 1) Authorization: Bearer <JWT>(最常用,无状态)
# 2) Cookie(浏览器场景,Session 服务端存储)
# 3) 自定义头/API Key(机器间,网关层校验)
# 关键: 凭证绝不进 query 字符串/body(会进日志与缓存)

1.2 解析进 context

认证应在进入 resolver 之前完成,把解析好的用户对象注入 context,resolver 只消费:

// 服务器启动时解析 token → 注入 context
server = new ApolloServer({
  context: async ({ req }) => {
    const token = extractToken(req.headers.authorization);
    const user = token ? await authService.verify(token) : null;
    return { user, token };   // 所有 resolver 从这里拿当前用户
  },
});

工程铁律:认证只做一次(在 context),授权在 resolver 反复用。别让每个 resolver 各自去解 token。

1.3 匿名与可选认证

# 三种模式
# 强制认证: 所有操作都需登录(后台类 API)
# 可选认证: context.user 可能为 null,resolver 按需分支
#   (公开字段可读,私有字段需登录)
# 混合: 公开端点匿名,写/敏感操作强制
# 注意: 可选认证最容易漏防——"判断 user 为空就返回公共数据",别把私有数据当公共返回

二、JWT 与 Session 的工程选择

2.1 JWT 的用与防

维度JWTSession
状态存储无状态(token 自包含)服务端存储
扩展性横向扩展友好需共享 session 存储
吊销难(需黑名单/版本号)简单(删 session)
适用分布式 API、跨服务单域 Web 应用

JWT 落地细节:

# 1) 签名算法: 用 RS256(非对称),别用 HS256 泄漏密钥
# 2) 过期: access token 短时效(15 分钟)+ refresh token 轮换
# 3) 声明: sub(用户 id)、role、scope、jti(唯一 id)、exp
# 4) 校验: 验签名 + 验过期 + 验 iss/aud + 验 jti 是否已吊销
# 5) 安全: token 不进 URL、日志打码、客户端存安全存储

2.2 refresh token 轮换

# 轮换协议(防重放)
# 1) refresh token 每次使用即作废,换发新 refresh + access
# 2) 服务端存 refresh token 的 hash + 轮换记录
# 3) 检测到"旧 refresh 再次使用"(重放)→ 吊销整条 token 链
# 收益: 盗取 refresh token 的攻击窗口被压缩到一次使用

三、授权模型:RBAC 与 ABAC

3.1 RBAC:角色分配

RBAC(Role-Based Access Control)按角色授权,最简单也最常见:

# 用户 → 角色 → 权限
# 角色: admin / editor / viewer
# 权限: user:read / user:write / user:delete
# resolver 里: 校验当前用户角色是否含所需权限
# 优点: 模型简单、可管理
# 局限: 细粒度("仅本部门"、"仅自己的数据")不够用

3.2 ABAC:属性授权

ABAC(Attribute-Based Access Control)用属性规则表达细粒度授权:

# 规则: 用户属性 + 资源属性 + 上下文 → 允许/拒绝
# 例: 允许 staff 读取 本部门 且 未归档 的工单
# 例: 允许 owner 更新自己的资料
# 实现: 策略引擎(OPA/Cedar)或 resolver 内规则函数
# 适用: 多租户、数据级权限、动态策略
# 代价: 规则复杂、调试难、性能开销

3.3 混合实践

# 生产推荐: RBAC 定"能力边界",ABAC 定"数据边界"
# 角色管: 能调用哪些字段/操作(静态)
# 属性管: 能看哪些行/哪些对象(动态,数据级)
# 分层校验:
# 字段级 RBAC(能不能查该字段)
# + 行级 ABAC(能查到哪些记录)

四、resolver 层授权:三层防护

4.1 入口层授权

# 三层防护
# 1) 传输/网关层: 认证 + 基础校验(401/403,拦截非法 token)
# 2) 字段入口层: resolver 开头校验权限(能否操作该字段)
# 3) 数据层: 行级过滤(能读到哪些数据)
# 例: 查询订单
#   - 网关验 token → 通过
#   - resolver 查角色是否有 order:read → 通过
#   - 数据层 WHERE user_id = 当前用户 OR is_public → 只返回该用户订单
// resolver 层的授权模板
const order = async (_parent, args, ctx) => {
  assertPermission(ctx, "order:read");          // 字段级 RBAC
  const rows = await db.orders.findByOwner(     // 行级 ABAC
    { ownerId: ctx.user.id, scope: ctx.user.scope }
  );
  return rows;
};

4.2 授权复用与中间件

授权逻辑要复用,别在每个 resolver 里复制粘贴:

# 模式
# 1) 高阶函数包装: requirePermission('order:read')(resolver)
# 2) schema 指令: @auth(role: "admin") 声明式(graphql-shield/graphql-directive)
# 3) 统一守卫: 在 resolver 执行前后统一钩子
# 选型: schema 指令把授权写进 schema(直观);函数包装更灵活
# 指令式授权示例
type Query {
  users: [User!]! @auth(requires: ADMIN)
  me: User @auth
}

4.3 授权失败的表现

# 授权失败响应规范
# 1) 401(未认证)vs 403(已认证但无权限)——别混用
# 2) errors[] 里 code: 'UNAUTHENTICATED' / 'FORBIDDEN'
# 3) 敏感字段: 无权限时返回 null 还是抛错?
#    列表字段建议过滤(返回能看到的部分)
#    单条敏感数据建议抛 FORBIDDEN(避免"猜是否存在")
# 4) 别泄露"为什么没权限"(枚举探测风险)

五、字段级权限:GraphQL 的核心差异

5.1 为什么必须字段级

REST 里字段跟随 URL 一起授权;GraphQL 里查询任意组合字段,必须按字段拦截:

# 典型泄露场景
# 查询 { user(id) { name, email, phone, ssn } }
# REST: /users/{id} 返回字段受控
# GraphQL: 若 resolver 直接返回整个 user 对象,
#          客户端可自由选择要 email/ssn → 泄露
# 解法: 敏感字段在 schema 层/field resolver 层单独授权

5.2 字段级授权实现

# 方案一: schema 指令
type User {
  id: ID!
  name: String!
  email: String! @auth(requires: SELF_OR_ADMIN)
  ssn: String  @auth(requires: ADMIN)
}
// 方案二: 字段 resolver 内分支
const User = {
  email: (user, _args, ctx) => {
    if (!canRead(ctx, user, "email")) return null;
    return user.email;
  },
};

工程决策:敏感度高的字段用指令(声明式、schema 可见);复杂规则用字段 resolver(灵活)。

5.3 列表级行过滤

字段级 + 行级组合,处理"列表只返回有权看的部分":

# 例: 项目列表 —— 用户只看得到自己参与的
# resolver 查数据时带上授权过滤(WHERE ...)
# 避免: 先查出全部再内存过滤(性能 + 泄露窗口)
# 数据层授权(RLS 等价物)是最强的防线

六、Federation 与跨子图授权

6.1 子图间的信任边界

Federation 把 schema 拆成多个子图,授权必须考虑"哪个子图负责鉴权":

# 联邦授权模式
# 1) 网关统一认证(解 token)→ 注入到子图
# 2) 字段级授权在各子图内做(数据归属的子图负责行级过滤)
# 3) 跨子图敏感数据: 不通过 @external 暴露,或用 @shareable 时重新授权
# 关键风险: 子图 B 信任了子图 A 传入的实体参数,
#           若 A 未授权就"代查"了 B 的私有数据

6.2 实体解析的授权

Federation 的 _entities 解析是按 key 组装跨子图实体,容易被绕过授权:

# 防护
# 1) 实体解析器也做授权(不只是业务 resolver)
# 2) @requires 引用的跨子图字段,引用方授权后还要引用方重验
# 3) 网关层面禁止未授权方调用实体的私有字段

七、GraphQL 特有的安全攻击面

7.1 复杂查询与深度攻击

GraphQL 允许客户端"点播"任意字段组合,恶意客户端可构造超大查询:

攻击危害防护
深度攻击嵌套层数深,递归打爆maxDepth 限制
复杂度攻击字段数×类型复杂度过高maxCost 成本计算
别名滥用同字段起多个别名放大别名计数限制
批量攻击一次查询大量 ID列表大小限制
# 成本计算示例(估算每个字段权重)
# Query.getUsers(limit) 成本 = 10 + limit * 20
# 超过 maxCost → 拒绝(409/429)
# 结合持久化查询(persisted queries)白名单最稳

7.2 信息泄露面

# GraphQL 特有泄露点
# 1) introspection: 生产环境必须关闭(schema 即攻击地图)
# 2) 错误信息: 内部堆栈/表名/字段名别回给客户端
# 3) 批量取数: 未授权列表字段被逐条探测(时间侧信道)
# 4) 缓存侧信道: 公共缓存里混入未授权数据
# 统一: 生产关闭 introspection + formatError 脱敏 + 缓存键含授权维度

7.3 速率限制与配额

# 速率限制(网关层)
# 1) 按客户端(token/API key)配额
# 2) 按操作复杂度加权(成本预算,不是"请求数")
# 3) 登录/敏感操作额外限制
# 为什么按成本: 一个复杂查询抵百个简单查询,只按次数限制拦不住

八、授权测试与审计

8.1 授权测试矩阵

# 授权测试要点
# 1) 每个字段: 无 token / 游客 / 普通用户 / 管理员 / 数据归属者 五种身份
# 2) 角色矩阵: 每个角色对每个字段/操作的结果断言
# 3) 边界: 越权参数(他人 id)、批量探测、深度/复杂度超限
# 4) 负向测试: 明确"不该访问的必须被拒"(403),别只测正向
# 工具: 契约测试 + 授权断言夹具,CI 里全量回归

8.2 授权审计与日志

# 审计要求
# 1) 敏感操作(删除、改权限、读 PII)记录: 谁、何时、访问什么
# 2) 授权失败打日志(区分正常 403 与批量扫描)
# 3) 权限变更留痕(角色分配历史)
# 4) 定期权限对账: 僵尸账号、超范围授权清理
# 监控信号: 异常"越权尝试"频率、403 批量触发 → 告警

8.3 常见陷阱

  • 只做认证不做授权:能登录就行,字段级全放行——等于裸奔。
  • 授权写在客户端:前端隐藏按钮不是授权,API 必须自己校验。
  • 把整个 user 对象塞进 data:字段 resolver 不细控,客户端随意点选。
  • Federation 信任子图:@external/@requires 字段绕过授权。
  • 生产开 introspection:schema 结构直接暴露给攻击者。

Q1: 认证和授权必须分开实现吗?

是的,在架构上分开。认证(解 token 定身份)在 context/网关统一做一次;授权(字段/行级判断)在 resolver 各层反复用。混在一起会让每个 resolver 重复解 token,且职责不清难审计。

Q2: 字段级授权会不会让 resolver 代码变啰嗦?

会,但值得。用 schema 指令(@auth(requires:))或高阶函数包装把规则集中声明,resolver 只写业务逻辑。啰嗦的代码在规则集中后反而更少、更可测。

Q3: 列表字段无权限时应该过滤还是报错?

列表默认过滤(返回有权看的部分),因为"是否存在未授权数据"本身也是敏感信息。单条敏感查询建议抛 FORBIDDEN,避免枚举探测。两者要成约定写进规范。

Q4: Federation 下授权放网关还是子图?

网关统一认证、子图各自授权(数据归属子图负责行级)。关键原则:数据在哪,行级授权就在哪;跨子图字段(@requires/@external)要重新授权,不能因为"网关验过就信任子图参数"。

Q5: 生产环境真的必须关 introspection 吗?

对互联网暴露的 API 强烈建议关闭(schema 即攻击地图)。内部服务可按需保留,但配合网关控制访问。关闭 introspection 不影响正常客户端(他们用已知 schema)。


一句话总结

GraphQL 的认证与授权,核心是从 REST 的"按 URL 授权"转向按字段授权:认证在 context 统一解 token,授权在 resolver 按字段与行级逐层校验,Federation 里"数据在哪行级授权就在哪",再叠加 GraphQL 特有的成本控制与 introspection 收敛——把 schema 从攻击地图变成受控的权限边界。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL 过滤、搜索与聚合:查询数据的工程化
  2. GraphQL 多态类型设计:Interface 与 Union 深度实践
  3. GraphQL 可观测性与链路追踪:从 resolver 指标到全链路