OAuth2 + JWT 企业单点登录(SSO)实战:多系统一次登录全打通(SpringBoot)

OAuth2 + JWT 企业单点登录(SSO)实战:多系统一次登录全打通(SpringBoot)

🌐演示地址:http://ruoyioffice.com | 📦源码1·GitHub:ruoyi-office | 📦源码2·GitCode:ruoyi-office | 📦源码3·Gitee:ruoyi-office | 💬微信:17156169080(备注「RuoYi Office」)

企业一旦上了 OA、CRM、ERP、报表等多套系统,"每个系统一套账号密码"就成了灾难:员工记不住、IT 管不过来、离职账号清不干净。单点登录(SSO,Single Sign-On)即一次登录、多系统通行。但真正落地时绕不开几个硬问题:Token 用 JWT 还是不透明串?登录态存哪?怎么撤销?怎么续期?本文基于 RuoYi Office 真实源码,拆解一套「Spring Security 无 Session + OAuth2 + 不透明 Token(UUID)+ Redis/MySQL 双存储」的企业级 SSO 方案。

▲ OAuth2 SSO 认证全景:登录发 Token(UUID)→ 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 是授权框架,定义"如何颁发和使用 Token";JWT 是一种自包含 Token 格式。二者正交——OAuth2 既能发 JWT,也能发不透明串。RuoYi Office 选择的是不透明 Token(UUID)

// OAuth2TokenServiceImpl:Token 即一串随机 UUID,本身不含任何信息privateOAuth2AccessTokenDOcreateOAuth2AccessToken(OAuth2RefreshTokenDOrefreshToken,...){OAuth2AccessTokenDOaccessToken=newOAuth2AccessTokenDO().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 热缓存读OAuth2AccessTokenDOaccessTokenDO=oauth2AccessTokenRedisDAO.get(accessToken);if(accessTokenDO!=null){returnaccessTokenDO;}// 2. Redis 未命中(如重启、过期被驱逐),回查 MySQLaccessTokenDO=oauth2AccessTokenMapper.selectByAccessToken(accessToken);if(accessTokenDO!=null&&!DateUtils.isExpired(accessTokenDO.getExpiresTime())){oauth2AccessTokenRedisDAO.set(accessTokenDO);// 回写缓存}returnaccessTokenDO;}

Redis Key 设计的小细节:TTL 不是写死的,而是"距过期时间的剩余秒数",保证 Redis 与 MySQL 过期时刻一致:

// OAuth2AccessTokenRedisDAOpublicvoidset(OAuth2AccessTokenDOaccessToken){longexpireSeconds=LocalDateTimeUtil.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、expiresTime
system_oauth2_refresh_token刷新令牌refreshToken、userId、clientId、scopes、expiresTime
system_oauth2_client客户端clientId、secret、authorizedGrantTypes、scopes、accessTokenValiditySeconds
system_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){OAuth2RefreshTokenDOrefreshTokenDO=oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);if(refreshTokenDO==null){throwexception(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(),"无效的刷新令牌");}OAuth2ClientDOclientDO=oauth2ClientService.validOAuthClientFromCache(clientId,...);// 1. 删除该 refresh 关联的旧 access(MySQL + Redis 都清)List<OAuth2AccessTokenDO>oldList=oauth2AccessTokenMapper.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 支持五种授权模式(OAuth2GrantTypeEnum):password(密码)、authorization_code(授权码)、implicit(简化)、client_credentials(客户端)、refresh_token(刷新)。第三方应用接入 SSO 用得最多的是授权码模式,全链路如下:

步骤动作端点
1用户已登录主系统,第三方带client_id/redirect_uri/response_type=code跳转授权页sso-login.vue
2前端查询该 client 已授权的 scopeGET /system/oauth2/authorize
3用户同意授权,服务端生成一次性 code,返回重定向 URLPOST /system/oauth2/authorize
4第三方拿 code + client 凭证换 TokenPOST /system/oauth2/token
5后续请求带 Token 访问,网关校验

服务内的鉴权由TokenAuthenticationFilter完成——它从请求头取 Token,调用 Token 服务校验,成功则把LoginUser放进上下文:

// TokenAuthenticationFilter.doFilterInternal(节选)Stringtoken=SecurityFrameworkUtils.obtainAuthorization(request,...);// 默认 Authorization 头if(StrUtil.isNotEmpty(token)){LoginUserloginUser=buildLoginUserByToken(token,userType);// 内部调 checkAccessTokenif(loginUser!=null){SecurityFrameworkUtils.setLoginUser(loginUser,request);// 写入安全上下文}}chain.doFilter(request,response);

接口权限则用注解式声明,背后是本地缓存(Guava,1 分钟)加速的权限判断:

// 用法:@ss 即 SecurityFrameworkServiceImpl@PreAuthorize("@ss.hasPermission('system:oauth2-client:create')")@PostMapping("/create")publicCommonResult<Long>createOAuth2Client(@Valid@RequestBodyOAuth2ClientSaveReqVOvo){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 微服务架构实战。


常见问题(FAQ)

RuoYi Office 的 SSO 用的是 JWT 吗?

不是。它实现的是 OAuth2 协议语义 +不透明 Access Token(UUID),配合 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 支持一下!