返回博客

JSESSIONID 在浏览器自动化里是个骗子:登录检测要看重定向终点,不要看 Cookie 名

给某个 J2EE 系统写 RPA 自动化脚本,登录检测逻辑看起来无懈可击——「浏览器里有 JSESSIONID?那一定登录了」。但跑起来,脚本永远走「已登录」分支,然后在第一个需要鉴权的操作上炸掉:选择器找不到,或者被 302 踢回登录页。

最诡异的地方:手动在 DevTools 把 Cookie 全部清掉,再刷新一次页面,JSESSIONID 立刻回来了。

现象

  1. 用户正常登录,JSESSIONID=ABC123 落库,脚本检测「有该名称的 Cookie」→ 判定已登录。正常。
  2. 若干分钟后,服务端 Session 超时(Tomcat 默认 30 分钟)。Cookie 还在浏览器里(maxAge 很长),检测仍然报「已登录」。错误。
  3. 脚本跑 navigate('https://site/user/dashboard.jsp')——服务端看到的 JSESSIONID 对应一个已失效 Session,立刻重定向到 /login。同时,Set-Cookie 头里附上了一个全新的匿名 JSESSIONID=XYZ789
  4. 脚本看到 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 时下发,同名 | 不可以 |

坑:Cookie 名在 J2EE 类后端里不区分匿名与已认证

用这段代码检测登录,在 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 实验改文案都可能让这个方案失效,只做兜底。

正解:用 URL 重定向终点做 ground-truth

在 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 检测。

验证清单

实现后按这个顺序测三遍:

  1. 匿名测试:清空所有 Cookie → 访问 probeUrl → 断言 detector 返回 false(未登录)。如果返回 true,检测器在匹配匿名状态。
  2. 认证测试:正常登录 → 运行检测 → 断言 true。
  3. 过期 Session 测试(原始陷阱):登录 → 等待服务端 Session 过期(或后台强制失效)→ 运行检测 → 断言 false。Cookie 还在 jar 里,但服务端 Session 已死,这是 Cookie 名方案必然失败的用例。

一句话外卖

Session Cookie 的名字告诉你「服务端给你开了一个房间」,不告诉你「你有没有登记入住」;只有往受保护的门上敲一下,看服务端把你放进去还是推到大厅,才是真相。