Apache APISIX jwe-decrypt 插件实战:基于 JWE(RFC 7516)的请求头解密与安全认证
【免费下载链接】apisixThe Cloud-Native API Gateway项目地址: https://gitcode.com/GitHub_Trending/ap/apisix
jwe-decrypt是 Apache APISIX 提供的一个认证(auth)类型插件,用于在网关侧解密请求中携带的 JWE(JSON Web Encryption,RFC 7516)授权头,并将解密后的明文转发给上游服务。本文以 docs/en/latest/plugins/jwe-decrypt.md 为骨架,结合 jwe-decrypt 插件源码 与 jwe-decrypt 测试用例,完整讲解其在 Consumer 与 Route 上的配置参数、加解密端点、密钥管理(含 base64 与加密字段)、以及删除插件的操作,帮助读者把"客户端加密、网关解密、上游拿明文"的链路一次性落地。
背景:为什么网关需要 JWE 解密
JWE(RFC 7516)描述了一种对 JSON 载荷进行加密的标准化格式,与仅做签名的 JWS 不同,JWE 保证的是机密性——载荷内容对非授权方不可读。在微服务/网关架构中,客户端把敏感数据(用户 ID、会话信息等)加密后放进 HTTP 头,网关解出明文再转交给上游,是一种常见的"端到端凭证保护"方案。
jwe-decrypt插件承担了网关侧的解密职责,同时内置一个加密端点,形成一个闭环:
- 加密侧:插件提供内部端点
/apisix/plugin/jwe/encrypt,配合public-api插件暴露出去,用 Consumer 里配置的密钥把明文载荷加密成 JWE Token。 - 解密侧:请求到达 Route 时,插件从指定请求头中取出 JWE Token,根据 Token 头部的
kid定位 Consumer 密钥,解密后把明文写回指定请求头,再继续转发给上游。
从源码看,插件在 apisix/plugins/jwe-decrypt.lua 中声明了version = 0.1、priority = 2509、type = 'auth',即它在认证插件中拥有较高的执行优先级,会在路由匹配之后、上游转发之前完成解密动作。
插件属性(Attributes)
jwe-decrypt的属性分为两组:一组配置在Consumer(解密密钥),另一组配置在Route(解密行为)。
Consumer 侧属性
| 名称 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| key | string | 是 | - | Consumer 的唯一标识,同时也是 JWE Token 中kid对应的取值。 |
| secret | string | 是 | - | 解密密钥,必须为 32 个字符。可通过 Secret 资源 将密钥存放到密钥管理器中。 |
| is_base64_encoded | boolean | 否 | false | 若为 true,表示secret是 base64 编码的,插件会先解码再使用。 |
注意:启用
is_base64_encoded后,secret的原始长度可以超过 32 字符,只需保证解码后的长度仍为 32 字符即可。
这一"32 字符"的硬约束来自底层加密算法:源码在 apisix/plugins/jwe-decrypt.lua 使用aes.cipher(256, "gcm")创建 AES-256-GCM 密码对象,256 位即 32 字节。check_schema在 Consumer 模式下会严格校验长度(apisix/plugins/jwe-decrypt.lua):
- 未开启
is_base64_encoded时,要求#conf.secret == 32,否则报错the secret length should be 32 chars; - 开启后,要求
#base64.decode_base64url(conf.secret) == 32,否则报错the secret length after base64 decode should be 32 chars。
对应地,jwe-decrypt 测试用例 的 TEST 4、TEST 5 分别验证了这两种报错分支;TEST 19~24 则验证了 base64 密钥的完整加解密链路。
另外,Consumer schema 中声明了encrypt_fields = { "key", "secret" }(apisix/plugins/jwe-decrypt.lua),表示这两个字段属于可加密敏感字段。当在 conf/config.yaml 中启用apisix.data_encryption.enable_encrypt_fields且配置中心为 etcd 时,存入 etcd 的key/secret会被自动加密存储(源码 apisix/plugins/jwe-decrypt.lua 会在该场景下跳过长度校验,因为密文长度必然超过 32 字符);测试 t/plugin/jwe-decrypt.t 的 TEST 7 直接从 etcd 读取到加密后的字段值作为佐证。
Route 侧属性
| 名称 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
| header | string | 是 | Authorization | 从哪个请求头中读取 JWE Token。 |
| forward_header | string | 是 | Authorization | 解密后的明文写入哪个请求头再转发给上游。 |
| strict | boolean | 否 | true | 为 true 时,若请求中缺失 JWE Token 则直接返回 403;为 false 时,找不到 Token 不报错,请求继续放行。 |
需要说明的是:官方文档将header/forward_header标为必填(Required = True),而源码 apisix/plugins/jwe-decrypt.lua 的 schema 为两者提供了默认值Authorization并列入required,实际使用时即使不显式配置,也会按Authorization头处理。strict的默认值true与源码default = true完全一致。
strict的行为在 rewrite 阶段 体现:fetch_jwe_token取不到 Token 且strict为 true 时,返回403 {"message":"missing JWE token in request"};测试 t/plugin/jwe-decrypt.t 的 TEST 12 精确断言了这一响应。
加解密流程与源码级原理
jwe-decrypt的解密逻辑全部发生在rewrite阶段(apisix/plugins/jwe-decrypt.lua),完整链路如下:
- 取 Token:
fetch_jwe_token读取conf.header指定的请求头;若 Token 以Bearer或bearer前缀开头则自动去掉前缀(apisix/plugins/jwe-decrypt.lua)。测试 TEST 14/15/16 验证了带 Bearer、不带 Bearer、小写 bearer 三种写法均可正常解密。 - 解析 JWE:
load_jwe_token按 JWE 紧凑序列化格式header.enckey.iv.ciphertext.tag切分五段,并对 header 做 base64url 解码与 JSON 解析(apisix/plugins/jwe-decrypt.lua);解析失败返回 400JWE token invalid(TEST 13/17)。 - 定位密钥:校验 JWE header 中的
kid必须存在,并通过get_consumer(kid)在配置了jwe-decrypt插件的 Consumer 中按key字段精确匹配(apisix/plugins/jwe-decrypt.lua);kid缺失返回 400missing kid in JWE token,找不到对应 Consumer 返回 400invalid kid in JWE token。这意味着密钥分发以kid为索引,一个 Consumer 对应一把密钥。 - 解密:
jwe_decrypt_with_obj用get_secret取出密钥(base64 场景先解码),以 JWE 的 IV 初始化 AES-256-GCM 后解密 ciphertext 并校验 tag(apisix/plugins/jwe-decrypt.lua);失败返回 400failed to decrypt JWE token。 - 回写明文:
core.request.set_header(ctx, conf.forward_header, plaintext)把解密出的明文写入forward_header指定的请求头,随后请求带明文继续转发给上游。测试 TEST 26 通过 httpbin 上游断言了上游收到的Authorization头正是明文"hello"(t/plugin/jwe-decrypt.t)。
加密端由插件声明的插件级 API 提供:_M.api()返回一个 GET 端点/apisix/plugin/jwe/encrypt(apisix/plugins/jwe-decrypt.lua)。APISIX 启动时会收集所有插件声明的 API 路由(见 apisix/api_router.lua 对plugin.api的遍历),因此该端点默认只在内部 API 路由中存在,需配合public-api插件显式暴露到公网。public-api在 access 阶段 将请求 URI 覆盖为目标 URI 并在内部 API 路由中匹配,从而实现"内部端点对外发布"。
gen_token(apisix/plugins/jwe-decrypt.lua)的加密实现细节:
- 必填 URI 参数
key(定位 Consumer 密钥)与payload(要加密的明文),缺key返回 400,找不到 Consumer 返回 404; - 可选参数
iv指定初始化向量,不传时使用固定值"123456789012"(源码中标注为 TODO 随机字节,生产环境建议自行传入随机 IV); - 生成 JWE header:
{"kid": key, "alg": "dir", "enc": "A256GCM"},即直接使用对称密钥(dir 模式)配合 AES-256-GCM; - 输出
header..".."..base64url(iv).."."..base64url(ciphertext).."."..base64url(tag),即紧凑序列化的 JWE Token。
使用示例:从加密到解密的完整闭环
下面按照官方文档 docs/en/latest/plugins/jwe-decrypt.md 的操作顺序,演示完整链路。
第一步:准备 admin_key
Admin API 的调用需要携带管理员密钥,可先从 conf/config.yaml 中读取并保存到环境变量(该文件在deployment.admin.admin_key下配置管理员凭证,生产环境务必替换默认密钥):
admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g')第二步:创建带解密密钥的 Consumer
jwe-decrypt的密钥必须配置在 Consumer 上,key字段即 JWE Token 的kid:
curl http://127.0.0.1:9180/apisix/admin/consumers -H "X-API-KEY: $admin_key" -X PUT -d ' { "username": "jack", "plugins": { "jwe-decrypt": { "key": "user-key", "secret": "-secret-length-must-be-32-chars-" } } }'注意
secret必须是 32 个字符(上述示例字符串恰好 32 字符)。若密钥以 base64 形式存储,需同时设置"is_base64_encoded": true,例如测试 t/plugin/jwe-decrypt.t 中使用的fo4XKdZ1xSrIZyms4q2BwPrW5lMpls9qqy5tiAk2esc=。
第三步:在 Route 上启用解密
创建一个启用jwe-decrypt的 Route(此处插件配置留空,即使用Authorization头作为输入与输出):
curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X PUT -d ' { "methods": ["GET"], "uri": "/anything*", "plugins": { "jwe-decrypt": {} }, "upstream": { "type": "roundrobin", "nodes": { "httpbin.org:80": 1 } } }'若希望从自定义请求头读取 Token、并把明文写入另一个头,可显式配置header与forward_header,例如"jwe-decrypt": {"header": "X-JWE", "forward_header": "X-Plain"}。
第四步:暴露加密端点并生成 JWE Token
插件自带的加密端点默认不对外,需要创建一条挂载public-api插件的 Route 来暴露:
curl http://127.0.0.1:9180/apisix/admin/routes/jwenew -H "X-API-KEY: $admin_key" -X PUT -d ' { "uri": "/apisix/plugin/jwe/encrypt", "plugins": { "public-api": {} } }'然后通过 URI 参数key(Consumer 的 key)和payload(要加密的明文)调用加密接口(注意 payload 需 URL 编码):
curl -G --data-urlencode 'payload={"uid":10000,"uname":"test"}' 'http://127.0.0.1:9080/apisix/plugin/jwe/encrypt?key=user-key' -i响应体即为紧凑序列化的 JWE Token(响应头中的Apisix-Plugins: public-api表明请求由 public-api 插件处理):
HTTP/1.1 200 OK Date: Mon, 25 Sep 2023 02:38:16 GMT Content-Type: text/plain; charset=utf-8 Transfer-Encoding: chunked Connection: keep-alive Server: APISIX/3.5.0 Apisix-Plugins: public-api eyJhbGciOiJkaXIiLCJraWQiOiJ1c2VyLWtleSIsImVuYyI6IkEyNTZHQ00ifQ..MTIzNDU2Nzg5MDEy.hfzMJ0YfmbMcJ0ojgv4PYAHxPjlgMivmv35MiA.7nilnBt2dxLR_O6kf-HQUA对该 Token 做 base64url 解码其 header 即可验证:{"alg":"dir","kid":"user-key","enc":"A256GCM"},kid与 Consumer 的key一一对应。
第五步:携带 JWE Token 访问 Route 完成解密
把上一步得到的 Token 放进Authorization头请求 Route:
curl http://127.0.0.1:9080/anything/hello -H 'Authorization: eyJhbGciOiJkaXIiLCJraWQiOiJ1c2VyLWtleSIsImVuYyI6IkEyNTZHQ00ifQ..MTIzNDU2Nzg5MDEy.hfzMJ0YfmbMcJ0ojgv4PYAHxPjlgMivmv35MiA.7nilnBt2dxLR_O6kf-HQUA' -i从响应可以看到,上游 httpbin 收到的Authorization头已经是解密后的明文 JSON(响应头Apisix-Plugins: jwe-decrypt标明插件已生效):
HTTP/1.1 200 OK Content-Type: application/json Content-Length: 452 Connection: keep-alive Date: Mon, 25 Sep 2023 02:38:59 GMT Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true Server: APISIX/3.5.0 Apisix-Plugins: jwe-decrypt { "args": {}, "data": "", "files": {}, "form": {}, "headers": { "Accept": "*/*", "Authorization": "{\"uid\":10000,\"uname\":\"test\"}", "Host": "127.0.0.1", "User-Agent": "curl/8.1.2", "X-Amzn-Trace-Id": "Root=1-6510f2c3-1586ec011a22b5094dbe1896", "X-Forwarded-Host": "127.0.0.1" }, "json": null, "method": "GET", "origin": "127.0.0.1, 119.143.79.94", "url": "http://127.0.0.1/anything/hello" }常见错误响应速查
结合 jwe-decrypt 测试用例 与源码,可将各错误分支整理如下,便于排障:
| 触发条件 | 响应 |
|---|---|
请求缺失 JWE Token 且strict = true | 403 {"message":"missing JWE token in request"} |
| Token 不符合 JWE 紧凑格式 / header 无法解析 | 400 {"message":"JWE token invalid"} |
JWE header 中缺少kid | 400 {"message":"missing kid in JWE token"} |
kid找不到对应 Consumer | 400 {"message":"invalid kid in JWE token"} |
| 解密失败(密钥错误、密文被篡改等) | 400 {"message":"failed to decrypt JWE token"} |
加密端点缺失key参数 | 400 |
加密端点key找不到 Consumer | 404 |
| 非 GET 方法访问加密端点 | 404(见 TEST 11) |
Consumersecret非 32 字符(未加密存储时) | schema 校验失败:the secret length should be 32 chars |
删除插件
要移除jwe-decrypt插件,只需把 Route 配置中plugins字段里的对应 JSON 配置删除,APISIX 会自动热加载,无需重启:
curl http://127.0.0.1:9180/apisix/admin/routes/1 -H "X-API-KEY: $admin_key" -X PUT -d ' { "methods": ["GET"], "uri": "/anything*", "plugins": {}, "upstream": { "type": "roundrobin", "nodes": { "httpbin.org:80": 1 } } }'删除后,Route 不再执行 JWE 解密,Authorization头会原样透传给上游。同理,删除 Consumer 上配置的jwe-decrypt插件即可撤销对应密钥(测试 t/plugin/jwe-decrypt.t 的 TEST 18 演示了删除 Consumer 后加密端点对已删除 key 的行为变化)。
注意事项与最佳实践
- 密钥长度与算法绑定:插件固定使用 AES-256-GCM(
alg=dir),因此密钥必须是解码后 32 字节(256 位),这是校验逻辑的硬性前提。 kid即密钥索引:JWE header 的kid必须与 Consumer 的key精确匹配,多租户场景下应为每个 Consumer 配置独立密钥。- 密钥托管:建议通过 Secret 资源 将
secret存放到外部密钥管理器;同时可在 conf/config.yaml 中开启apisix.data_encryption.enable_encrypt_fields,让 etcd 中存储的key/secret自动加密(开启后长度校验自动跳过,因为密文必然超过 32 字符)。 strict模式取舍:生产环境建议保持strict = true,防止未携带 Token 的请求被静默放行;若插件仅用于"能解就解、解不了不拦截"的场景再考虑false。- IV 的随机性:加密端点的
iv参数不传时使用固定值(源码中标注为待改进项),涉密场景建议每次调用传入随机 IV;同时加密端点一经public-api暴露即公开可访问,请结合访问控制(如 ip-restriction 插件)限制调用来源。 - 前后端协议一致性:解密后的明文会覆盖写入
forward_header,上游需按约定解析该头;若header与forward_header相同,原始 JWE Token 将被明文替换,避免密文泄露给上游。
【免费下载链接】apisixThe Cloud-Native API Gateway项目地址: https://gitcode.com/GitHub_Trending/ap/apisix
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考