一次跨 Mac 的单站点登录态迁移记录:从「能打开」到「重启后仍可用」

1. 写在最前面

最近遇到一个看起来很简单、实际很容易踩坑的问题:旧电脑上的某个网站还保持着登录状态,但绑定手机暂时收不到验证码,也不记得密码,想把这个网站的登录状态迁移到另一台 Mac 上。

一开始很容易想到两个方案:复制浏览器的 Cookie,或者直接搬整个 Chrome 用户目录。前者涉及登录凭证,后者会把书签、历史记录、扩展和其他网站的登录态一起带走,范围都太大。

最后采用的方案是:只处理一个站点、只使用隔离的 Chrome 配置、通过 SSH 连接两台 Mac、让 Chrome 自己读写 Cookie,并且把「重启后还能不能用」作为最终验收标准。

注:本文只记录方法、判断过程和代码风格分析。真实用户名、主机名、IP、文件路径、Cookie 值、账号标识和内部命令都已替换或省略。文中的「某站点」仅代表本人拥有并有权使用的账号,不能用于迁移他人账号。

2. 先把问题说清楚

这里要迁移的不是账号本身,也不是密码,而是浏览器里的登录会话。

可以把它分成三层:

  • 账号层:用户名、密码、绑定邮箱、绑定手机;
  • 浏览器层:Cookie、Local Storage、IndexedDB、缓存等浏览器状态;
  • 服务端层:服务端签发的会话、设备校验、风险控制和过期策略。

这次只触碰第二层,而且只允许触碰目标站点的 Cookie。即使 Cookie 成功复制,服务端仍然可能因为会话过期、设备变化或风险控制要求重新登录,所以「文件写进去了」不能作为成功标准。

真正的验收标准应该是:

rust 复制代码
目标 Chrome 能打开目标站点
        -> 页面被识别为已登录
        -> 与源设备是同一个账号
        -> 关闭 Chrome
        -> 重新打开 Chrome
        -> 仍然保持登录

3. 先看代码怎么拆

这次实际写的工具没有做成一个大脚本,而是拆成了几块相对独立的代码。这个拆分方式也比较符合笔者平时写小工具的习惯:先把权限和边界写清楚,再把核心动作拆开。

3.1 manifest 只声明必要权限

浏览器扩展的 manifest 只申请 Cookie 权限,并把 host permission 限定到目标站点:

json 复制代码
{
  "permissions": ["cookies"],
  "host_permissions": [
    "https://target.example/*",
    "https://*.target.example/*"
  ]
}

这里没有申请所有网站的访问权限,也没有读取标签页、历史记录或文件系统。这个风格的重点不是「代码少」,而是让代码本身成为一份权限说明:工具能访问什么,不能访问什么,一眼能看出来。

3.2 UI 层只负责交互

popup.mjs 主要处理四件事:

  • 读取口令;
  • 调用 Cookie API;
  • 触发加密文件下载或导入;
  • 把成功、过期和失败数量显示出来。

加密、解密、域名校验和 Cookie 参数转换都放在另外的模块里。这样 UI 不需要知道 AES-GCM 如何工作,也不需要知道某个 Cookie 的字段细节。

代码里有一个比较明显的习惯:操作开始时先禁用按钮,结束时在 finally 中恢复:

scss 复制代码
busy(true);
try {
  // 执行导出或导入
} catch (error) {
  show(error.message);
} finally {
  busy(false);
}

这类写法很朴素,但可以避免用户连续点击产生两次导出、两次导入或两个并发状态。

3.3 核心模块强调白名单和提前失败

session.mjs 里的代码有几个特征。

第一,域名使用白名单匹配,而不是「拿到什么就处理什么」:

javascript 复制代码
const isAllowedDomain = domain =>
  /^(?:[a-z0-9-]+.)*target.example$/i
    .test(domain.replace(/^./, ""));

