JSESSIONID 在浏览器自动化里是个骗子:登录检测要看重定向终点,不要看 Cookie 名
给某个 J2EE 系统写 RPA 自动化脚本,登录检测逻辑看起来无懈可击——「浏览器里有 JSESSIONID?那一定登录了」。但跑起来,脚本永远走「已登录」分支,然后在第一个需要鉴权的操作上炸掉:选择器找不到,或者被 302 踢回登录页。
最诡异的地方:手动在 DevTools 把 Cookie 全部清掉,再刷新一次页面,JSESSIONID 立刻回来了。
现象
- 用户正常登录,
JSESSIONID=ABC123落库,脚本检测「有该名称的 Cookie」→ 判定已登录。正常。 - 若干分钟后,服务端 Session 超时(Tomcat 默认 30 分钟)。Cookie 还在浏览器里(
maxAge很长),检测仍然报「已登录」。错误。 - 脚本跑
navigate('https://site/user/dashboard.jsp')——服务端看到的 JSESSIONID 对应一个已失效 Session,立刻重定向到/login。同时,Set-Cookie头里附上了一个全新的匿名JSESSIONID=XYZ789。 - 脚本看到 Cookie 名还在,还是判定「已登录」,继续操作,继续炸。
根因
Tomcat / Jetty / JBoss / Spring Session,乃至 PHP 的 session_start()、ASP.NET 的 Session 机制,都有一个共同行为:Session Cookie 是在第一次请求时就下发的,与用户是否认证无关。
Servlet 规范的逻辑是:「我需要一个地方存请求上下文,所以先给你分配一个 Session 容器,通过 Set-Cookie 告诉浏览器。」这个动作对匿名访客和已认证用户同样触发。
结果就是:JSESSIONID 这个 Cookie 名意味着「你有一个 Session 容器」,而不是「你已经登录了」。两个状态下 Cookie 名字一模一样,只有 Value 对应的服务端对象里有没有 User 信息才是区别——但浏览器侧根本看不到这个信息。
| 后端类型 | Cookie 名语义 | 能靠 Cookie 名检测登录? |
|---|---|---|
| 现代 SPA(小红书、抖音等内容平台) | 认证 Cookie 名与匿名 Cookie 名不同(如 web_session 仅登录后才有) | 可以,名字本身就是信号 |
| J2EE / Tomcat / Spring Session | JSESSIONID 首次请求即下发,同名 | 不可以 |
| PHP session_start() | PHPSESSID 在每次调用时下发,同名 | 不可以 |
| ASP.NET Session | ASP.NET_SessionId 在首次访问 Session 时下发,同名 | 不可以 |
用这段代码检测登录,在 Tomcat / Spring Session 类后端上会永远返回 true,即使用户是完全匿名的:
// ❌ 错误——仅凭 Cookie 名断定已登录
const cookies = await session.cookies.get({})
const loggedIn = cookies.some(c => c.name === 'JSESSIONID')快速验证是否踩坑:打开无痕窗口,不登录,直接访问目标站。如果 DevTools → Application → Cookies 里已经有 JSESSIONID / PHPSESSID / ASP.NET_SessionId,你的站就在这个坑里。
正解
唯一可靠的 ground-truth:让服务端自己说话——向一个受保护的 URL 发请求,观察最终落在哪里。服务端的鉴权中间件会做 if (session.user == null) redirect('/login'),这个行为是确定性的。
方案 A:探测受保护 URL,检查终点 URL(首选)
async function isLoggedIn(probeUrl: string, loginPath = '/login') {
try {
await webContents.loadURL(probeUrl)
} catch (err) {
// 302 重定向中途会触发 ERR_ABORTED,这不是真正的错误
if (!/ERR_ABORTED/.test(String(err))) throw err
await new Promise(r => setTimeout(r, 200)) // 等重定向稳定
}
const terminalUrl = webContents.getURL()
return !terminalUrl.includes(loginPath)
}probeUrl 要选服务端渲染的受保护页面,不要选 SPA 的根路径(SPA 根路径往往先渲染匿名 Shell,再由前端 JS 跳转,URL 检测会误判)。
方案 B:正向匹配登录后才有的 URL 前缀
// 服务端对未认证用户会将 /user/ 路径重定向走
const terminalUrl = webContents.getURL()
const loggedIn = terminalUrl.includes('/user/dashboard')方案 C:DOM 文本兜底(最不稳定)
// "退出登录" 仅在认证后的布局里出现
const hit = await webContents.executeJavaScript(
`document.body.innerText.includes('退出登录')`
)注意:跨域 iframe、A/B 实验改文案都可能让这个方案失效,只做兜底。
在 RPA / 自动化步骤的 check_logged_in 里,配置如下:
{
"type": "check_logged_in",
"params": {
"probeUrl": "https://example.com/user/dashboard.jsp",
"detector": { "kind": "url-not-contains", "value": "/login" }
}
}Cookie 名检测只在一种情况下可用:认证 Cookie 的名字与匿名 Cookie 的名字不同,且你用无痕测试确认过匿名状态下不会出现该名字。否则,跳过 Cookie 检测。
验证清单
实现后按这个顺序测三遍:
- 匿名测试:清空所有 Cookie → 访问
probeUrl→ 断言 detector 返回 false(未登录)。如果返回 true,检测器在匹配匿名状态。 - 认证测试:正常登录 → 运行检测 → 断言 true。
- 过期 Session 测试(原始陷阱):登录 → 等待服务端 Session 过期(或后台强制失效)→ 运行检测 → 断言 false。Cookie 还在 jar 里,但服务端 Session 已死,这是 Cookie 名方案必然失败的用例。
一句话外卖
Session Cookie 的名字告诉你「服务端给你开了一个房间」,不告诉你「你有没有登记入住」;只有往受保护的门上敲一下,看服务端把你放进去还是推到大厅,才是真相。