TT Lab
开始
学习 学习路径 课程

会话与令牌 — 从浏览器到服务网格

Envoy jwt_authn 与两种身份

在 TT Lab 中继续学习

目标

使用 Envoy 的 jwt_authn 过滤器验证本地 JWKS 中的 RS256 令牌,在上游之前拦下错误请求,只把验证过的声明作为请求头传下去,然后通过真实请求确认:mTLS 所证明的工作负载身份与 JWT 所证明的用户身份是两回事。

为什么重要

引入网格后,令牌验证不必在每个服务里重复做,可以转移到每个服务前面那个代理的一处配置中。作为代价,上游会把代理传来的请求头当作身份来信任,所以如果在跳过验证的路径上混进了伪造的请求头,就会直接变成身份伪造。另外,服务之间启用了 mTLS,并不意味着用户认证已经完成。证书只能说明哪个工作负载发起了连接,至于这是代表谁发的请求,只有令牌才能说明。本实验让你在同一个请求里并排看到这两种身份。

步骤

  1. 在 /root/st/mesh/private.pem 中放入 RSA 2048 私钥,在 /root/st/mesh/jwks.json 中放入只含其公开分量的 JWK Set(kid mesh-k1,RS256),并用 /root/st/mesh/mint.py 签发 RS256 令牌(iss https://issuer.mesh.lab,aud mesh-api)。
  2. 在 /root/st/mesh/envoy.yaml 中配置管理端口 19901、监听器 18000、集群 app(18081),以及 jwt_authn(provider mesh)和其后的 router,并用 envoy --mode validate 检查。
  3. 在 18081 上启动 /root/st/mesh/upstream.py 回显服务器,并启动 Envoy,用 alice 令牌请求 /api/orders,在 /root/st/mesh/allow.txt 中写入 code=200、path=/api/orders。
  4. 发送无令牌、伪造、过期、受众错误的请求,在 /root/st/mesh/deny.txt 中写入 401、401、401、403,并确保被拒绝的请求不会出现在上游日志中。
  5. 加入 forward_payload_header: x-jwt-payload,把上游收到的声明写入 /root/st/mesh/claims.txt。
  6. 将 /public 开放为免验证的例外,并在该路径上删除 x-jwt-payload,然后在 /root/st/mesh/public.txt 中写入 200、401、absent。
  7. 在 /root/st/mesh/pki/ 中创建 CA、服务器和客户端证书,在 18000 上加入 mTLS 链,然后用 /root/st/mesh/e2e.sh 测试两种身份,在 /root/st/mesh/e2e.out 中留下七行。

参考

创建签名密钥和 JWKS

在 /root/st/mesh/private.pem 中创建 RSA 2048 私钥,并把只含公开分量的 JWK Set 放到 /root/st/mesh/jwks.json 中(一把密钥:kty RSA、kid mesh-k1、alg RS256、use sig、n、e)。/root/st/mesh/mint.py 通过 python3 /root/st/mesh/mint.py <sub> 输出一行 RS256 令牌——令牌头部的 kid 为 mesh-k1,声明为 iss https://issuer.mesh.lab、aud mesh-api、sub、iat、exp(默认 5 分钟后)。让它支持 --aud、--ttl(单位为秒,负数表示已过期)、--key(另一把私钥)选项。

JWT 由 base64url(헤더).base64url(페이로드).base64url(서명)(占位符依次为头部、载荷、签名)三段组成,签名的对象是用点连接前两段得到的字符串。RS256 使用 cryptography 的 key.sign(데이터, padding.PKCS1v15(), hashes.SHA256())(占位符为待签名数据)。base64url 要去掉末尾的 =。JWK 的 n、e 是把公钥数值(public_numbers())转成大端字节后,按同样的方式编码。JWKS 是对任何人公开的文件,所以不能含有私有分量(d、p、q)。

验证 jwt_authn 配置

创建 /root/st/mesh/envoy.yaml——管理接口 127.0.0.1:19901,监听器 127.0.0.1:18000,集群 app 为 127.0.0.1:18081。HTTP 过滤器的顺序是 envoy.filters.http.jwt_authn 之后接 envoy.filters.http.router。provider 名称为 mesh,issuer 为 https://issuer.mesh.lab,audiences 为 [mesh-api],local_jwks.filename 为 /root/st/mesh/jwks.json,规则是 prefix / 要求 provider_name: mesh。envoy --mode validate -c /root/st/mesh/envoy.yaml 必须通过。

过滤器链按顺序执行。如果验证过滤器位于 router 之后,请求已经发往上游,验证就毫无意义。rules 中的 requires 为空,意味着该路径不做验证——不是“有令牌就检查”,而是根本不看。allow_missing_or_failed 连错误的令牌也会放行,所以这一步不要使用。--mode validate 不会打开端口,只读取配置,因此用来在启动之前发现字段名之类的失误。

有效令牌会到达上游

在 127.0.0.1:18081 上启动 /root/st/mesh/upstream.py——对任意 GET 路径都返回 200 和 JSON {"path": 요청 경로, "headers": {소문자 헤더 이름: 값}}(占位符依次为请求路径、小写的请求头名称与值),并把同样的 JSON 逐行追加到 /root/st/mesh/upstream.log。用 /root/st/mesh/envoy.yaml 启动 Envoy,用 mint.py alice 令牌请求 http://127.0.0.1:18000/api/orders,在 /root/st/mesh/allow.txt 中写入 code=200 和 path=/api/orders 两行。

上游不做认证。它相信前面的 Envoy 已经验证过,只是把收到的内容原样显示出来——所以上游收到了什么,就是实验的证据。服务器和 Envoy 要从 shell 中脱离出来在后台启动(setsid --fork nohup ...),并等待 Envoy 管理端口的 /ready 变为 LIVE。令牌通过 Authorization: Bearer <토큰>(占位符为令牌)请求头发送。

令牌缺失或伪造时在上游之前拦下

对同一个 /api/orders 发送四种请求,在 /root/st/mesh/deny.txt 中以 missing=(无令牌)、forged=(用另一把 RSA 密钥签名但保留 kid 的令牌)、expired=(--ttl -600)、wrong_aud=(--aud billing-api)四行写入收到的状态码。前三个必须是 401,最后一个必须是 403,并且被拒绝的请求在 /root/st/mesh/upstream.log 中一条都不能留下。

攻击者可以随意写 kid 和声明。唯一得不到的只有签发者的私钥,所以签名验证就是防御的全部。伪造令牌的做法是用 openssl genpkey 再生成一把密钥,并用 mint.py --key 签发。留意 401 和 403 的分界——签名或过期有误,是“不知道你是谁”;签名正确但受众不同,是“知道你是谁,但这不是发给这里的令牌”。评分器会给每个请求附带标记请求头,并检查上游日志中是否出现了该标记。

把验证过的声明传给下游

给 provider mesh 加上 forward_payload_header: x-jwt-payload(forward 保持默认值 false),并重新启动 Envoy。用 mint.py alice 令牌发起请求,解码上游收到的请求头,在 /root/st/mesh/claims.txt 中写入 sub=alice、aud=mesh-api、iss=https://issuer.mesh.lab、authorization_forwarded=no 四行。

为了让上游不必再次验证令牌,Envoy 会把验证成功的载荷放进请求头传过去。值是不带填充的 base64url 编码的 JSON。forward 为 false 时,验证之后原始令牌会从请求中删除——这个默认值是为了让上游无法把收到的令牌再用到别处。也请试一次:客户端伪造同名请求头发送过来会怎样。评分器也会检查这一点。Envoy 不会重新读取静态配置,所以改完之后要先停掉再重新启动。

开放公共路径并删除伪造的身份

在 jwt_authn 规则中,把 prefix /public 不带 requires 地放在 / 规则之前,并在路由中也单独设置 prefix /public,用 request_headers_to_remove 删除 x-jwt-payload。重新启动 Envoy 之后,在 /root/st/mesh/public.txt 中写入三行:public=(无令牌访问 /public/status 的状态码)、api=(无令牌访问 /api/orders 的状态码)、spoofed_payload=(附带假的 x-jwt-payload 发往 /public/status 时,如果上游收到了该请求头则为 present,否则为 absent)。期望值为 200、401、absent。

规则是第一个匹配的获胜。如果 / 在前面,所有路径都会被它匹配,公共路径就不会出现。在做验证的路径上,Envoy 会用验证后的值覆盖 x-jwt-payload,但在不做验证的路径上,没有人会动这个请求头。如果上游把该请求头当作身份来信任,公共路径就成了身份伪造的通道。路由中的 request_headers_to_remove 可以堵住这个漏洞。

分别查看工作负载身份和用户身份

在 /root/st/mesh/pki/ 中创建 CA(ca.pem)、服务器证书(server.pem、server.key,SAN 为 IP:127.0.0.1 和 URI:spiffe://mesh.lab/ns/shop/sa/orders)、客户端证书(client.pem、client.key,SAN 为 URI:spiffe://mesh.lab/ns/shop/sa/frontend)。给 18000 监听器加上带 tls_inspector 和 transport_protocol: tls 的过滤器链——require_client_certificate: true,trusted_ca 为 ca.pem,该链的 HCM 使用 forward_client_cert_details: SANITIZE_SET 以及 set_current_client_cert_details 的 uri: true。重新启动后用 /root/st/mesh/e2e.sh 测试,在 /root/st/mesh/e2e.out 中留下七行:valid=200、missing=401、forged=401、workload=spiffe://mesh.lab/ns/shop/sa/frontend(通过 mTLS 发送 alice 令牌时,上游收到的 XFCC 的 URI)、user=alice(同一请求中 x-jwt-payload 的 sub)、mtls_without_jwt=401(只有证书、没有令牌)、without_client_cert=rejected(只有令牌、没有证书,握手失败)。

mTLS 证明的是哪个工作负载建立了连接,JWT 证明的是请求代表的是哪个人。这就是为什么只有证书而没有令牌时应该返回 401——前端 Pod 这个事实并不意味着就是 Alice。要在同一个端口上同时接收 TLS 和明文,需要由监听器过滤器 tls_inspector 查看连接的第一批字节来选择过滤器链。XFCC 的默认行为是 SANITIZE,所以通过明文连接伪造的 XFCC 会被删除。SANITIZE_SET 会丢弃 mTLS 时收到的值,改用自己验证过的证书信息重新填充。如果服务器证书没有 IP:127.0.0.1 SAN,curl 就不会信任这台服务器。用 openssl x509 -req ... -extfile 加入 SAN。