第二,输入数据会先校验,再进入加密或写入流程。比如 Cookie 必须有合法的域名、路径、布尔字段和 SameSite 值;文件格式、口令长度、Cookie 数量也都有上限。

第三,大多数错误采用「提前失败」的方式:

javascript 复制代码
if (cookies.length === 0) {
  throw new Error("没有找到目标站点 Cookie");
}

这种风格的好处是,错误尽量发生在边界上,不让坏数据继续流入后面的浏览器操作。

3.4 加密使用平台能力,不引入第三方依赖

导出文件没有使用 npm 加密库,而是使用浏览器原生 Web Crypto API:

  • PBKDF2 从临时口令派生密钥;
  • 每次导出生成新的 salt 和 IV;
  • 使用 AES-GCM 加密整个 Cookie 结构;
  • 解密失败时统一提示「口令不正确或文件已损坏」,不泄露更多内部细节。

这里体现的是「能用平台能力就不额外引入依赖」的取向。对于一次性、本地运行的小工具,这样可以减少安装步骤和供应链风险。

4. 为什么没有直接复制 Chrome 用户目录

Chrome 的登录状态不是一个简单的明文 JSON 文件。Cookie 数据通常存储在 SQLite 数据库里,敏感字段还会受到操作系统钥匙串和浏览器加密机制保护。

直接复制整个用户目录有几个问题:

  • 会把其他网站的登录态一起复制;
  • 会把历史记录、扩展、缓存和本地存储一起复制;
  • 源机器和目标机器的系统钥匙串、Chrome 版本或配置可能不同;
  • 浏览器运行时可能正在写数据库,直接复制容易得到不一致的文件;
  • 一旦目录泄露,影响范围远大于一个站点。

因此,迁移的基本原则定成了:

复制代码
不复制整个浏览器
不读取其他站点
不打印 Cookie 值
不把 Cookie 写入普通日志
只在本人控制的两台设备之间传输

5. 实际迁移过程

5.1 第一步:先确认 SSH 和浏览器环境

目标机器已经配置了免密码 SSH,所以先做只读检查:

sql 复制代码
ssh <user>@<target-host> 'sw_vers'
ssh <user>@<target-host> '"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version'

这一步只确认:

  • SSH 是否可用;
  • 目标用户是谁;
  • 目标 Mac 是否安装 Chrome;
  • Chrome 是否正在运行。

检查通过以后,才创建一个专门给目标站点使用的浏览器配置目录。它与目标机器原来的 Chrome 配置分开,因此不会覆盖原有浏览器。

5.2 第二步:创建源端隔离配置

源端原来的 Chrome 仍然保持登录,但不直接拿它做批量写入。先创建一个临时隔离配置,再让 Chrome 读取经过筛选的目标站点数据。

这样做有两个好处:

  • 后续测试失败时,不会把源端正在使用的 Chrome 搞坏;
  • 可以明确知道临时配置里只有目标站点相关状态。

最初考虑过开发扩展,通过 chrome.cookies API 导出和导入。这个方向适合交互式使用,也能把权限限制在一个域名,但跨两台机器时还需要处理加密文件、人工传输和目标端导入。

最终验证路径采用了 Chrome DevTools Protocol。流程大致是:

rust 复制代码
源端隔离 Chrome
    -> Storage.getCookies
    -> 过滤目标域名和未过期 Cookie
    -> 保留在进程内存中
    -> 通过 SSH 转发的 WebSocket 发送
    -> 目标端隔离 Chrome
    -> Storage.setCookies

这里最重要的一点是:Cookie 值没有输出到终端,也没有写进普通文本文件。日志只输出数量、是否匹配、是否通过验证等布尔信息。

5.4 第四步:第一次验证只证明了「内存态可用」

第一次把 Cookie 写入目标端以后,立即读取目标端 Cookie,发现认证字段可以匹配;接着打开目标站点,页面也返回了已登录状态。

如果到这里就宣布成功,其实还早了。

因为这只能证明:

复制代码
Cookie 在当前 Chrome 进程的内存里有效

