项目实践:同父域双前端单点登录实践01:共享 Cookie、会话恢复与双向登出联调

同父域双前端单点登录实践:共享 Cookie、会话恢复与双向登出联调

目录
  • 同父域双前端单点登录实践:共享 Cookie、会话恢复与双向登出联调
    • 前言
    • 一、背景与约束
      • 1. 项目现状
      • 2. 已确认的业务约束
    • 二、先明确:共享 Token 不等于完整 SSO
    • 三、方案选择:同父域共享 USER_TOKEN
      • 1. Cookie 为什么可以跨项目共享
      • 2. 必须确认的 Cookie 条件
    • 四、已经实现的 user-front → carbon-front 登录链路
      • 1. carbon 兼容共享 Token
      • 2. 路由守卫不只检查 Token,还恢复会话
      • 3. 使用 Token 获取用户和账户
      • 4. 初始化 carbon 内部状态
    • 五、联调过程:先验证最小闭环
      • 1. 第一步:验证 Cookie 能否跨端口读取
      • 2. 第二步:验证 Token 是否被 carbon 后端接受
      • 3. 第三步:恢复用户和企业上下文
    • 六、联调问题一:登录企业正确,carbon 却显示了另一个名称
      • 1. 现象
      • 2. 根因
      • 3. 修复
    • 七、联调问题二:删除 Cookie 后,另一个项目又恢复了登录
      • 1. user 登出后 carbon 仍然登录
      • 2. carbon 登出删除 Cookie 后,USER_TOKEN 又出现
      • 3. 为什么 Cookie 和 localStorage 双写不适合这里
    • 八、Token 过期与 401 的统一处理
    • 九、双向登录与全局登出的最终方案
      • 1. 目标状态
      • 2. 前端需要收敛 Token 规则
      • 3. 后端必须实现 Token 注销
    • 十、测试环境还需要确认什么
    • 十一、联调 Checklist
      • Cookie
      • 会话恢复
      • 登出与失效
    • 十二、实践复盘
    • 总结

前言

这次需求看起来很直接:用户已经在一个系统登录,打开另一个系统时不应该再次输入账号密码。

但真正开始联调后会发现,“共享一个 Token”只是单点登录的第一步。一个前端拿到另一个前端的 Token 后,还需要恢复用户资料、当前企业、账号列表、权限和系统上下文;登出时还要解决 Cookie、localStorage、页面内存状态和后端 Token 有效期之间的不一致。

本文记录一次真实的双前端单点登录实践。两个项目分别记为:

  • user-front:面向用户的门户项目,负责原有登录流程。
  • carbon-front:碳管理后台,需要复用 user-front 的登录状态。

当前已经完成 user-front → carbon-front 的单点登录。反方向登录和双向登出尚未完成,文章会如实区分“已经验证的实现”和“后续方案”,避免把设计目标写成已经交付的功能。

一、背景与约束

1. 项目现状

两个前端使用不同技术工程,但共用一套后端用户体系。它们原本各自维护登录逻辑:

项目 Token Key 请求鉴权 原有登录入口
user-front USER_TOKEN 旧版接口使用原始 Token,部分接口使用 Bearer Token 门户登录
carbon-front token Authorization: Bearer <token> carbon 自己的登录页

两个项目都不是传统的服务端 Session 页面,而是前端拿到 JWT Token 后,在 Axios 请求拦截器中写入 Authorization 请求头。

2. 已确认的业务约束

这次方案有几个明确边界:

  1. 两个项目共用一套后端,user-front 的 Token 可以被 carbon 后端识别。
  2. 不建设新的统一认证中心,不引入 OAuth2、OIDC 或 Token Exchange。
  3. 尽量不改 user-front,优先在 carbon-front 中增加兼容逻辑。
  4. 生产环境计划部署在同一父域下,可以使用父域 Cookie。
  5. 本地可以同时运行两个项目进行联调。

