登录自动化:一份完整的排查手记
在 Playwright E2E 测试中,用 11 种方法尝试自动化 CAS 登录,从失败到成功。这篇文章记录了整个过程------不跳过任何一条死胡同。
背景
给公司内部的 SkillHub 平台写 Playwright E2E 测试。首页、搜索、路由、性能------38 个用例覆盖了未登录态下的所有场景。但登录态测试是零:没有真实的登录会话,Dashboard、命名空间、Token 管理这些核心功能都没法测。
SkillHub 的登录链路是这样的:
- 首页 → 点击「登录」→ 登录页面
- 登录页有两种方式:桌面客户端 SSO 和「企业统一认证」
- 企业统一认证 → 跳转到公司 CAS Server → 输入用户名密码 → 验证通过 → 带 ticket 回调 SkillHub
目标很简单:让 Playwright 自动完成步骤 2-3,保存登录会话,后续测试复用。
实际用了 11 次尝试才成功。
前置:什么是 CAS
CAS --- Central Authentication Service(中央认证服务),是耶鲁大学开发的开源单点登录协议,企业/高校使用最广。
它的认证流程是一个标准的 OAuth-like 三次握手:
用户 → 应用(SkillHub)
↓ 未登录 → 302 跳转
CAS Server
↓ 输入用户名密码
↓ 验证通过 → 302 Location: 应用/callback?ticket=ST-xxx
应用(SkillHub)
↓ 后端用 ticket 调用 CAS /serviceValidate
↓ 获取用户信息,写入 SESSION cookie
用户 → 已登录态
对于自动化来说,关键点是第三步:CAS Server 返回的 302 重定向中包含 ?ticket= 参数 。如果 CAS 返回的 Location: https://cas-server/login(回到自身而不是跳到回调),说明认证失败。CAS 不会返回「用户名或密码错误」------它静默地把 302 重定向到自己的登录页。
这个设计让排查变得很困难。
尝试 #1-8:一切看起来都对,但 CAS 就是拒绝
#1 --- 桌面客户端 SSO(失败)
点击 桌面客户端 SSO按钮
→ 浏览器尝试打开内部协议(如 teams:// 或其他自定义协议)
→ Playwright 捕获到 about:blank 弹窗
→ 后续无法继续
桌面客户端 SSO 不是标准 Web OAuth,而是自定义系统级协议。Playwright 无法拦截 Native 协议。
放弃 SSO,走 Web 表单。
#2 --- page.fill()(失败)
python
page.fill('input[name="username"]', creds['username'])
page.fill('input[name="password"]', creds['password'])
page.click('button:has-text("登录")')
CAS 返回 302 Location: https://cas-server/login。回到登录页自身,拒绝。
#3 --- page.type()(失败)
怀疑 fill() 的编码方式和用户真实输入不同。换成 type() 逐字符模拟:
python
page.type('input[name="username"]', creds['username'], delay=50)
page.type('input[name="password"]', creds['password'], delay=50)
结果相同。
#4 --- keyboard.type()(失败)
怀疑 type() 和用户按键盘的底层事件有区别。换成 keyboard.type() 逐键敲击 + delay。
结果相同。
#5 --- form.submit()(失败)
怀疑 Element UI 拦截了按钮的 click 事件。换成绕过按钮直接触发表单提交:
python
page.evaluate('document.querySelector("form[method=\\"post\\"]").submit()')
结果相同。
#6 --- form.requestSubmit()(失败)
form.submit() 不触发表单的 submit 事件和验证逻辑。换成 HTML5 原生 requestSubmit():
python
page.evaluate('document.querySelector("form[method=\\"post\\"]").requestSubmit()')
结果相同。
#7 --- curl(失败)
怀疑是 Playwright 浏览器环境的网络层问题。直接绕过浏览器,用 curl POST:
bash
curl -X POST 'http://cas-server/login' \
-d 'username=testuser&password=...&execution=e1s1&_eventId=submit'
CAS 返回 302 Location: https://cas-server/login。拒绝。与 Playwright 结果一致。
#8 --- page.request.post()(失败)
Playwright 本身有一个纯网络层的 request API,不需要浏览器渲染。尝试用这个直接 POST:
python
page.request.post('http://cas-server/login', form={
'username': creds['username'],
'password': creds['password'],
'execution': 'e1s1',
'_eventId': 'submit',
}, max_redirects=0)
结果相同。
中间站:手动验证靠不靠谱
排查了几轮之后,首先需要确认一个基础问题:用户名和密码到底对不对?
在真实浏览器中手动打开 CAS 登录页面,输入同样的用户名和密码 ------ 成功登录了。
这说明三个事实:
- 凭据正确
- CAS 服务正常
- 问题不在网络层,不在表单编码,在 浏览器身份
尝试 #9-10:绕道补票
#9 --- 手动登录保存 storageState(部分失效)
打开 Headed 浏览器 → 等人手动完成登录 → context.storage_state() 保存 cookie → 新 context 加载 cookie → 访问 Dashboard:
python
context.storage_state(path='/tmp/skillhub-auth.json')
# 新测试
context2 = browser.new_context(storage_state='/tmp/skillhub-auth.json')
page2 = context2.new_page()
page2.goto('https://skill-dev.example.com/dashboard')
Dashboard 内容显示仍被重定向到登录页 。检查 cookie:SESSION cookie 确实保存了,也在请求中发送了。但 auth/me 返回 401。
结论 :SESSION cookie 与特定的浏览器指纹绑定。storageState 只能保存和恢复 cookie,无法恢复指纹。
#10 --- Codegen 录制(失败)
Playwright Codegen 记录了人工操作的每一步:点击、填表、Enter。重放后结果与 #2-#6 一致。录制只是录了操作序列,底层的浏览器还是被标记为自动化工具。
尝试 #11:反检测 --- 正确解法
之前的线索全部指向同一个方向:CAS 前端(Element UI SPA)正在检测浏览器是否由自动化工具驱动。
普通浏览器中 navigator.webdriver 的值为 undefined。被 Playwright 控制的浏览器中,该属性被设为 true。CAS 页面检查这个标记,如果发现是自动化工具,直接拒绝认证------不返回错误信息,静默重定向回登录页。
解决方法:
python
browser = p.chromium.launch(
headless=False,
args=['--disable-blink-features=AutomationControlled'],
)
page.add_init_script("""
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5]
});
""")
三个要点:
-
--disable-blink-features=AutomationControlled:禁止 Chromium 在navigator对象上注入webdriver属性。这是 Chrome DevTools Protocol 中的一个标记位,专门用来标识浏览器是否被自动化工具控制。 -
navigator.webdriver = undefined:额外保障,在任何页面脚本执行前(add_init_script),先覆盖掉可能残留的标记。真实浏览器中这个属性根本不存在,所以恢复成undefined。 -
navigator.plugins:CAS 前端的 Element UI 可能还会检查浏览器插件阵列。真实 Chrome 有一组内置插件(Chrome PDF Viewer 等),Headless 返回空数组。设成[1,2,3,4,5]让检测代码认为有插件存在。
加上这三行,提交表单后一秒内 login → ticket → callback → auth/me 200 → 登录成功。
登录后:持久化与复用
一次登录成功后,Playwright 的标准做法是用 storageState 序列化 cookie 和 localStorage,后续测试直接加载。
但记住:保存和加载必须在同一个反检测上下文中 。指纹清除是在浏览器启动时做的,storageState 本身不包含反检测的配置。后续测试如果用干净的 Playwright 默认配置加载 cookie,指纹检测仍然会生效。
幸运的是,CAS 只在登录时验证指纹------登录完成后,后续 API 调用只检查 SESSION cookie,不重复检测浏览器环境。所以反检测只需要在登录那一次使用,后续测试可以直接 headless 加载 storageState。
验证流程:
python
# 步骤 1:登录(需要反检测)
# headed + 反检测 → 登录成功 → 保存 storageState
# 步骤 2:后续测试(不需要反检测)
# headless + storageState 加载 → Dashboard 正常 → auth/me 200
# → namespaces 可访问 → tokens 可管理
经验总结
| 教训 | 具体场景 |
|---|---|
| 遇到静默失败,先手动验证 | 如果没先手动试一次 CAS 登录,可能在凭据错误上排查几轮。实际花费最大的步骤是确认「凭据是对的」。先手动登录一次,排除凭据本身的问题 |
| CAS 不返回错误消息 | 认证失败只返 302 到登录页自身。如果有「Invalid credentials」之类的提示,问题定位会快很多。这一点在设计 API 时值得借鉴 |
| SPA 框架可能检测自动化工具 | Element UI 会检查 navigator.webdriver。这是一个越来越常见的反爬策略。如果你发现手动能登录但 Playwright 不行,第一个检查的就应该是这个 |
| 表单提交方式不是根因 | fill、type、keyboard.type、form.submit、requestSubmit ------ 尝试了 5 种。最终不是提交方式的问题,根本没走到这一步。在方向上多花一分钟,胜过在细节上浪费一小时 |
| storageState 只保存 cookie,不保存指纹 | 如果某个系统把 cookie 绑定到浏览器指纹,仅 cookie 不够。需要在一个持久化的反检测浏览器实例中完成所有操作,或确认登录后不再检查指纹 |
| 用 Playwright Codegen 不是万能药 | Codegen 录制了精确的 UI 操作序列,但底层仍然是 Playwright 的自动化机制。自动化标记不会因为操作方式改变而消失。Codegen 适合生成选择器,不适合绕过反自动化检测 |
| 反检测的三个关键点 | --disable-blink-features=AutomationControlled 禁标记注入 → navigator.webdriver 手动覆盖 → 后续测试直接用 storageState 复用以避免重复反检测 |