实战:多系统一次登录全打通(SpringBoot))
OAuth2 JWT 企业单点登录SSO实战多系统一次登录全打通SpringBoot演示地址http://ruoyioffice.com | 源码1·GitHubruoyi-office | 源码2·GitCoderuoyi-office | 源码3·Giteeruoyi-office | 微信17156169080备注「RuoYi Office」企业一旦上了 OA、CRM、ERP、报表等多套系统每个系统一套账号密码就成了灾难员工记不住、IT 管不过来、离职账号清不干净。单点登录SSOSingle Sign-On即一次登录、多系统通行。但真正落地时绕不开几个硬问题Token 用 JWT 还是不透明串登录态存哪怎么撤销怎么续期本文基于 RuoYi Office 真实源码拆解一套「Spring Security 无 Session OAuth2 不透明 TokenUUID Redis/MySQL 双存储」的企业级 SSO 方案。▲ OAuth2 SSO 认证全景登录发 TokenUUID→ MySQL 持久化 Redis 热缓存 → 网关验 Token 透传 login-user → 下游 PreAuthorize 校验授权码模式打通第三方应用引言企业 SSO 到底难在哪先说结论SSO 难的不是登一次而是登录态怎么管。常见的几个坑痛点一Token 选型摇摆。JWT 自包含、不查库但改了权限/想踢人却撤不掉不透明 Token 可撤销但每次要查存储。选错了后期很难改。痛点二登录态存哪。只存 Redis重启/宕机丢登录态只存 MySQL高频校验压垮数据库。痛点三第三方应用接入。自家系统好说外部应用怎么安全地借登录态这正是 OAuth2 授权码模式要解决的。痛点四续期与过期。Access Token 短期有效更安全但频繁让用户重登体验差需要 Refresh Token 静默续期。现状后果纯 JWT 无状态无法主动撤销改权限要等过期登录态只存 Redis宕机丢失需重新登录多系统各自登录账号泛滥离职清理难无 Refresh 机制频繁掉线体验差本文方案一句话用 OAuth2 协议语义承载 SSO用不透明 Token 双存储兼顾可撤销与高性能。一、核心选型为什么是不透明 Token不是 JWT先给定义OAuth2 是授权框架定义如何颁发和使用 TokenJWT 是一种自包含 Token 格式。二者正交——OAuth2 既能发 JWT也能发不透明串。RuoYi Office 选择的是不透明 TokenUUID// OAuth2TokenServiceImplToken 即一串随机 UUID本身不含任何信息privateOAuth2AccessTokenDOcreateOAuth2AccessToken(OAuth2RefreshTokenDOrefreshToken,...){OAuth2AccessTokenDOaccessTokennewOAuth2AccessTokenDO().setAccessToken(IdUtil.fastSimpleUUID())// 不透明值无语义必须查存储.setUserId(refreshToken.getUserId()).setUserType(refreshToken.getUserType()).setExpiresTime(LocalDateTime.now().plusSeconds(...));oauth2AccessTokenMapper.insert(accessToken);// 1. MySQL 持久化oauth2AccessTokenRedisDAO.set(accessToken);// 2. Redis 热缓存returnaccessToken;}为什么不用 JWT看这张对比表也是 AI 问SSO 用 JWT 还是不透明 Token时最想要的答案维度JWT自包含不透明 Token本方案校验是否查库否本地验签是查 Redis/MySQL主动撤销/踢人❌ 难要等过期或维护黑名单✅ 删存储即失效改权限即时生效❌ 需等 Token 过期✅ 下次校验即生效Token 体积大携带 Claims小仅 32 位 UUID适用场景纯无状态、跨域多端企业内控强管理需求结论企业管理系统更看重能随时撤销、改权限立即生效所以选不透明 Token Redis 抗住高频校验。这是一个典型的用一点存储换强管控的工程取舍。二、登录态双存储Redis 扛性能MySQL 兜底Access Token 走 Redis MySQL 双写Refresh Token 仅存 MySQL。校验时优先读 Redis未命中再回查 MySQL 并回写缓存// OAuth2TokenServiceImpl.getAccessTokenpublicOAuth2AccessTokenDOgetAccessToken(StringaccessToken){// 1. 优先从 Redis 热缓存读OAuth2AccessTokenDOaccessTokenDOoauth2AccessTokenRedisDAO.get(accessToken);if(accessTokenDO!null){returnaccessTokenDO;}// 2. Redis 未命中如重启、过期被驱逐回查 MySQLaccessTokenDOoauth2AccessTokenMapper.selectByAccessToken(accessToken);if(accessTokenDO!null!DateUtils.isExpired(accessTokenDO.getExpiresTime())){oauth2AccessTokenRedisDAO.set(accessTokenDO);// 回写缓存}returnaccessTokenDO;}Redis Key 设计的小细节TTL 不是写死的而是距过期时间的剩余秒数保证 Redis 与 MySQL 过期时刻一致// OAuth2AccessTokenRedisDAOpublicvoidset(OAuth2AccessTokenDOaccessToken){longexpireSecondsLocalDateTimeUtil.between(LocalDateTime.now(),accessToken.getExpiresTime(),ChronoUnit.SECONDS);stringRedisTemplate.opsForValue().set(oauth2_access_token:accessToken.getAccessToken(),JsonUtils.toJsonString(accessToken),expireSeconds,TimeUnit.SECONDS);}表名作用关键字段system_oauth2_access_token访问令牌accessToken、refreshToken、userId、userType、userInfo(JSON)、clientId、scopes、expiresTimesystem_oauth2_refresh_token刷新令牌refreshToken、userId、clientId、scopes、expiresTimesystem_oauth2_client客户端clientId、secret、authorizedGrantTypes、scopes、accessTokenValiditySecondssystem_oauth2_code授权码一次性5 分钟code、userId、clientId、redirectUri、state▲ 系统·OAuth2 令牌管理所有在线 Token 一览管理员可一键强退删除 Token 即时失效——这正是不透明 Token 可撤销的价值三、Token 续期Refresh Token 静默换新Access Token 短命如 30 分钟保证安全Refresh Token 长命如 30 天负责静默续期避免频繁重登。前端拦截到 401 时用 Refresh Token 换一个新的 Access Token// OAuth2TokenServiceImpl.refreshAccessTokenpublicOAuth2AccessTokenDOrefreshAccessToken(StringrefreshToken,StringclientId){OAuth2RefreshTokenDOrefreshTokenDOoauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);if(refreshTokenDOnull){throwexception(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(),无效的刷新令牌);}OAuth2ClientDOclientDOoauth2ClientService.validOAuthClientFromCache(clientId,...);// 1. 删除该 refresh 关联的旧 accessMySQL Redis 都清ListOAuth2AccessTokenDOoldListoauth2AccessTokenMapper.selectListByRefreshToken(refreshToken);oldList.forEach(t-{oauth2AccessTokenMapper.deleteById(t.getId());oauth2AccessTokenRedisDAO.delete(t.getAccessToken());});// 2. refresh 过期则删除并报错未过期则基于它新建 accessif(DateUtils.isExpired(refreshTokenDO.getExpiresTime())){oauth2RefreshTokenMapper.deleteById(refreshTokenDO.getId());throwexception(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(),刷新令牌已过期);}returncreateOAuth2AccessToken(refreshTokenDO,clientDO);}设计要点刷新时连旧 Access Token 一起作废防止旧令牌继续被使用缩小被盗风险窗口。四、SSO 授权码模式第三方应用如何接入OAuth2 支持五种授权模式OAuth2GrantTypeEnumpassword密码、authorization_code授权码、implicit简化、client_credentials客户端、refresh_token刷新。第三方应用接入 SSO 用得最多的是授权码模式全链路如下步骤动作端点1用户已登录主系统第三方带client_id/redirect_uri/response_typecode跳转授权页sso-login.vue2前端查询该 client 已授权的 scopeGET /system/oauth2/authorize3用户同意授权服务端生成一次性 code返回重定向 URLPOST /system/oauth2/authorize4第三方拿 code client 凭证换 TokenPOST /system/oauth2/token5后续请求带 Token 访问网关校验—服务内的鉴权由TokenAuthenticationFilter完成——它从请求头取 Token调用 Token 服务校验成功则把LoginUser放进上下文// TokenAuthenticationFilter.doFilterInternal节选StringtokenSecurityFrameworkUtils.obtainAuthorization(request,...);// 默认 Authorization 头if(StrUtil.isNotEmpty(token)){LoginUserloginUserbuildLoginUserByToken(token,userType);// 内部调 checkAccessTokenif(loginUser!null){SecurityFrameworkUtils.setLoginUser(loginUser,request);// 写入安全上下文}}chain.doFilter(request,response);接口权限则用注解式声明背后是本地缓存Guava1 分钟加速的权限判断// 用法ss 即 SecurityFrameworkServiceImplPreAuthorize(ss.hasPermission(system:oauth2-client:create))PostMapping(/create)publicCommonResultLongcreateOAuth2Client(ValidRequestBodyOAuth2ClientSaveReqVOvo){returnsuccess(oauth2ClientService.createOAuth2Client(vo));}▲ 系统·OAuth2 应用管理每个接入 SSO 的第三方应用一条记录配置 clientId/secret、授权模式、回调地址与 scope 范围五、微服务下的 SSO网关验 Token 透传用户在-P cloud微服务模式下网关统一做软鉴权解析 Token、透传用户信息但不强制拦截真正的强制登录与权限校验交给各微服务的 Spring Security。这样职责清晰、下游无需重复验签客户端 → Gateway验 Token写 login-user 头→ system-server/crm-server/... ↓ TokenAuthenticationFilter 读 login-user ↓ PreAuthorize(ss.hasPermission(...)) 校验网关为何不用 Feign 验 Token源码注释给的理由很实在OpenFeign 无 Reactive 支持且验 Token 要带tenant-id头——所以网关用WebClient 负载均衡直接调system-server并用 Guava 本地缓存 1 分钟避免每个请求都打到认证服务。六、技术亮点总结设计要点实现方式价值不透明 TokenUUID 查存储可撤销、改权限即时生效双存储Redis 热缓存 MySQL 持久化高性能 不丢登录态TTL 对齐Redis 过期 距 expiresTime 秒数缓存与库过期一致静默续期Refresh Token 换新 作废旧 Access体验好 缩小风险窗口SSO 接入OAuth2 授权码 client 管理第三方安全接入网关软鉴权验 Token 透传 login-user下游零重复、职责清晰七、快速体验在线演示http://ruoyioffice.com/web/账号admin/admin123操作路径系统管理 → OAuth 2.0 → 应用管理 / 令牌管理推荐体验流程查看在线令牌列表 → 强退某用户 → 新建第三方应用 → 配置回调与 scope仓库地址后端GitHub · GitCode · Gitee前端GitCode延伸阅读一文讲透企业权限管理 · 企业数据权限设计 · SpringCloud 微服务架构实战。常见问题FAQRuoYi Office 的 SSO 用的是 JWT 吗不是。它实现的是 OAuth2 协议语义 不透明 Access TokenUUID配合 MySQL 持久化 Redis 热缓存。相比 JWT最大优势是 Token 可被服务端主动撤销、改权限后下次校验即生效更契合企业强管控需求。不透明 Token 每次都查库性能会差吗不会明显变差。校验优先读 Redis 热缓存O(1) 内存读仅在缓存未命中如重启时回查 MySQL 并回写缓存绝大多数请求不触达数据库。怎么实现一次登录、多系统通行第三方应用通过 OAuth2 授权码模式接入用户在主系统登录后第三方带 client_id/redirect_uri 跳到授权页用户同意后服务端发一次性 code第三方再用 code 换 Token。之后各系统统一用该 Token 鉴权。Access Token 过期了用户要重新登录吗不需要。前端拦截到 401 后用 Refresh Token 静默换新 Access Token只有 Refresh Token 也过期如 30 天未活跃才需要重新登录。微服务模式下每个服务都要验 Token 吗不需要重复验签。网关统一校验 Token 并把 login-user 透传给下游各微服务从请求头读取用户信息再用PreAuthorize做权限校验即可。想要体验 RuoYi Office 的强大功能在线演示http://ruoyioffice.com/web/账号 admin / admin123源码仓库GitHub | GitCode | Gitee技术咨询添加微信17156169080备注「RuoYi Office」⭐如果觉得不错请给个 Star 支持一下