在这些约束下,最小成本方案不是“重新设计认证体系”,而是复用已有 Token,让两个前端围绕同一个 USER_TOKEN 建立会话。

二、先明确:共享 Token 不等于完整 SSO

一开始最容易混淆的是三个概念:

概念 解决的问题
Token 共享 carbon 能否读取 user 登录后获得的 Token
会话恢复 carbon 能否根据 Token 初始化用户、企业、账户和权限
登录状态同步 任一项目登录或登出后,另一个项目是否同步变化

如果 carbon 只能执行:

const token = Cookies.get("USER_TOKEN");

只能说明“Token 可以读取”,还不能说明 carbon 已经完成登录。完整会话至少需要:

  • Token 有效。
  • 当前用户接口调用成功。
  • 当前企业或账套识别正确。
  • carbon 所需的 systemId、菜单和权限完成初始化。
  • 刷新页面后能够重新恢复。
  • Token 过期时可以统一退出。

因此,本次实现的核心不是简单增加一个 Cookie Key,而是建立一条完整的会话启动链路。

三、方案选择:同父域共享 USER_TOKEN

Cookie 的作用域主要由 DomainPath 决定,不按端口隔离。

本地联调使用:

user-front:   http://localhost:5173
carbon-front: http://localhost:5174

两个地址的 hostname 都是 localhost。虽然端口不同,但 Cookie 可以共享;localStorage 则因为 origin 不同,不能共享。

生产环境如果是不同子域,需要类似配置:

Name=USER_TOKEN
Domain=.example.com
Path=/
Secure=true

因为 carbon 需要使用 JavaScript 读取 Token 并放入 Authorization 请求头,所以当前方案中的 Cookie 不能设置 HttpOnly。这也意味着前端必须继续重视 XSS 防护。更安全的 HttpOnly 会话方案需要后端整体改造,不属于本次最小改动范围。

共享 Cookie 至少需要满足:

  • 两个项目使用相同 hostname,或位于同一个父域。
  • Domain 覆盖两个项目。
  • Path=/,避免 Token 只在某个页面路径下可见。
  • Token 是多账号选择完成后的最终业务 Token。
  • 本地不能混用 localhost127.0.0.1
  • 前端读取方案下不能设置 HttpOnly

四、已经实现的 user-front → carbon-front 登录链路

当前已经跑通的流程如下:

user-front 登录↓
写入 USER_TOKEN Cookie↓
打开 carbon-front↓
carbon 读取 USER_TOKEN↓
路由守卫调用 bootstrapSession()↓
获取当前用户 + 账户列表↓
初始化 Pinia、当前企业和 systemId↓
初始化菜单权限↓
进入 carbon 业务页面

1. carbon 兼容共享 Token

carbon 原本只读取自己的 token Cookie。为兼容 user-front,Token 工具增加了共享 Token 读取:

const TOKEN_KEY = "token";
const USER_TOKEN_KEY = "USER_TOKEN";export const getLocalToken = () => Cookies.get(TOKEN_KEY) || "";
export const getSharedToken = () => Cookies.get(USER_TOKEN_KEY) || "";
export const getToken = () => getLocalToken() || getSharedToken();

这一步让 carbon 能读取 user-front 写入的 USER_TOKEN

不过这也是一个过渡状态:如果 carbon 还存在不同值的私有 token,当前逻辑会优先使用它。后续要实现真正双向 SSO,需要将 USER_TOKEN 收敛成唯一认证来源,并处理旧 token 的迁移和清理。

2. 路由守卫不只检查 Token,还恢复会话

路由守卫检测到 Token 后调用 Pinia store:

if (getToken()) {try {await authStore.bootstrapSession();} catch {authStore.clearSession();permissionStore.reset();}
}

bootstrapSession() 成功后,carbon 才继续初始化资源菜单和权限。这样可以避免“Cookie 存在,但用户信息无效”时仍然进入业务页面。

