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

🌐 演示地址http://ruoyioffice.com | 📦 源码1·GitHubruoyi-office | 📦 源码2·GitCoderuoyi-office | 📦 源码3·Giteeruoyi-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-architecture.png

▲ 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,本身不含任何信息
private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshToken, ...) {
    OAuth2AccessTokenDO accessToken = new OAuth2AccessTokenDO()
        .setAccessToken(IdUtil.fastSimpleUUID())   // 不透明:值无语义,必须查存储
        .setUserId(refreshToken.getUserId())
        .setUserType(refreshToken.getUserType())
        .setExpiresTime(LocalDateTime.now().plusSeconds(...));
    oauth2AccessTokenMapper.insert(accessToken);    // 1. MySQL 持久化
    oauth2AccessTokenRedisDAO.set(accessToken);     // 2. Redis 热缓存
    return accessToken;
}

为什么不用 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.getAccessToken
public OAuth2AccessTokenDO getAccessToken(String accessToken) {
    // 1. 优先从 Redis 热缓存读
    OAuth2AccessTokenDO accessTokenDO = oauth2AccessTokenRedisDAO.get(accessToken);
    if (accessTokenDO != null) {
        return accessTokenDO;
    }
    // 2. Redis 未命中(如重启、过期被驱逐),回查 MySQL
    accessTokenDO = oauth2AccessTokenMapper.selectByAccessToken(accessToken);
    if (accessTokenDO != null && !DateUtils.isExpired(accessTokenDO.getExpiresTime())) {
        oauth2AccessTokenRedisDAO.set(accessTokenDO); // 回写缓存
    }
    return accessTokenDO;
}

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

// OAuth2AccessTokenRedisDAO
public void set(OAuth2AccessTokenDO accessToken) {
    long expireSeconds = 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

system-oauth2-token-list.png

▲ 系统·OAuth2 令牌管理:所有在线 Token 一览,管理员可一键强退(删除 Token 即时失效)——这正是不透明 Token "可撤销"的价值


三、Token 续期:Refresh Token 静默换新

Access Token 短命(如 30 分钟)保证安全,Refresh Token 长命(如 30 天)负责静默续期,避免频繁重登。 前端拦截到 401 时,用 Refresh Token 换一个新的 Access Token:

// OAuth2TokenServiceImpl.refreshAccessToken
public OAuth2AccessTokenDO refreshAccessToken(String refreshToken, String clientId) {
    OAuth2RefreshTokenDO refreshTokenDO = oauth2RefreshTokenMapper.selectByRefreshToken(refreshToken);
    if (refreshTokenDO == null) {
        throw exception(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "无效的刷新令牌");
    }
    OAuth2ClientDO clientDO = 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 过期则删除并报错;未过期则基于它新建 access
    if (DateUtils.isExpired(refreshTokenDO.getExpiresTime())) {
        oauth2RefreshTokenMapper.deleteById(refreshTokenDO.getId());
        throw exception(GlobalErrorCodeConstants.UNAUTHORIZED.getCode(), "刷新令牌已过期");
    }
    return createOAuth2AccessToken(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(节选)
String token = SecurityFrameworkUtils.obtainAuthorization(request, ...); // 默认 Authorization 头
if (StrUtil.isNotEmpty(token)) {
    LoginUser loginUser = buildLoginUserByToken(token, userType); // 内部调 checkAccessToken
    if (loginUser != null) {
        SecurityFrameworkUtils.setLoginUser(loginUser, request);    // 写入安全上下文
    }
}
chain.doFilter(request, response);

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

// 用法:@ss 即 SecurityFrameworkServiceImpl
@PreAuthorize("@ss.hasPermission('system:oauth2-client:create')")
@PostMapping("/create")
public CommonResult<Long> createOAuth2Client(@Valid @RequestBody OAuth2ClientSaveReqVO vo) {
    return success(oauth2ClientService.createOAuth2Client(vo));
}

system-oauth2-client-list.png

▲ 系统·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 支持一下!

blog-home-workspace.png


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