如果你和我一样手里长期维护着一套基于ruoyi-vue-pro二开的中后台平台大概率早晚会被一个问题找上门企业内部系统越来越多报表、工单、审批、监控各自一套账号密码用户在系统间来回跳转登录登出恨不得占半天工。我这边的客户提了一整年的需求最后实在绕不过去决定在项目里新增一个通用的单点登录模块yudao-module-sso专门解决多系统统一认证和登录态打通的问题。这篇文章就把整个设计和落地过程梳理一遍包括模块边界、数据模型、三种内嵌单点登录实现方式、实际部署的配置和代码以及我在对接过程中踩过的坑。如果你正在用 ruoyi-vue-pro 做二开想把 SSO 能力补上这篇文章可以直接当落地参考。1. 先想清楚边界为什么认证体系里还需要再拆一个 SSO 模块1.1 已有能力盘点登录、令牌、OAuth2 都覆盖了缺的是什么ruoyi-vue-pro 本身不是一个没有认证的框架。它自带账号密码登录、短信登录、微信/钉钉等第三方登录还提供了 Spring Security 和 Sa-Token 两套会话机制不同版本有差异token 签发、刷新、拦截器都是现成的。模块层面也有yudao-module-system管用户、角色、权限yudao-module-oauth2实现了 OAuth2 授权服务器能力。那为什么还要新增yudao-module-sso这是我在动手前反复确认的问题。如果只是用户登录后拿到 token 去访问多个子系统那依赖 OAuth2 或者直接共享 token 就够了。但实际情况比这复杂单点登录中的主系统往往需要给子应用提供跳转登录入口不能每次都用账号密码而是子应用带着来源标识跳转过来主站登录后带着凭证回去。子应用不一定是 ruoyi-vue-pro 体系的可能是老系统、第三方部署的系统甚至只有简单后端接口。它不能直接信任主站的 token需要一个机制完成主站登录态 - 子站本地登录态的转换。企业场景下SSO 不只服务 Web 端还要考虑回调地址白名单、登出广播、登录审计这些逻辑如果塞进 system 模块会让用户管理模块变得臃肿。所以我最终确定的定位是yudao-module-sso 是一个独立于用户管理和 OAuth2 的接入层模块目标是让任意子系统都能通过标准的跳转票据方式接入统一登录同时保留 OAuth2 这类开放授权能力给外部开发者使用。1.2 模块边界设计yudao-module-sso 与其他模块的分工模块之间的职责边界我建议按下面的方式划分这也是我在项目里注册模块依赖时遵循的原则模块核心职责和 SSO 模块的关系yudao-module-system用户管理、角色权限、多租户、操作日志SSO 模块通过 API 读取用户信息、创建会话不直接操作用户表yudao-module-oauth2开放授权、统一 token 管理、OAuth2 授权码OAuth2 可以作为 SSO 的一种接入方式但 SSO 不依赖它作为唯一实现yudao-module-infra配置管理、文件、定时任务、日志SSO 登录审计、异步任务记录复用它yudao-module-sso应用注册、票据签发与校验、登出广播、接入方 SDK新增模块不侵入既有业务为什么说不能直接扩展 oauth2 就行核心原因是方向不同。OAuth2 是资源授权模型强调的是第三方应用在用户授权后获取访问资源的能力而企业内部门户场景的 SSO往往只有一个中心登录页和一个主登录态子应用只是借这个登录态完成自己的本地会话建立并不需要复杂的 scope 授权流程。如果硬用 OAuth2 模型套 SSO会出现一个很尴尬的结果每个子系统都要维护自己的 client 配置、回调地址、scope 定义接入成本被人为抬高。1.3 项目落地的实际收益这个模块建好之后最直接的收益不是多了一个功能而是把之前零散的接入方式收敛了。我的项目里同时存在老 PHP 系统、Spring Boot 2 的旧服务、以及新起的 ruoyi 体系服务以前每个系统都需要单独开发登录页处理密码同步现在统一走 SSO 跳转用户在任何一个子系统里点登录都会跳回统一登录页登录完成后自动回到原来的页面并建立会话。从长期维护角度看修改密码策略、开启双因子认证、封禁账号这类操作也只需要在主站做一遍所有接进来的系统自动生效。这是我在设计阶段感受最深的价值点减少重复建设只是显性收益收敛认证入口带来的管理便利才是更值钱的部分。2. 通用 SSO 的数据模型与核心流程2.1 应用注册表appId、appSecret 与回调白名单既然要给多个子系统提供接入能力第一步就是做好应用注册的数据模型。这里借鉴了 OAuth2 的 client 模型但不完全照搬核心表我设计了三张应用表、票据表、登录审计表。应用表yudao_sso_app的字段设计如下CREATE TABLE yudao_sso_app ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, app_name varchar(64) NOT NULL COMMENT 应用名称如报表系统, app_id varchar(32) NOT NULL COMMENT 应用标识全局唯一, app_secret varchar(64) NOT NULL COMMENT 应用密钥用于签名回调请求, redirect_uris varchar(512) NOT NULL COMMENT 回调地址白名单多个用逗号分隔, logout_uri varchar(512) DEFAULT NULL COMMENT 登出通知地址, sso_mode tinyint NOT NULL DEFAULT 1 COMMENT 接入模式1-票据重定向 2-Cookie共享 3-OAuth2, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0-启用 1-禁用, expire_duration int NOT NULL DEFAULT 300 COMMENT 票据有效期单位秒, create_time datetime NOT NULL COMMENT 创建时间, update_time datetime NOT NULL COMMENT 更新时间, deleted bit(1) NOT NULL DEFAULT b0 COMMENT 逻辑删除, PRIMARY KEY (id), UNIQUE KEY uk_app_id (app_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT单点登录应用表;几个设计细节我重点说一下。回调地址白名单之所以要用逗号分隔而不是单独建子表是因为实际接入的应用回调地址基本只有一到两个拿子表存反而增加代码复杂度和跨表查询。等到真出现一个应用有几十个回调域名的情况再升级为子表也来得及这个度我自己认为比较合适。票据表yudao_sso_ticket不一定要建成物理表我实际用的是 Redis 来存储表结构只是预留审计和故障排查用。核心字段就是 ticketId、appId、用户ID、状态、过期时间。如果用 Rediskey 建议设计成sso:ticket:{ticketId}value 存 JSON 字符串包含 uid、appId、创建时间TTL 和应用的expire_duration保持一致。2.2 票据签发与校验的完整时序信息在应用中流转的时序是整个 SSO 模块的心脏。我先把默认的票据重定向模式画成文字流程因为后面所有代码都是围绕它展开的用户访问子系统比如报表系统未登录子系统后端给前端一个跳转 URL格式为https://sso.example.com/sso/login?appIdreportredirectUrihttps://report.example.com/callback。用户在统一登录页登录主站认证框架我是用 Sa-Token创建主站会话。登录成功后由yudao-module-sso的服务端生成一次性票据ticketIdUUID存 Redis同时把用户重定向回redirectUri并拼接参数https://report.example.com/callback?ticketxxxappIdreporttimestamp123。子系统收到回调请求拿着 ticket 参数调用 SSO 模块提供的校验接口POST /sso/validate参数为appId、ticket、timestamp请求头里带appSecret签名。SSO 模块校验签名、校验票据存在、校验票据说对应用 ID如果都通过删除这个 ticket防止重放返回用户基本信息uid、username、nickname和主站 token。子系统拿到用户信息后在自己的体系里查用户或自动建账号建立本地会话然后用户回到正常页面。这里最关键的设计是一次性票据。它和很多初学者的做法不同不是把主站 token 直接交给子系统而是用票据换取用户信息然后票据立刻失效。你可以把它类比成演唱会门票进场的时候检票员撕掉票根你才能进入场馆撕掉的票根不可能再用第二次。这样即使回调地址被监听或者票据在传输过程中泄露攻击者在票据失效之后也无法再冒用。2.3 会话同步登出时如何通知所有已接入应用单点登录有一句俗话登录靠跳转登出靠广播。如果用户在主站点了退出子系统的会话还活着下次访问仍然不需要登录这个单点登录就是残缺的。实现登出广播我采用的是最务实的方案主站登出后遍历所有启用的应用异步调用各应用注册的logout_uri并在请求参数里附上userId、timestamp和签名。子系统收到请求后校验签名清除本地会话。子系统的接口设计大概是这样的PostMapping(/sso/logout) public ResultBoolean ssoLogout(RequestBody SSOLogoutReqVO reqVO) { // 1. 校验签名 ssoClient.verifySign(reqVO.getAppId(), reqVO.getTimestamp(), reqVO.getSign()); // 2. 清除本地会话 loginService.logoutByUserId(reqVO.getUserId()); return success(true); }异步发送的安全性有一点要提醒登出广播本来就是尽力而为的通知不能因为某个子系统挂了就导致主站登出失败。所以我这里用线程池异步发送并加了简单的重试机制第一次发送失败后延迟 10 秒重试一次再失败就只记录日志由运维人员事后排查。这个设计在可靠性和实现成本之间取了平衡如果你对登出强一致有要求可以加消息队列但很多中小项目确实没有必要。3. 内嵌单点登录的三种实现方式与选型逻辑标题里提到的内嵌单点登录三种实现方式是我在模块里同时支持的三种对接模式。很多项目在做 SSO 时只适配一种场景我是因为在真实交付中发现客户子系统的形态差异实在太大单一路线根本走不通所以干脆做成三种模式让接入方按自己的技术现状选择。3.1 方式一同主域 Cookie 共享模式这是成本最低的一种方式前提条件是所有子系统都在同一个顶级域名下。比如主站是auth.example.com子系统是report.example.com、workflow.example.com那么只需要在主域.example.com下种一个 Cookie理论所有子域都能读到。实现时有几个关键参数cookie.domain设置为.example.comcookie.path设置为/cookie.sameSite在生产环境建议设置为Lax避免跨站请求带上 Cookie 导致安全问题cookie.httpOnly必须为 true防止 XSS 脚本读取会话标识这种模式的优点是把登录态变成了浏览器行为子系统只需要在本地解析 Cookie 里的用户标识就能建立会话。但它的问题也很明显一旦跨顶级域名Cookie 就失效了。比如主站在example.com子站是another.com这条路就走不通。另一个隐患是安全边界较宽所有子域只要能被攻击者控制一个整个认证体系都会受影响。所以我的建议是仅用于内部工具类系统且主域名能统一管理的场景。3.2 方式二中心认证加票据重定向模式CAS 风格这是yudao-module-sso默认推荐的模式也是我前面时序图里描述的那种实现。它参考了 CAS 协议的核心思想但做了裁剪不直接实现完整的 CAS Ticket 校验协议而是通过自己的校验接口完成登录态转换。适用场景很清晰子系统分布在不同的域名甚至不同的网络环境无法共享 Cookie但子系统自己有完整的用户体系和会话管理能力。这种模式对子系统的改造量最小只需要增加一个校验票据换用户的接口改动通常在半天以内就能完成。票据重定向模式在内部还有一个变体如果子系统只是前端需要登录后端可以不做任何对接由前端在 SSO 登录成功后把用户 token 直接放到本地 localStorage前端每个请求带上 token。这种方式安全性稍弱但对于纯前端静态部署的轻量系统很友好。我在项目中一般会根据子系统的后端能力来推荐具体做法而不是一刀切。3.3 方式三OAuth2 授权码模式当接入方不是内部系统而是合作方、生态伙伴或者需要更细粒度的授权控制时我会启用占位在yudao-module-oauth2上的授权码模式。这个方式其实不算 yudao-module-sso 独有的能力但我在 SSO 模块里做了一层适配使它也能通过yudao_sso_app表里的应用配置直接接入。流程上也就是标准的 OAuth2 授权码流程跳转授权页、用户确认、回调授权码、换 token、拿用户信息。对于外部系统来说这个模式的好处是有标准协议可参考成熟 SDK 多坏处是接入成本最高参与方需要理解client_id、client_secret、redirect_uri、scope这一套概念。比较有意思的场景是同一个子系统可以同时支持票据模式和 OAuth2 模式。比如内部网络场景用票据模式外部办公网络访问时走 OAuth2这样既能享受到内网跳转的快捷又能保证外部接入的可控性。3.4 选型逻辑对比表格三种模式放在一起做对比我通常用下面这张表给客户讲能省很多沟通成本维度Cookie 共享模式票据重定向模式OAuth2 授权码模式接入成本低基本不用改中需要加校验接口高需要理解完整协议跨顶级域名不支持支持支持客户端适配浏览器 Cookie 机制后端跳转HTTP 调用标准 OAuth2 客户端安全强度一般较高一次性票据防重放高可配置授权范围适用场景内部同主域工具站群内部多域名系统合作方开放平台选型时我的经验就一句话离核心业务越远、域名越分散、接入方越不受控就越需要往协议完整的方向走。纯内网、都是自己人的场景根本没有必要让开发人员去理解授权码那套概念票据模式就是效率和安全的最优平衡点。4. 实操落地pom 依赖、表结构初始化、核心代码与配置4.1 模块创建与依赖关系和 ruoyi-vue-pro 里其他模块一样我会新建一个yudao-module-sso目录并在pom.xml里声明依赖。核心依赖只需要三个基础模块、system 的 API 模块、还有 Redis 缓存dependency groupIdcn.iocoder.boot/groupId artifactIdyudao-common/artifactId /dependency dependency groupIdcn.iocoder.boot/groupId artifactIdyudao-module-system-api/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency这里有一个容易被忽略的点不要直接依赖yudao-module-system的完整 jar而是只依赖yudao-module-system-api。这样 SSO 模块只能通过 API 接口访问用户数据不会和 system 模块内部的 Mapper、Service 耦合。模块化开发的边界感就在这种细节里。4.2 配置项说明在全局配置application.yaml中增加如下配置段yudao: sso: enabled: true login-url: /sso/login default-ticket-timeout: 300 cookie-domain: .example.com cookie-name: SSO_TOKEN logout-broadcast-enabled: true配置项的含义我逐个说明login-url是统一登录入口所有子应用跳转都会先进入这个地址。default-ticket-timeout控制票据默认有效期单位秒。我建议不要设太长300 秒足够用户从登录页跳回子系统并完成校验太长会增加被截获重放的风险窗口。cookie-domain只在 Cookie 共享模式下生效如果你全部走票据模式这个配置可以留空。logout-broadcast-enabled是登出广播开关多子系统场景必须打开。4.3 核心代码走读票据生成的核心逻辑我把它抽成了一个独立的SSOTicketService这样 Controller 层只需要关注参数接收和跳转Service public class SSOTicketService { Resource private StringRedisTemplate stringRedisTemplate; public String createTicket(Long userId, String appId, int expireDuration) { String ticketId UUID.randomUUID().toString().replace(-, ); SSOTicket ticket new SSOTicket(); ticket.setTicketId(ticketId); ticket.setUserId(userId); ticket.setAppId(appId); long expireTime LocalDateTime.now().plusSeconds(expireDuration) .atZone(ZoneId.systemDefault()).toEpochSecond(); stringRedisTemplate.opsForValue().set( RedisKeyConstants.SSO_TICKET ticketId, JSONUtil.toJsonStr(ticket), Duration.ofSeconds(expireDuration) ); return ticketId; } public UserInfo validateTicket(String appId, String ticketId) { String key RedisKeyConstants.SSO_TICKET ticketId; String value stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(value)) { throw new ServiceException(SSOErrorCodeConstants.TICKET_INVALID); } SSOTicket ticket JSONUtil.toBean(value, SSOTicket.class); if (!appId.equals(ticket.getAppId())) { throw new ServiceException(SSOErrorCodeConstants.TICKET_APP_MISMATCH); } // 验证通过后立即删除防止重放 stringRedisTemplate.delete(key); return userApi.getUser(ticket.getUserId()); } }这段代码里最容易被忽略的一行是stringRedisTemplate.delete(key)。很多早期做 SSO 的实现就因为没有消费后删除导致同一个 ticket 可以被抓包后反复使用。票据是一次性的这个语义必须在代码级别强制执行而不是靠业务约束。签名校验的逻辑我放在客户端 SDK 里服务端和客户端共用同一套签名算法public String buildSign(String appId, String timestamp, String appSecret) { String raw appId timestamp appSecret; return DigestUtils.md5Hex(raw); }签名的顺序必须是appId timestamp appSecret并约定拼接顺序不参与 hash 碰撞讨论。它的作用是防止篡改和重放timestamp 由接入方生成超过 5 分钟则拒绝签名不符则拒绝。这个机制简单但有效内部系统完全不需要引入复杂的 JWT 和公钥体系。4.4 管理端界面给应用注册表补充运维页面模块做出来了如果管理端界面还是裸奔的运维起来会非常痛苦。ruoyi-vue-pro 带代码生成器所以我直接用它的生成功能把yudao_sso_app表的 CRUD 管理页面生成了出来菜单名就叫SSO 应用管理。生成后我重点调整了两个字段redirect_uris的录入形式改成了多行文本一行一个回调地址后端处理时按换行拆分比逗号分隔更不容易出错。appSecret在列表页不回显只在创建或重置时显示一次并增加重置密钥按钮满足定期轮换密钥的要求。界面这东西不必写得天花乱坠但密钥可以重置这一条很重要。现实中密钥泄露是迟早的事没有重置入口就只能手动改数据库我可不想在工单里面看到请帮我改一下 SSO 密钥这种需求。5. 踩坑记录与性能优化5.1 回调 URL 编码与签名校验的坑我最开始接入第一个子系统时就遇到了回调地址带中文查询参数导致验证失败的问题。子系统的回调地址是https://report.example.com/callback?from工作台跳转时整个 URL 拼接在重定向参数后面前端没有做 encodeURIComponent结果到了 SSO 登录页时from参数已经乱码后续跳回去也跟着错。这个问题的教训是回调地址在拼接时必须把整个 redirectUri 作为参数值进行一次 URL 编码。服务端在解析时再用URLDecoder.decode还原然后拿还原后的地址和白名单比对。白名单比对这里也有讲究不能只做前缀匹配否则https://report.example.com/callback.attacker.com也能绕过要按 URI 的协议、域名、端口、路径逐段做精确匹配。5.2 Redis 序列化导致票据偶发校验失败这个坑非常隐蔽。项目里的 Redis 配置本来用的是GenericJackson2JsonRedisSerializer会把对象序列化成带类型信息的 JSON。我写SSOTicket时图省事用了内部类或者没有提供无参构造函数结果在反序列化时偶发报类型转换错误ticket 明明存在但校验失败用户反馈登录跳转后白屏。排查过程花了半天最后定位到问题后我把票据存储的 key 和 value 改为纯 String 类型value 只保存一个紧凑的 JSON同时在SSOTicket上加上无参构造和 getter/setter。如果你也在 ruoyi-vue-pro 里集成 Redis 序列化这一点要特别注意最稳妥的方案是像上面代码那样用StringRedisTemplate专门处理票据和业务对象的序列化体系隔离。5.3 多个接入方同时回调时的性能问题上线第一个月没出问题直到公司举办了一场全员活动几百人几乎同一时间从门户系统跳转到活动子系统结果 SSO 模块的 CPU 瞬时拉高用户跳转明显卡顿。我排查时发现瓶颈不在 Redis而在校验接口里同步调用了用户 API 的完整信息查询每个人都做了多级 DB 查询。优化方案两个第一在SSOTicketService.validateTicket里只返回用户 ID、用户名、昵称这三个必要字段不返回岗位、部门、角色这些重字段减少 API 调用链第二对同一 userId 的用户信息增加本地缓存TTL 设 5 分钟。登录审计日志的写入也从同步改成了异步。优化后单接口响应从 120ms 降到 20ms 左右活动当天再没出现卡顿。另外还有一个容易被忽略的小点发现跳转慢的时候要检查spring.mvc.async配置和 HTTP 连接池。如果子系统和 SSO 模块之间用的连接池默认最大连接数太小并发稍微上来就会线程排队表现为偶发跳转超时。适当调大连接池的maxTotal和maxIdle同时对票据校验接口设置合理的超时时间不要无限等下游。5.4 前端登录页的 iframe 嵌入兼容问题客户提出一个需求希望把统一登录页嵌套在自己门户的 iframe 里减少跳转的割裂感。这个需求听着简单实际操作时到处都是问题。一个就是第三方 Cookie 限制如果门户域名和 SSO 域名不一致浏览器默认情况下会阻止 iframe 内的跨站 Cookie 写入导致用户明明登录成功了Cookie 却种不上。实测后的处理办法SSO 登录页检测到自身被 iframe 嵌套时不再依赖 Cookie而是登录成功后把 token 通过postMessage传递给父页面由父页面保存并在后续路由中透传。这个方案有几个前提条件一是父页面和子页面必须约定消息格式二是要校验消息来源event.origin不能什么域名发来的消息都信。如果你也要做类似功能务必把postMessage的 origin 白名单校验写对否则等于给 XSS 攻击开了一扇门。6. 上线后的运行效果与后续扩展6.1 一个真实系统的接入过程让我用一个真实例子说明整个模块怎样降低了接入成本。客户有一个用 PHP 写的老报表系统运维都准备弃坑了但业务部门还在用而且希望它能纳入统一登录。这个系统没有用户管理能力之前是每半年手动同步一次账号密码。接yudao-module-sso时流程很顺利在管理后台注册应用拿到 appId 和 appSecret配置回调地址。报表系统的登录入口改为跳转 SSO 的登录 URL本地不再做账号密码验证。报表系统新增一个 callback 接口接收 ticket 后调用/sso/validate拿到用户信息后在本地建立一个临时会话记录把用户 ID 映射到报表系统内部角色。主站登出时报表系统收到登出广播销毁本地会话。整个改造不到一个工作日而且没有动报表系统的核心数据表。这是这个模块最让我满意的地方它不是挑业务接入而是让不同技术栈的系统都能用最低成本纳入统一认证。6.2 后续还能怎么扩展模块上线后我下一个打算做的扩展是支持 OIDC 协议。虽然很多内网系统用现在的票据模式就够了但随着外部合作伙伴增多OIDC 的 ID Token 和标准用户信息端点会成为刚需。好消息是现有模块的表结构已经在sso_mode字段上为多种模式留了空间扩展时只需要新增一个控制器和解析器不需要改已有的票据逻辑。另外一个被问得比较多的方向是扫码登录。企业微信、钉钉的扫码登录其实也可以理解为一种外部 SSO 接入用户扫码后由外部平台回调到消息服务再通过内部 SSO 流程创建会话。这个方向已经不在 yudao-module-sso 的本体范围里了但能基于现有的应用注册 用户映射机制做得很顺。从我个人的体会来说模块化开发的真正价值恰恰在于把一个横切关注点收拢成独立的领域模型。用户管理管的是这是谁OAuth2 管的是能做什么而 yudao-module-sso 管的是如何让不同系统相信同一个用户的身份。这三个问题分开思考、独立演进比全部堆在一个模块里要清晰得多。如果你也准备在 ruoyi-vue-pro 上搭建单点登录建议你也先把边界画清楚再写代码后面会省掉大量返工的麻烦。