3. 使用 Token 获取用户和账户

会话恢复阶段并行调用:

POST /api/member/m/xxx/get
POST /api/member/m/yyy/list/get

两个接口职责不同:

  • /member/m/xxx/get:返回当前 Token 对应的用户和企业身份。
  • /member/m/yyy/list/get:返回手机号关联的账户列表、默认账户和最近登录账户。

需要特别说明:Token 不是由 /member/m/xxx/get 返回的。Token 已经来自共享 Cookie,这个接口只是使用 Token 查询当前身份。

旧版用户接口沿用原系统约定,直接传原始 Token:

postWithHeaders(currentUserUrl,{ redirect: false },{Authorization: token,},
);

carbon 自己的业务接口则由统一 Axios 拦截器转换为:

Authorization: Bearer <USER_TOKEN>

4. 初始化 carbon 内部状态

接口成功后,carbon 初始化:

  • Pinia 中的 Token 和用户状态。
  • 当前账户 ID。
  • 当前企业显示名称。
  • 账户列表。
  • systemId
  • 用户和账户展示缓存。
  • 资源菜单与路由权限。

共享会话不会再把 USER_TOKEN 复制成新的 carbon 私有 Token,避免同一个会话产生更多持久化副本。

五、联调过程:先验证最小闭环

先同时运行两个项目:

http://localhost:5173
http://localhost:5174

在 user-front 登录后,到 carbon 页面执行:

document.cookie.includes("USER_TOKEN=");

最初出现过 false,排查重点包括:

  • 当前页面是否使用相同 hostname。
  • Cookie 是否设置了其他 Domain。
  • Path 是否为 /
  • 是否设置了 HttpOnly
  • DevTools 中看到的 Cookie 是否真的属于当前 hostname。

最终确认两个项目在 localhost 不同端口下可以读取同一个 USER_TOKEN,说明 Cookie 共享基础成立。

2. 第二步:验证 Token 是否被 carbon 后端接受

Cookie 可读之后,下一步不是立刻做完整页面,而是携带该 Token 请求一个 carbon 鉴权接口:

Authorization: Bearer <USER_TOKEN>

结果可以快速区分问题:

响应 含义
200 Token 可以直接复用
401 Token 不兼容或已经失效
403 Token 能识别,但用户缺少 carbon 权限

本次确认两个项目可以复用同一个 Token,因此不需要后端增加 Token 换取接口。

3. 第三步:恢复用户和企业上下文

Token 验证成功后,才接入当前用户与账户列表接口。这里暴露了一个典型问题:后端返回的“用户编码”和“当前账户编码”不是同一个字段体系,不能看到一个看起来像 ID 的字段就直接匹配。

六、联调问题一:登录企业正确,carbon 却显示了另一个名称

1. 现象

user-front 实际选择的是“测试企业 B”,但进入 carbon 后,顶部显示成了账户列表第一项“测试账户 A”。

账户接口返回的结构类似:

{"mobile": "***","defaultAccount": "***","lastLoginAccount": "UHxxxx","accountList": [{"account": "Vxxxxx","companyName": "测试账户 A"},{"account": "UHxxxx","companyName": "测试企业 B"}]
}

用户接口返回:

{"accountType": "uCode","memberCode": "6xxxxxxx","companyName": "测试企业 B","empName": "用户 A"
}

2. 根因

原实现使用 memberCode 匹配 accountList[].account

memberCode = 6xxxxxxx
account = Vxxxxx / UHxxxx

匹配必然失败。代码随后静默回退到 accountList[0],所以显示了第一项。

问题不在 /member/m/xxx/get 返回错误,而在 carbon 选择当前账户时使用了错误字段,并且“匹配失败后默认第一项”掩盖了错误。

3. 修复

当前账户改为按以下优先级解析:

