Playwright Python 客户端证书(mTLS)测试资产解析:从证书生成到自动化验证

原创2026-10-01 12:19:21818 阅读
文章标签:测试GUI 自动化网页爬虫

Playwright Python 客户端证书(mTLS)测试资产解析:从证书生成到自动化验证

本篇技术指南围绕 Playwright Python 仓库中 tests/assets/client-certificates/README.md 所管理的客户端证书测试资产展开,完整讲解如何通过一行命令重新生成整套 TLS 双向认证(mTLS)测试证书,并深入剖析证书在 client_certificates 配置中的使用方式、底层协议序列化实现以及对应的端到端测试验证逻辑。读完本文,你将掌握在 Playwright Python 项目中搭建客户端证书测试环境的完整方案,包括服务端签名证书、自签名证书、PFX 等多种形态的生成与选用场景。

一、这套测试资产在项目中的定位

Playwright Python 支持 TLS Client Authentication(TLS 客户端认证):当服务端在 TLS 握手阶段要求客户端出示证书时,浏览器需要携带指定的客户端证书才能完成认证。仓库通过 tests/async/test_browsercontext_client_certificates.py 与 tests/sync/test_browsercontext_client_certificates.py 两套对称的测试,专门验证 client_certificates 参数在普通上下文、持久化上下文以及 API Request 上下文中的行为。

而所有测试所需的证书材料,全部集中存放在 tests/assets/client-certificates/ 目录下。该目录内的 README 是一份极其精简的“证书再生成说明”:核心命令只有一条 bash generate.sh,用于重新生成全部证书。真正的内容承载在 generate.sh 脚本中,它完整刻画了每一类证书的 OpenSSL 生成与签发流程,是理解这套测试资产的一手资料。

二、目录结构与证书类型总览

tests/assets/client-certificates/
├── README.md          # 再生成说明(bash generate.sh)
├── generate.sh        # 全套证书生成脚本
├── server/
│   ├── server_cert.pem   # 服务端证书(同时充当测试 CA)
│   └── server_key.pem    # 服务端私钥
└── client/
    ├── trusted/          # 受信任客户端证书(服务端签名,有效)
    │   ├── cert.pem / cert.pfx / cert-legacy.pfx
    │   ├── csr.pem / key.pem
    ├── localhost/        # localhost 专用受信任证书
    │   ├── localhost.pem / localhost.key / localhost.csr / localhost.ext
    └── self-signed/      # 自签名客户端证书(服务端不可信,无效)
        ├── cert.pem / csr.pem / key.pem

从证书的信任关系看,可以划分为三类:

