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 已授权的 scope | GET /system/oauth2/authorize |
| 3 | 用户同意授权,服务端生成一次性 code,返回重定向 URL | POST /system/oauth2/authorize |
| 4 | 第三方拿 code + client 凭证换 Token | POST /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 分钟,避免每个请求都打到认证服务。
六、技术亮点总结
| 设计要点 | 实现方式 | 价值 |
|---|---|---|
| 不透明 Token | UUID + 查存储 | 可撤销、改权限即时生效 |
| 双存储 | 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 支持一下!