lastLoginAccount
→ defaultAccount(能匹配账户列表时)
→ 根据 accountType 选择对应编码字段
→ memberCode(能匹配时)
→ 唯一 companyName 匹配
→ 已缓存且仍有效的账户
→ 单账户

如果存在多个账户且仍无法确认,不再默认选择第一项,而是抛出明确错误。

本次数据最终解析为:

currentAccountId = UHxxxx
currentDisplayName = 测试企业 B

这个问题带来的经验是:用户身份、人员身份、企业身份和登录账户要分开建模。例如 empName 适合表示人员姓名,companyName 适合表示当前企业,不能混成一个 username 到处复用。

完成 user → carbon 后,继续测试双向登出,出现了两个现象。

1. user 登出后 carbon 仍然登录

carbon 自己登录时会写私有 token,而 user 登出只清理 USER_TOKEN。carbon 当前读取顺序是:

getLocalToken() || getSharedToken();

因此,只要 carbon 私有 token 还存在,删除 USER_TOKEN 也不会让 carbon 退出。

这说明同时保留两个认证 Token 会形成两套互不联动的登录态。

user-front 当前不仅将 USER_TOKEN 写入 Cookie,还镜像写入自己的 localStorage:

window.localStorage.setItem(TOKEN_COOKIE_KEY, value);

读取时,如果 Cookie 不存在,会回退到 localStorage:

return window.localStorage.getItem(TOKEN_COOKIE_KEY) || undefined;

于是出现下面的恢复链路:

carbon 删除共享 USER_TOKEN Cookie↓
无法跨 origin 删除 user-front 的 localStorage.USER_TOKEN↓
user-front 路由守卫从 localStorage 读取旧 Token↓
establishSession() 再次写入 USER_TOKEN Cookie↓
carbon 重新打开后再次自动登录

这不是浏览器缓存假象,而是旧 Token 被 user-front 主动恢复。

localStorage 在单项目里可以作为持久化手段,但跨子域 SSO 中,它不能被另一个项目清理。把同一个 Token 同时放在 Cookie 和 localStorage,会产生两个事实来源:

  • Cookie 已删除,但 localStorage 仍有效。
  • localStorage 可以重新生成 Cookie。
  • 两个项目看到的登录状态不一致。

因此,后续建议将 USER_TOKEN Cookie 作为唯一前端认证凭证。localStorage 可以继续保存非敏感展示状态,但不能用于恢复 Token。

八、Token 过期与 401 的统一处理

共享 Token 还需要统一失效处理。carbon 当前识别:

const TOKEN_ERROR_CODES = new Set([401, "401", "TKxx", "TKyy", "TKzz"]);

当接口返回 HTTP 401 或约定的 Token 业务错误码时:

  1. 清除共享 Cookie 和 carbon 本地状态。
  2. 清除用户、账户和 systemId
  3. 提示登录状态已过期。
  4. 跳转登录页,并保留原页面作为 redirect。
  5. 使用模块级标记避免多个并发请求重复提示和重复跳转。

实现中使用 window.location.replace(),目的不仅是改变路由,还要彻底重置 Pinia、Tab 和 keep-alive 中残留的内存状态。

目前代码链路已经实现,但真实 Token 过期、业务错误码和测试环境行为仍需要端到端验收。

九、双向登录与全局登出的最终方案

1. 目标状态

最终希望四个方向都成立:

场景 当前状态
user 登录 → carbon 自动登录 已实现
carbon 登录 → user 自动登录 未实现
user 登出 → carbon 退出 未实现
carbon 登出 → user 退出 未实现

2. 前端需要收敛 Token 规则

推荐最终规则:

  • 两个项目统一使用 USER_TOKEN
  • USER_TOKEN Cookie 是唯一 Token 来源。
  • carbon 登录成功后也写入父域 USER_TOKEN,不再建立独立 token 会话。
  • user-front 不再将 USER_TOKEN 镜像到 localStorage。
  • 两个项目登出时删除同一条父域 Cookie。
  • 用户资料、企业资料可以保留在各自 localStorage,但路由恢复必须先检查 Cookie。