证书 签发方式 信任状态 用途
client/trusted/* 由 server/server_cert.pem 作为 CA 签发 受信任(有效) 验证正向通过场景,服务端返回 200
client/localhost/* 由 server/server_cert.pem 签发,CN=localhost 受信任(有效) 验证 origin 匹配(https://127.0.0.1:8000)场景
client/self-signed/* 自身私钥自签 不受信任(无效) 验证拒绝场景,浏览器抛出 alert unknown ca

这里的“受信任”是相对于测试服务器而言的:测试服务器把 server_cert.pem 作为信任根(CA)来校验客户端证书链(见下文测试服务器实现),因此由它签发的客户端证书可通过校验,而自签名的 Bob 证书则会被拒绝。

三、重新生成全部证书:一行命令

原 README 给出的核心操作只有一条命令:

bash generate.sh

该命令会在 tests/assets/client-certificates/ 目录下原地重建全部证书。由于脚本开头使用了 cd "$(dirname "$0")"(generate.sh),因此无论从仓库根目录还是从 tests/assets/client-certificates/ 目录执行,它都会自动切换到脚本所在目录,保证输出路径相对稳定。

执行前请确保系统已安装 OpenSSL 命令行工具(openssl version 可查看版本),脚本依赖 openssl req、openssl x509、openssl pkcs12 三个子命令,无需其他第三方依赖。

四、逐段拆解 generate.sh

4.1 服务端证书(Server Certificate)

openssl req \
	-x509 \
	-newkey rsa:4096 \
	-keyout server/server_key.pem \
	-out server/server_cert.pem \
	-nodes \
	-days 3650 \
	-subj "/CN=localhost/O=Client\ Certificate\ Demo" \
	-addext "subjectAltName=DNS:localhost,DNS:local.playwright"

这一步直接以 -x509 模式自签生成一张服务端证书,关键参数含义:

  • -newkey rsa:4096:同时生成 4096 位 RSA 新私钥并写入 server_key.pem;
  • -nodes:私钥不加密(no DES),便于测试环境直接读取;
  • -days 3650:证书有效期 10 年,避免测试资产频繁过期;
  • -subj "/CN=localhost/...":证书主体 CN 为 localhost,组织为 Client Certificate Demo;
  • -addext "subjectAltName=DNS:localhost,DNS:local.playwright":写入 SAN 扩展,覆盖 localhost 与 local.playwright 两个主机名。

这张证书在后续环节中扮演双重角色:既作为测试服务器的 TLS 服务端证书,又作为签发客户端证书的测试 CA(-CA server/server_cert.pem -CAkey server/server_key.pem)。

4.2 受信任客户端证书(Trusted Client Certificate)

这是测试中最核心的一份客户端证书,分三步生成:

第一步:生成 Alice 的私钥与 CSR

openssl req \
	-newkey rsa:4096 \
	-keyout client/trusted/key.pem \
	-out client/trusted/csr.pem \
	-nodes \
	-days 3650 \
	-subj "/CN=Alice"

CN=Alice 会被测试服务器读取并拼进响应文案,例如 Hello Alice, your certificate was issued by localhost!。

第二步:用服务器证书作为 CA 签发

openssl x509 \
	-req \
	-in client/trusted/csr.pem \
	-CA server/server_cert.pem \
	-CAkey server/server_key.pem \
	-out client/trusted/cert.pem \
	-set_serial 01 \
	-days 3650

-CA / -CAkey 使服务端证书充当 CA 对 CSR 签名,-set_serial 01 固定序列号,保证重建后的证书内容可复现。

第三步:生成 PFX 容器

openssl pkcs12 -export -out client/trusted/cert.pfx -inkey client/trusted/key.pem -in client/trusted/cert.pem -passout pass:secure

PFX(PKCS#12)把私钥与证书打包成单个文件,口令为 secure。目录中还保留了 cert-legacy.pfx,用于覆盖旧版 PFX 格式的兼容性场景。当 API 侧不方便直接提供 PEM 路径时,可使用 pfxPath / pfx 字段携带 PFX 与 passphrase(见第五节)。

4.3 localhost 专用受信任证书

openssl req \
	-newkey rsa:4096 \
	-keyout client/localhost/localhost.key \
	-out client/localhost/localhost.csr \
	-nodes \
	-days 3650 \
	-subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,DNS:127.0.0.1"

随后脚本把 SAN 扩展写入 localhost.ext 文件,并在签发时通过 -extfile client/localhost/localhost.ext 一并注入:

echo "subjectAltName=DNS:localhost,DNS:127.0.0.1" > client/localhost/localhost.ext

openssl x509 \
	-req \
	-in client/localhost/localhost.csr \
	-CA server/server_cert.pem \
	-CAkey server/server_key.pem \
	-set_serial 01 \
	-out client/localhost/localhost.pem \
	-days 3650 \
  -extfile client/localhost/localhost.ext

CN=localhost 的客户端证书用于测试“证书已配置、但 origin 不匹配”的行为:测试中为 https://127.0.0.1:8000 配置证书,访问 https://localhost:8000 时由于 origin 不同不会携带证书,从而得到 401;访问 https://127.0.0.1:8000 时携带证书得到 200。

4.4 自签名证书(Self-signed,无效)

openssl req \
	-newkey rsa:4096 \
	-keyout client/self-signed/key.pem \
	-out client/self-signed/csr.pem \
	-nodes \
	-days 3650 \
	-subj "/CN=Bob"

openssl x509 \
	-req \
	-in client/self-signed/csr.pem \
	-signkey client/self-signed/key.pem \
	-out client/self-signed/cert.pem \
	-days 3650

这里用 Bob 自己的私钥自签(-signkey),没有经过测试 CA 签发,因此服务端校验其证书链时必然失败。这正是测试 test_should_throw_with_untrusted_client_certs 所依赖的素材:浏览器在握手阶段直接抛出 alert unknown ca 错误(见第七节)。

五、client_certificates 配置结构与字段语义

测试中通过浏览器上下文与 API Request 上下文传入客户端证书,配置项的 Python 类型定义位于 playwright/_impl/_api_structures.py:

class ClientCertificate(TypedDict, total=False):
    origin: str
    certPath: Optional[Union[str, Path]]
    cert: Optional[bytes]
    keyPath: Optional[Union[str, Path]]
    key: Optional[bytes]

结合 playwright/sync_api/_generated.py 中生成的 API 签名,完整字段还包括 pfxPath、pfx、passphrase,全部可选。各字段语义如下:

字段 类型 说明
origin str 必填。证书适用的来源,如 https://127.0.0.1:8000,仅当请求 origin 匹配时浏览器才会携带该证书
certPath / cert Path / bytes 证书文件路径或直接提供 PEM 证书字节内容,二者二选一
keyPath / key Path / bytes 对应私钥路径或字节内容,二者二选一
pfxPath / pfx Path / bytes PFX 容器路径或字节内容,用于 PKCS#12 形态
passphrase str PFX 口令,对应 generate.sh 中的 -passout pass:secure

使用路径形态(certPath + keyPath)是测试中的主流方式,例如:

context = await browser.new_context(
    ignore_https_errors=True,
    client_certificates=[
        {
            "origin": "https://127.0.0.1:8000",
            "certPath": assetdir / "client-certificates/client/trusted/cert.pem",
            "keyPath": assetdir / "client-certificates/client/trusted/key.pem",
        }
    ],
)

使用字节内容形态(cert + key)的等价写法是把 PEM 文件读成 bytes 后直接传入,这在证书内容需要动态下发、不便依赖文件路径的场景中更实用。注意 origin 必须精确匹配请求 URL 的协议、主机与端口,测试中 https://127.0.0.1:8000 与 https://localhost:8000 被视作不同 origin,这正是多张证书素材存在的意义。

六、证书如何进入浏览器协议:底层序列化实现

client_certificates 列表并不会被直接透传给浏览器驱动,而是先在 playwright/_impl/_network.py 的 to_client_certificates_protocol 中做一次“路径 → 内容”的转换:

async def to_client_certificates_protocol(
    clientCertificates: Optional[List[ClientCertificate]],
) -> Optional[List[Dict[str, str]]]:
    ...
    out_record = {"origin": clientCertificate["origin"]}
    if passphrase := clientCertificate.get("passphrase"):
        out_record["passphrase"] = passphrase
    if pfx := clientCertificate.get("pfx"):
        out_record["pfx"] = base64.b64encode(pfx).decode()
    if pfx_path := clientCertificate.get("pfxPath"):
        out_record["pfx"] = base64.b64encode(
            await async_readfile(pfx_path)
        ).decode()
    if cert := clientCertificate.get("cert"):
        out_record["cert"] = base64.b64encode(cert).decode()
    if cert_path := clientCertificate.get("certPath"):
        out_record["cert"] = base64.b64encode(
            await async_readfile(cert_path)
        ).decode()
    ...

由此可以看出三个实现事实:

  1. 路径会被读取:所有 *Path 字段都通过 async_readfile 在 Python 侧读入,证书与私钥最终都以 Base64 编码字符串传给浏览器端协议;
  2. PFX 与 PEM 二选一:pfxPath/pfx 与 certPath/cert 分别映射到协议中的 pfx 与 cert 字段,二者互斥使用;
  3. origin 原样透传:作为匹配键,最终由浏览器端按 origin 决定是否发送证书。

这一转换逻辑在两类入口被复用:浏览器上下文路径(playwright/_impl/_browser_type.py 中的 new_context/launch_persistent_context 参数处理)与 API Request 路径(playwright/_impl/_fetch.py 中的 new_context 参数处理),这也解释了为什么第七节的测试能在页面导航与纯 API 请求两条链路上得到一致结果。

七、端到端测试验证:证书如何被“使用”

7.1 测试服务器行为

两套测试文件(async 版、sync 版)共用同一套由 Twisted 搭建的 HTTPS 测试服务器(监听 8000 端口,reactor.listenSSL(8000, ...))。服务器读取 server/server_cert.pem 与 server/server_key.pem 启动 TLS,并在 HttpsResource.render_GET 中校验客户端证书:

  • 校验通过(证书链以 server_cert.pem 为信任根):返回 HTTP 200,文案 Hello {CN}, your certificate was issued by {issuer}!",即 Hello Alice, your certificate was issued by localhost!;
  • 有证书但校验失败:返回 HTTP 403,文案 Sorry {CN}, certificates from {issuer} are not welcome here.;
  • 无证书:返回 HTTP 401,文案 Sorry, but you need to provide a client certificate to continue.。

校验逻辑基于 OpenSSL 的 X509Store:把服务端证书加入信任库后,用 X509StoreContext.verify_certificate() 验证客户端证书链(_verify_cert_chain)。因此“受信任”的本质就是“由 server_cert 这把测试 CA 签发”。

7.2 用例与结果矩阵

async 与 sync 两套文件用例一一对应,覆盖以下场景:

测试用例 配置素材 预期行为
test_should_throw_with_untrusted_client_certs self-signed/cert.pem + key.pem,origin 为 https://localhost:8000/ 请求抛出异常,匹配 alert unknown ca
test_should_work_with_new_context trusted/cert.pem + key.pem,origin 为 https://127.0.0.1:8000 访问 localhost 得 401,访问 127.0.0.1 得 Hello Alice, ...;API Request 同样验证
test_should_work_with_new_context_passing_as_content 以 cert/key 字节内容传参 与路径传参行为完全一致
test_should_work_with_new_persistent_context trusted 证书经 launch_persistent_context 传入 持久化上下文同样生效
test_should_work_with_global_api_request_context trusted 证书经 playwright.request.new_context 传入 纯 API 链路同样生效

测试文件还包含一个平台相关约束:_skip_webkit_darwin fixture 表明 WebKit 在 macOS 上不代理 localhost,因此该测试在 macOS + WebKit 组合下直接跳过。

八、在自己的项目中复用这套模式

如果要在自己的 Playwright Python 项目中建立客户端证书测试环境,可直接照搬本仓库的资产与脚本:

  1. 复制资产:把 tests/assets/client-certificates 整个目录拷贝到项目测试资产目录下;
  2. 重建证书:运行 bash generate.sh,按需修改 -subj 中的 CN(对应测试文案中的主体名称)、-days 有效期以及 -passout pass:secure 中的 PFX 口令;
  3. 配置传入:在 browser.new_context() / browser_type.launch_persistent_context() / playwright.request.new_context() 中填写 client_certificates 列表,并确保 origin 与测试 URL 的 scheme + host + port 完全一致;
  4. 编写服务端校验:参照测试中的 HttpsResource,用 OpenSSL X509Store 校验客户端证书链,并在响应中区分 200 / 403 / 401 三种情况,即可断言证书是否真正被浏览器携带。

需要注意两点前提:一是受信任证书必须由你信任的 CA(脚本中即 server_cert.pem)签发,自签名证书只能用于负向测试;二是若测试机尚未配置该 CA 为系统信任,通常需与 ignore_https_errors=True 搭配使用(测试源码中以 TODO 注释标注“移除后即可传入自定义 CA”)。

九、注意事项与边界说明

  • 命令环境:generate.sh 依赖 OpenSSL 命令行,需在安装了 OpenSSL 的环境(Linux/macOS/WSL)中执行;
  • 证书有效期:所有证书均为 10 年有效期,且序列号固定(-set_serial 01),重建结果可复现;
  • 私钥形态:-nodes 生成的私钥未加密,仅适用于本地测试资产,不应直接用于生产环境;
  • PFX 口令:仓库中 cert.pfx 的口令固定为 secure(-passout pass:secure),使用 pfxPath/pfx 时必须同步传入 passphrase="secure";
  • origin 匹配粒度:client_certificates 的 origin 按完整来源匹配,127.0.0.1 与 localhost 是不同 origin,配置时需按实际访问地址逐一覆盖。

通过 README 指引、generate.sh 的完整命令语义以及测试用例与底层序列化代码的相互印证,你可以完整掌握 Playwright Python 客户端证书测试环境的构建与验证全流程——这套资产既是仓库自测的基础设施,也是读者在自己项目中搭建 mTLS 测试的现成模板。

登录后查看全文
playwright-python