还不能证明:

复制代码
Chrome 退出后会把它持久化到磁盘

5.5 第五步:发现 headless Chrome 的持久化问题

为了自动化验证,最初使用了 headless Chrome。它的行为很有迷惑性:

  • 运行中读取 Cookie,认证字段存在;
  • 打开目标站点,接口返回已登录;
  • 关闭浏览器后,重新检查 Cookie 数据库,认证字段却不在了。

这次失败非常有价值,因为它把问题从「Cookie 值对不对」缩小到了「浏览器进程是否持久化」。

于是把验收标准进一步拆成两条:

css 复制代码
A. 进程内验证:当前浏览器能否识别为登录状态?
B. 生命周期验证:关闭并重新打开后还能否识别为登录状态?

只有 A、B 都通过,才算迁移完成。

5.6 第六步:改用可见 Chrome,并做重启验证

随后改用目标 Mac 上的可见 Chrome 实例,仍然使用同一个隔离配置目录。写入以后先读取目标端状态,再关闭浏览器;重新启动以后再次打开目标站点,并调用一个只返回登录状态的接口进行检查。

最终验证结果可以抽象成这样:

json 复制代码
{
  "sourceRecognized": true,
  "targetRecognized": true,
  "sameAccount": true,
  "sourceHttpStatus": 200,
  "targetHttpStatus": 200
}

这里没有输出账号 ID、Cookie 值或页面内容,只保留了用于判断的结果。目标端原来的 Chrome 配置也没有被覆盖。

6. 这次代码风格里做得比较好的地方

6.1 责任边界清晰

大致可以分成四层:

  • 权限层:manifest 约束能访问什么;
  • 数据层:域名白名单、字段校验、加解密;
  • 传输层:SSH 转发和 DevTools Protocol;
  • 验证层:目标站点是否识别登录、重启后是否仍然有效。

不同层之间通过简单的数据结构连接,而不是让一个函数同时负责浏览器控制、加密和 UI。

6.2 正常路径短,异常路径有出口

正常情况下,代码阅读起来比较直:获取、过滤、加密、发送、验证。异常情况则通过 try/catch/finally、数量上限、格式检查和明确错误消息收敛掉。

这类小工具不一定需要复杂的框架,但需要让失败可见、可定位、可恢复。

6.3 日志遵循「足够诊断,但不泄密」

日志输出的是:

  • Cookie 数量;
  • 成功、失败和过期数量;
  • 目标端是否匹配;
  • 登录检查是否通过。

日志不输出:

  • Cookie 的名称和值;
  • 账号 ID;
  • 页面正文;
  • SSH 主机名和真实路径。

这是公开文章和实际运维里都值得保留的习惯:错误日志不是调试数据库,能不打印的秘密就不要打印。

6.4 代码仍然有改进空间

这次代码毕竟是为一次具体排障写的小工具,仍有几处可以继续改:

  • 把测试脚本和正式迁移脚本分到独立目录;
  • 使用统一的格式化工具和 lint 规则;
  • 给 Chrome 协议调用增加更完整的错误分类;
  • 把「源端、目标端、验证端」的连接管理抽象成复用模块;
  • 为「写入后关闭、重启后检查」增加自动化回归测试;
  • 不在生产脚本里硬编码具体站点名,改成显式配置并默认拒绝未知域名。

另外,测试代码里为了快速验证曾经出现过比较紧凑的条件表达式。它能运行,但可读性一般。正式整理时,宁愿多写两行判断,也不要让安全边界藏在一个复杂的三元表达式里。

7. 这次排障真正学到的东西

7.1 「能打开」不是「迁移完成」

浏览器、数据库、缓存和服务端会话是不同层次。一个页面当前显示已登录,只能说明此刻的请求成功;只有经过关闭、重启和再次访问,才能说明迁移结果具有可用性。

7.2 先做隔离,再做自动化