3. 后端必须实现 Token 注销

只删除浏览器 Cookie 不等于 Token 真正失效。旧 Token 仍可能存在于:

  • 另一个标签页的内存状态。
  • user-front 的历史 localStorage。
  • 已复制的请求或调试工具中。

因此,后端后续需要提供统一登出接口:

任一项目点击登出↓
携带当前 USER_TOKEN 调用统一登出接口↓
后端注销当前 Token↓
前端删除父域 USER_TOKEN Cookie↓
清理当前项目状态↓
另一个项目下次请求收到 401 / TKxx↓
清理状态并进入未登录页面

接口需要明确:

  • 使用什么 Authorization 格式。
  • 注销当前 Token 还是注销该用户全部设备。
  • 重复登出是否幂等。
  • Token 注销后的统一错误码。
  • JWT 是使用黑名单、版本号还是其他方式实现撤销。

对于已经打开的另一个标签页,本次接受“下一次请求、刷新或路由切换时退出”,暂不额外建设实时跨窗口通知。

十、测试环境还需要确认什么

本地同 hostname 不同端口验证成功,并不代表测试环境一定成功。部署前还需要确认:

  • carbon 测试域名是否属于约定的父域(如 .example.com)。
  • USER_TOKENDomain 是否覆盖两个子域。
  • Path 是否为 /
  • HTTPS 环境下是否正确设置 Secure
  • SameSite 是否符合页面跳转方式。
  • API CORS 是否允许 Authorization 请求头。
  • 旧版接口的原始 Token 与 carbon 接口的 Bearer Token 是否分别正确。
  • 后端注销接口是否真的使旧 Token 无法再次请求。

十一、联调 Checklist

会话恢复

登出与失效

十二、实践复盘

这次实践中最重要的几个认识是:

  1. SSO 不是“读到 Cookie”就结束了。 Token 共享只是入口,后面还有用户、企业、权限和异常状态初始化。
  2. Cookie 不按端口隔离,localStorage 按 origin 隔离。 这个差异既帮助本地联调,也直接决定了登出方案。
  3. 同一个 Token 不要维护多个事实来源。 Cookie 和 localStorage 双写很容易让已删除的登录态复活。
  4. 人员与企业必须分开建模。 empNamememberNamecompanyNamememberCode、用户编码和账户编码并不是可以互换的字段。
  5. 不要用“匹配失败就取第一项”掩盖数据问题。 多账户系统中,错误的默认值比明确失败更危险。
  6. 全局登出最终需要后端参与。 前端只能清理浏览器可访问的状态,不能保证 Token 在服务端真正失效。
  7. 本地成功只是第一阶段。 父域 Cookie、HTTPS、安全属性、CORS 和后端错误码都必须在真实环境再次验收。

总结

在“不建设统一认证中心、两个项目共用后端、尽量少改旧项目”的前提下,共享父域 USER_TOKEN 是一条成本较低、可以逐步落地的单点登录路径。

目前已经完成 user-front 登录后 carbon-front 自动恢复会话:carbon 可以读取共享 Token,获取当前用户和账户列表,识别正确企业,初始化系统上下文和权限,并进入业务页面。

真正困难的部分出现在双向同步:carbon 私有 Token 会让 user 登出失效,user-front 的 localStorage fallback 又会让 carbon 删除的 Cookie 重新出现。这个过程说明,单点登录的核心不是“让两个页面都拿到一个字符串”,而是让 Token 的颁发、存储、恢复、注销和过期处理都遵循同一套规则。

下一阶段需要完成两件事:前端将 USER_TOKEN Cookie 收敛为唯一认证来源,后端提供真正的 Token 注销能力。在这两个条件成立后,双向登录和全局登出才能形成完整闭环。