Sa-Token SSO 用户数据同步与迁移实战:多系统账号、角色与权限对齐的三种架构方案
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
本篇技术指南以 Sa-Token 官方文档「用户数据同步 / 迁移」为核心,系统讲解企业级 SSO 对接中最棘手的难题:当公司已有 N 个各自独立账号体系的系统时,如何让它们借助sa-token-sso认证中心实现统一登录。文章将完整拆解「统一迁移、实时同步、字段关联」三种方案,并给出checkTicketAppendData、ticketResultHandle、loginId 与 centerId 转换等可直接落地的 Java 代码,同时结合仓库源码揭示这些策略函数的底层实现。
适用场景:正在基于 Sa-Token 的 sa-token-sso 插件做 SSO 单点登录改造,且各子系统已有存量用户数据、需要与认证中心对齐或关联的开发者。阅读前建议先熟悉 SSO 模式三 与 SSO 消息推送机制。
一、需求背景:理想化 SSO 架构与现实差距
在前面的不同架构 SSO 对接示例中(模式一 / 模式二 / 模式三),文档均假设了一个前提:
所有的 sso-client 只负责业务操作,不存储 user 数据,user 数据全部来源于 sso-server,包括登录认证也都是基于 sso-server 里的 user 账号进行校验操作。
这种架构比较简洁、清晰,是一种理想化的 SSO 架构模型——所有子系统的账号数据都收拢到认证中心,业务端只认一个账号源头。
然而更多时候,我们遇到的实际情况是:
公司已经有了 N 多个系统,每个系统都有自己独立的一套账号认证体系,现在老板要让这 N 个毫无关系的系统集成单点登录。
要完成这种需求,首先得考虑两个问题:
问题一:sso-client 需不需要保留 user 数据?
- sso-client 不涉及 user 信息连表查的业务,就可以不保留 user 信息;
- sso-client 涉及 user 信息连表查业务(例如帖子列表要附加显示头像昵称),就需要在 sso-client 保留 user 数据。
问题二:如果保留的话,是和 sso-server 强同步,还是弱同步?
- 强同步:sso-client 的 user 数据和 sso-server 的 user 数据字段值必须保持一致。例如一个用户在 server 端昵称修改为「张三」,那么在 client 端也要实时同步修改;
- 弱同步:两边可以各改各的。例如一个用户 server 端修改了昵称为「张三」,他在 client 端依然可以叫「李四」。
说明:本文源自官方文档设计思路,真实项目中每个公司的架构设计千差万别,一套设计理论未必适应所有公司的项目。如果本篇文章的设计理念不能契合你的需求,请以你公司的原设计为准。仓库中的 SSO 模式一、模式二、模式三 对接文档可作背景阅读。
二、三种设计方案总览
针对上述两个问题,可大致分为三种设计方案:
| 方案序号 | 方案名称 | 简单说明 | 适用系统 |
|---|---|---|---|
| 方案一 | 统一迁移 | 统一把用户数据迁移到 sso-server 认证中心再进行对接 | 比较简单的系统,业务上不需要 user 信息连表查 |
| 方案二 | 实时同步 | 按照一定的规则,使 sso-client 和 sso-server 保持 user 信息实时同步 | 一般业务上需要 user 信息连表查的系统 |
| 方案三 | 字段关联 | 不同步,但找一个关键字段,将 sso-client 和 sso-server 的 user 账号进行关联起来 | sso-client 不打算过分依赖 sso-server 的 user 数据,只是想借助 sso-server 完成统一登录 |
下面逐一拆解三种方案的具体实现。
三、方案一:统一迁移
核心思路:对接工作开发前,sso-client 的 user 数据完全迁移到 sso-server 中,且自身不再保留 user 数据,只进行业务数据处理操作。
这种方案其实不必过多讲解,因为数据完成迁移后整个架构就转化为了上文所述的「理想化 SSO 模型」,后续对接也比较方便。迁移方式可以选择数据库同步工具,或者手写代码从 sso-client 库读取数据然后 insert 到 sso-server 库中。
- 方案优点:架构简洁明了,SSO 登录、注销对接起来非常方便;
- 方案缺点:sso-client 不存储 user 信息,因此业务上需要连表查询 user 信息的地方会比较麻烦(例如:拉取帖子列表时需要附加显示用户头像和昵称信息);
- 适用范围:适合业务比较简单、不涉及 user 资料连表查业务的子系统。
四、方案二:实时同步
核心思路:首先,对接前数据还是要迁移的,只不过迁移后 sso-client 的 user 数据不删除掉,依然保留。然后在项目运行阶段,每当 sso-server 的 user 数据发生变动时(增删改),逐一向每个 sso-client 推送变化信息,使 sso-client 与 sso-server 的 user 数据保持强同步。
你可能会疑问:那 sso-client 的 user 数据发生变动时,要不要向 sso-server 推送信息?
官方建议:尽量不要让 sso-client 的 user 信息主动发生变化。
举个例子:
公司有电商、论坛、短视频 3 个子系统 + 1 个 sso-server 认证中心,无论用户从哪个子系统点击「修改我的资料」按钮时,都应该统一跳转到 sso-server 认证中心进行修改,修改完毕后再由 sso-server 将 user 信息推送至 3 个子系统,以此来保证 4 个系统间的 user 信息同步。
在 Sa-Token 中,「sso-server 主动通知 sso-client」这一动作,正是依赖 SSO 消息推送机制 中的消息类型实现的。从源码看,sso-server 端内置了checkTicket(ticket 校验)、signout(单点注销)两类消息处理器,而 sso-client 端内置了logoutCall(单点注销回调)处理器,并且两端都支持通过messageHolder.addHandle(type, handler)自定义消息处理器——这为「server 向 client 推送 user 变更」提供了现成的扩展通道。
- 方案优点:sso-client 存储了 user 信息,可以比较方便地进行 user 连表查操作;
- 方案缺点:sso-server 与 sso-client 的 user 数据同步功能不算简单,开发起来可能要耗费一段不小的工期;
- 适用范围:一般业务上需要 user 信息连表查的子系统都适合。
五、方案三:字段关联(核心方案)
如果子系统不需要和 sso-server 做到信息强同步,可以使用字段关联法做到账户关联进行登录。
举个例子:公司有三个子系统——电商、论坛、短视频。同一个用户可以在这三个子系统以及 sso-server 认证中心拥有不同的昵称、头像等信息,互不干扰。
例如,在 sso-server 认证中心里,张三的数据库信息为:
| id | username | avatar | password | age | |
|---|---|---|---|---|---|
| 10001 | ... | ... | ... | ... | ... |
| 10002 | 小明 | cat.jpg | 123456 | 18 | 23397@xx.com |
| 10003 | ... | ... | ... | ... | ... |
在电商系统里,张三的数据库信息为:
| id | name | avatar | money | |
|---|---|---|---|---|
| 100334 | ... | ... | ... | ... |
| 100335 | 二明 | dog.jpg | 1000 | 23397@xx.com |
| 100336 | ... | ... | ... | ... |
这里的关键点在于:虽然用户「张三」在每个系统里的资料都是不同的,但程序要想办法将它们识别为同一个用户。要做到这一点,就需要准备一个关键字段将信息打通串联起来,例如上表中的「邮箱」信息就可以作为这个「关联字段」。
注:此处仅展示使用邮箱作为关联字段的操作,实际上除了邮箱以外,手机号、身份证号等具有唯一性的信息都可以作为关联字段。
5.1 sso-server 端:重写 checkTicketAppendData 追加返回信息
首先,在 sso-server 端,我们需要重写checkTicketAppendData函数,使其在「校验 ticket 返回 loginId」时,追加返回 email 字段:
// 配置SSO相关参数 @Autowired private void configSso(SaSsoServerTemplate ssoServerTemplate) { // 其它配置 ... // 配置:Ticket校验函数 ssoServerTemplate.strategy.checkTicketAppendData = (loginId, result) -> { System.out.println("-------- 追加返回信息到 sso-client --------"); // 在校验 ticket 后,给 sso-client 端追加返回信息的函数 SysUser user = sysUserMapper.getById(loginId); result.set("email", user.getEmail()); // result.set("user", user); // 你也可以将整个user 对象的信息都返回到 sso-client,自由决定 return result; }; }从源码看,checkTicketAppendData的定义位于 SaSsoServerStrategy 中,其底层函数式接口CheckTicketAppendDataFunction声明为BiFunction<Object, SaResult, SaResult>,即入参为「loginId + 响应结果对象 SaResult」,返回「追加了自定义字段后的 SaResult」——所以你可以在result上通过set(key, value)追加任意字段(email、完整 user 对象等),sso-client 端都会在ticketResultHandle中通过ctr.result.get("email")拿到。
5.2 sso-client 端:重写 ticketResultHandle 完成本地登录
在 sso-client 端,重写ticketResultHandle函数,根据 sso-server 返回的信息查询本地 user 信息并登录:
// 配置SSO相关参数 @Autowired private void configSso(SaSsoClientTemplate ssoClientTemplate) { // 其它配置 ... // 自定义校验 ticket 返回值的处理逻辑 (每次从认证中心获取校验 ticket 的结果后调用) ssoClientTemplate.strategy.ticketResultHandle = (ctr, back) -> { System.out.println("--------- 自定义 ticket 校验结果处理函数 ---------"); System.out.println("此账号在 sso-server 的 userId:" + ctr.loginId); System.out.println("此账号在 sso-server 会话剩余有效期:" + ctr.remainSessionTimeout + " 秒"); System.out.println("此账号返回的 email 信息:" + ctr.result.get("email")); // 模拟代码: // 根据 email 字段找到此账号在本系统对应的 user 信息 String email = (String) ctr.result.get("email"); SysUser user = sysUserMapper.getByEmail(email); // 如果找不到,说明是首次登录本系统的新用户,需要自动注册一个新账号给他 if(user == null) { // 涉及到数据库操作,此处仅做模拟代码 // 1、构建 user 信息 // 2、插入到数据库 // 3、查询出最新刚插入的这条 user 信息 user = sysUserMapper.getByEmail(email); } // 进行登录 StpUtil.login(user.getId(), ctr.remainSessionTimeout); StpUtil.getSession().set("user", user); // 一切工作完毕,重定向回 back 页面 return SaHolder.getResponse().redirect(back); }; }ticketResultHandle是sso-client端每次从认证中心获取校验 ticket 结果后都会调用的钩子,其函数式接口签名位于 TicketResultHandleFunction:run(SaCheckTicketResult ctr, String back)。
这里ctr(类型SaCheckTicketResult)承载了校验 ticket 后的全部返回信息,从 SaCheckTicketResult 源码可知它包含以下字段,供你灵活取用:
| 字段 | 含义 |
|---|---|
loginId | sso-server 端的账号 id |
centerId | 此账号在认证中心的 loginId |
tokenValue | 在 sso-server 端的 token 值 |
deviceId | 登录设备 id |
remainTokenTimeout | 此账号 token 剩余有效期(秒) |
remainSessionTimeout | 此账号会话剩余有效期(秒) |
result | 从 sso-server 返回的原生所有参数(含checkTicketAppendData追加的字段) |
方案三优缺点小结:
- 方案优点:
- sso-client 不需要和 sso-server 保持信息强同步,实现起来不复杂,架构也比较清晰易维护;
- 同一个用户的信息,sso-client 可以和 sso-server 保持不同,各自维护各自的,互不干扰。
- 方案缺点:好像没啥缺点,除非你觉着上述第 2 条优点属于缺点。
- 适用范围:在 user 信息方面不打算过分依赖 sso-server 的系统,希望自己维护自己的 user 信息,只是想借助 sso-server 完成一下统一登录。
六、扩展:没有关联字段怎么办(center_id 方案)
如果子系统的 user 表没有邮箱、手机号等唯一性字段和 sso-server 的 user 表进行关联,该怎么办呢?
没有字段,那就创造个字段,例如在子系统 user 表新增一列center_id,记录这个用户在认证中心所属的账号 id:
| id | name | avatar | age | center_id |
|---|---|---|---|---|
| 205421 | ... | ... | ... | ... |
| 205422 | 小风筝 | dog.jpg | 21 | 10002 |
| 205423 | ... | ... | ... | ... |
如上表所示,center_id记录了这个用户在认证中心所属的账号 id(此处为 10002),登录时根据这个center_id来查找相应的用户。
由于 sso-server 端默认就会返回 loginId 参数,因此在 sso-server 端不必再重写checkTicketAppendData函数来追加返回信息了,我们只需要重写 sso-client 端的ticketResultHandle函数即可:
// 配置SSO相关参数 @Autowired private void configSso(SaSsoClientTemplate ssoClientTemplate) { // 其它配置 ... // 自定义校验 ticket 返回值的处理逻辑 (每次从认证中心获取校验 ticket 的结果后调用) ssoClientTemplate.strategy.ticketResultHandle = (ctr, back) -> { System.out.println("--------- 自定义 ticket 校验结果处理函数 ---------"); System.out.println("此账号在 sso-server 的 userId:" + ctr.loginId); System.out.println("此账号在 sso-server 会话剩余有效期:" + ctr.remainSessionTimeout + " 秒"); // 模拟代码: // 根据 center_id 字段找到此账号在本系统对应的 user 信息 long centerId = SaFoxUtil.getValueByType(ctr.loginId, long.class); SysUser user = sysUserMapper.getByCenterId(centerId); // 如果找不到,说明是首次登录本系统的新用户,需要自动注册一个新账号给他 if(user == null) { // 涉及到数据库操作,此处仅做模拟 // 1、构建 user 信息 // 2、插入到数据库 // 3、查询出最新刚插入的这条 user 信息 user = sysUserMapper.getByCenterId(userId); } // 进行登录 // 注意此处需要使用 centerId 进行登录,否则该账号将无法正常完成单点注销功能 StpUtil.login(centerId, ctr.remainSessionTimeout); StpUtil.getSession().set("user", user); // 一切工作完毕,重定向回 back 页面 return SaHolder.getResponse().redirect(back); }; }代码中有两处关键细节值得注意:
SaFoxUtil.getValueByType(ctr.loginId, long.class)负责把 sso-server 返回的 loginId(可能是字符串等类型)安全转换为long类型,再去数据库按center_id查询;- 登录时必须使用centerId 进行登录(而非本地业务 user 的 id),否则该账号将无法正常完成单点注销功能——这一点在下一节会展开解释。
七、方案三完整登录流程解析
按照方案三,一个用户登录过程中,sso-server 和 sso-client 对这个用户账号的完整处理步骤如下:
- 用户进入 sso-client 登录页面,点击「使用 xx 认证中心快捷登录」按钮,浏览器跳转至 sso-server 认证中心;
- 如果用户在 sso-server 有账号,则直接登录;如果没有,则注册账号并登录;
- sso-server 重定向回 sso-client 端,并携带 ticket 参数;
- sso-client 获取 ticket 参数,并解析出 center_id 值;
- 根据 center_id 从 user 表查数据:
- 5.1 查得到,证明有账号,直接登录;
- 5.2 查不到,证明无账号,程序自动给他添加一条 user 账号,并登录;
- 登录完成。
整个流程的本质,就是「认证中心负责认证、本地端负责按关联字段落地账号」:sso-server 端通过 checkTicket 消息处理器 完成 ticket 校验并返回 loginId,sso-client 端则在ticketResultHandle中完成「查询 → 自动注册 → 登录」的完整闭环。
八、解决方案三下 loginId 与 centerId 不一致的问题
按照字段关联法登录之后,如果一个用户在本地应用端的 userId 和认证中心端的 userId 不一致,则可能发生单点注销失败的情况。
假设:一个用户在认证中心的 userId=10002,在本地应用端的 userId=100335。则在本地应用端发起单点注销时,其传递的 loginId 值是 100335,sso-server 是找不到 userId=100335 用户的,自然无法单点注销成功。
解决方案是在本地应用端重写loginId 与 centerId 转换策略函数,做到本地应用 userId 与认证中心 userId 的互相映射:
@RestController public class SsoClientController { // 配置SSO相关参数 @Autowired private void configSso(SaSsoClientTemplate ssoClientTemplate) { // 重写 loginId 与 centerId 转换策略函数,做到本地应用 userId 与认证中心 userId 的互相映射 // 将 centerId 转换为 loginId 的函数 ssoClientTemplate.strategy.convertCenterIdToLoginId = (centerId) -> { return "Stu" + centerId; }; // 将 loginId 转换为 centerId 的函数 ssoClientTemplate.strategy.convertLoginIdToCenterId = (loginId) -> { return loginId.toString().substring(3); }; } }如上代码演示了应用本地 loginId 与认证中心 centerId 不一致时的转换写法(演示逻辑为添加和裁剪指定前缀)。真实项目中,应该根据用户表存储的映射关系来做查询返回。
从源码看,这两个转换函数定义在 SaSsoClientStrategy 中,默认实现均为恒等映射(centerId -> centerId、loginId -> loginId)。它们已被底层核心链路自动调用:在 SaSsoClientTemplate.ssoLogout() 方法中,单点注销前会先执行strategy.convertLoginIdToCenterId.run(loginId)将本地 loginId 转换为认证中心 id,再构建 signout 消息推送至 sso-server——这就是「登录用 centerId、注销也按转换策略提交」的原因,两者必须一一对应才能保证单点注销链路完整。
值得注意的是,在重写转换策略后,我们在消息推送时也应该严格按照转换写法提交 loginId 参数,例如:
// 查询我的账号信息:sso-client 前端 -> sso-center 后端 -> sso-server 后端 @RequestMapping("/sso/myInfo") public Object myInfo() { // 如果尚未登录 if( ! StpUtil.isLogin()) { return "尚未登录,无法获取"; } // 原写法:直接调用 StpUtil.getLoginId() 当做 centerId 来提交 // Object centerId = StpUtil.getLoginId(); // 新写法:获取本地 loginId 对应的认证中心 centerId Object centerId = SaSsoClientUtil.getSsoTemplate().strategy.convertLoginIdToCenterId.run(StpUtil.getLoginId()); // 推送消息 SaSsoMessage message = new SaSsoMessage(); message.setType("userinfo"); message.set("loginId", centerId); SaResult result = SaSsoClientUtil.pushMessageAsSaResult(message); // 返回给前端 return result; }这样,/sso/pushS接口收到的 loginId 就是认证中心视角的 userId,sso-server 端无论做用户资料查询还是单点注销,都不会再出现「本地 id 在认证中心查不到」的问题。消息推送的消息类型定义与自定义处理器写法,可进一步参考 SSO 消息推送机制 文档;单点注销的完整链路可参考 SSO 单点注销。
九、三种方案落地建议与参考实现
最后做一次总结性对比,便于你在真实项目中快速决策:
| 维度 | 方案一 统一迁移 | 方案二 实时同步 | 方案三 字段关联 |
|---|---|---|---|
| 是否保留 client 端 user 数据 | 否 | 是 | 是 |
| 同步强度 | — | 强同步 | 弱同步 / 不同步 |
| 连表查 user 信息 | 麻烦 | 方便 | 方便(各自维护) |
| 开发成本 | 低 | 高 | 中 |
| 核心实现点 | 一次性数据迁移 | server 端消息推送 + client 端接收落库 | checkTicketAppendData+ticketResultHandle+ id 转换策略 |
如果你希望直接查看可运行的完整示例,仓库提供了模式三的 Demo:sa-token-demo-sso3-client,其中包含了 sso-client 的 ticket 校验结果处理、登录、注销等完整配置代码;sso-server 端的示例可参考 sa-token-demo-sso-server 与 SSO 服务端对接文档。
需要再次强调的是:本文中的三种方案属于架构设计参考,并非强制规范。真实场景中每个公司的架构设计千差万别,业务上需要连表查询 user 信息就优先考虑方案二/方案三,业务简单、不依赖 user 资料展示就选方案一,而不需要强同步的系统用字段关联法(方案三)往往是最低成本的落地方案——它既不需要复杂的同步工程,又能让各子系统保持各自的数据自治。
【免费下载链接】Sa-Token✨ 开源、免费、一站式 Java 权限认证框架,让鉴权变得简单、优雅!—— 登录认证、权限认证、分布式 Session 会话、微服务网关鉴权、SSO 单点登录、OAuth2.0 统一认证、jwt 集成、API Key 秘钥授权、API 参数签名项目地址: https://gitcode.com/GitHub_Trending/sa/Sa-Token
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考