如果一开始就直接修改目标机器正在使用的 Chrome,失败以后很难判断是原有状态被破坏,还是新状态没有写进去。

隔离配置的成本很低,却能把问题变成一个可重复的小实验。

7.3 「少搬一点」本身就是安全措施

只迁移一个站点,不仅减少实现复杂度,也减少泄露面。把「只允许哪些域名」写进代码,比事后提醒自己「别误操作其他网站」可靠得多。

7.4 让验证结果比过程更重要

中间尝试了扩展、headless Chrome、可见 Chrome 和不同的 Cookie 写入方式。真正留下来的不是某一条命令,而是验收标准:

复制代码
只迁移目标站点
源端不受影响
目标端能登录
目标端重启后仍能登录
日志不包含敏感数据

8. 使用时的安全边界

登录 Cookie 本质上接近「拿到就能用」的凭证,处理它时需要比普通配置文件更谨慎:

  • 只在自己拥有或明确获授权的设备之间迁移;
  • 不把 Cookie 粘贴到聊天、工单、代码仓库或公共网盘;
  • 不把 Cookie 值写进终端日志、截图或录屏;
  • 迁移完成后删除临时导出文件和测试副本;
  • 不要把旧设备过早退出,先确认新设备可以独立使用;
  • 会话失效时走网站官方的密码找回或账号申诉流程;
  • 不要把本文中的占位命令直接用于不属于自己的账号。

即使所有步骤都正确,服务端也可能主动让会话失效。迁移登录态不是绕过账号安全机制的办法,只是把本人设备上的浏览器状态,在本人控制的另一台设备上做受限复制。

9. 反思

这次最有意思的地方,是一个看起来「复制几个 Cookie」就能解决的问题,最后却变成了一个完整的小型工程:权限边界、隔离环境、加密、SSH、浏览器协议、持久化、重启验证,一个环节都不能只凭感觉。

以前排查问题时,容易把「命令执行成功」当成「事情完成了」。这次 headless Chrome 给出的教训很直接:中间状态成功,不代表生命周期成功。

以后再遇到类似问题,应该先问四个问题:

  • 我到底要迁移什么?
  • 最小安全范围是什么?
  • 失败时如何不影响原环境?
  • 什么证据才能证明真的完成?

把这四个问题想清楚,很多看似麻烦的迁移任务,其实都可以拆成几个可验证的小步骤。

10. 碎碎念

本月的知识探索只是就先到这里啦,笔者需要去享受一下阳光 & 微风, 因为上海的秋天实在是太太太短暂啦!

  • 昨天已经过去,明天尚未到来,今天仍是未知, 让我们试着过好每一个今天吧。
  • 不放手的话,就没办法抓住新的东西呀!

11. 参考资料

相关推荐
武子康2 小时前
LingBot-VLA 2.0 怎么从 6B 权重走到真实机器人?55 维动作只是起点
人工智能·后端·agent
学长毕设2 小时前
基于springboot的社会志愿者管理系统-附源码
java·spring boot·后端
喵了几个咪2 小时前
GoWind CMS 拆解:给 NL2SQL 上四把锁——智能问数的安全边界
开发语言·后端·安全·微服务·golang·gowind
用户8356290780513 小时前
Python 操作 Excel:创建数组公式与 R1C1 公式
后端·python
乘风gg3 小时前
出事了,AI 提效后,老板让我裁员20%
前端·后端·ai编程
Rain的Java大神之路3 小时前
面了 50 个候选人,90% 都挂在 LeetCode 3 无重复字符的最长子串:滑动窗口的 5 个致命坑
java·后端·leetcode
狂师4 小时前
夯爆了!分享几个我在用的,基于业务场景接口串联测试Skill !
人工智能·后端·测试
北京地铁1号线4 小时前
后端开发:缓存(Cache)简介
后端·缓存·cache
IT_陈寒4 小时前
Vue项目实战经验分享:动态组件在Keep-Alive下的缓存陷阱
前端·人工智能·后端