OAuth 2.0(SSO 单点登录)
RuoYi Office 内置 OAuth 2.0 授权服务,可以把平台作为企业内部统一认证中心,为门户、报表、大屏、客户自建系统等第三方 Web 应用提供 SSO 单点登录。当前推荐优先使用授权码模式:用户只在 RuoYi Office 登录与确认授权,第三方应用的后端拿 code 换取 access_token,再用 Bearer Token 访问开放接口。
本文基于当前仓库实现重写,重点覆盖授权码模式的接入流程;密码模式、客户端凭证模式和刷新令牌模式作为补充能力使用。
为什么优先选授权码模式
OAuth2 授权模式的选择,核心看两个问题:访问方是不是用户本人、客户端是否有可靠后端保存密钥。第三方 Web 系统做 SSO 时,浏览器侧不应该保存 client_secret,因此推荐走授权码模式,让第三方后端完成换 token。

上图可以作为模式选择速查:如果是第三方系统接入、并且有服务端,就走授权码模式;如果只是平台内部前端登录,不需要额外 SSO 客户端;如果是服务之间机器调用,再考虑客户端凭证模式。

RuoYi Office 当前保留了 authorization_code、password、client_credentials、refresh_token 等模式。本文的主线是授权码 SSO,原因是它最适合企业门户、报表、大屏等“用户从第三方系统跳到统一认证中心登录”的场景。
本地实现位置
| 层 | 位置 |
|---|---|
| OAuth2 客户端页面 | ruoyi-office-vben/apps/web-antd/src/views/system/oauth2/client |
| 访问令牌页面 | ruoyi-office-vben/apps/web-antd/src/views/system/oauth2/token |
| SSO 授权页 | ruoyi-office-vben/apps/web-antd/src/views/_core/authentication/sso-login.vue |
| 前端开放 API | ruoyi-office-vben/apps/web-antd/src/api/system/oauth2/open/index.ts |
| 前端路由 | ruoyi-office-vben/apps/web-antd/src/router/routes/core.ts |
| OAuth2 开放 Controller | OAuth2OpenController.java |
| OAuth2 用户 Controller | OAuth2UserController.java |
| 客户端校验 | OAuth2ClientServiceImpl.java |
| 授权码与令牌 | OAuth2CodeServiceImpl.java、OAuth2GrantServiceImpl.java、OAuth2TokenServiceImpl.java |
实现架构

在 RuoYi Office 里,授权中心由 PC 管理端前端、System 后端模块和 Token/Scope 校验链路共同组成:
- 第三方系统负责发起授权跳转、接收
code、保存自己的登录态。 web-antd的/auth/sso-login负责展示授权页和提交用户选择。- 后端
OAuth2OpenController负责发放授权码、换取令牌和校验令牌。 - 后续开放接口通过 Bearer Token 识别用户,再用 scope 控制访问范围。
授权码 SSO 总流程

RuoYi Office 的前后端分离实现和传统 OAuth2 服务端 302 有一点差异:POST /system/oauth2/authorize 返回的是重定向 URL 字符串,sso-login.vue 再执行 location.href = data。这样可以让 Axios 正常接收统一 CommonResult<String>,也便于前端处理自动授权和手动授权两种场景。
第一步:创建 OAuth2 客户端
在管理端进入 系统管理 -> OAuth 2.0 -> 应用管理,新增一个客户端。授权码 SSO 建议先按下面配置:
| 字段 | 示例 | 说明 |
|---|---|---|
| 客户端编号 | ruoyioffice-sso-demo | 第三方应用唯一标识,对应请求里的 client_id |
| 客户端密钥 | change-me-in-prod | 只保存在第三方应用后端,用于 Basic Authorization |
| 应用名 | RuoYi Office SSO 示例 | 授权页展示名称 |
| 应用图标 | 上传本地图标 | 授权页展示图标 |
| 状态 | 开启 | 禁用后所有授权与换 token 都会失败 |
| 访问令牌有效期 | 7200 | 单位秒,按安全等级调整 |
| 刷新令牌有效期 | 2592000 | 单位秒,常见为 30 天 |
| 授权类型 | authorization_code、refresh_token | Web SSO 至少需要授权码模式;需要续期时加刷新令牌 |
| 授权范围 | user.read、user.write | 只读用户资料可只给 user.read |
| 自动授权范围 | user.read | 可选,历史已授权或自动授权时可跳过确认页 |
| 可重定向的 URI 地址 | http://127.0.0.1:18080/callback | 必须匹配第三方回调地址前缀 |

这张图展示的是客户端配置最容易漏的几个点:授权类型必须包含 authorization_code,需要续期时再加 refresh_token;授权范围至少包含后续要访问接口需要的 scope;回调 URI 要和第三方系统实际 callback 地址保持一致。迁移到 RuoYi Office 时,客户端编号和密钥可以沿用企业自己的命名,不需要使用图里的示例值。
redirectUris 的校验使用前缀匹配:OAuth2ClientServiceImpl#validOAuthClientFromCache 会判断 redirect_uri 是否以客户端配置中的任一地址开头。因此生产环境建议把回调地址配置到具体路径或稳定前缀,不要配置过宽的域名根路径。
第二步:第三方应用跳转授权页
第三方应用未登录时,把用户浏览器跳转到 RuoYi Office 前端的 SSO 授权页:
http://127.0.0.1:5666/auth/sso-login
?response_type=code
&client_id=ruoyioffice-sso-demo
&redirect_uri=http%3A%2F%2F127.0.0.1%3A18080%2Fcallback
&scope=user.read%20user.write
&state=from-portal-001参数说明:
| 参数 | 是否必填 | 说明 |
|---|---|---|
response_type | 是 | 授权码模式固定传 code |
client_id | 是 | OAuth2 客户端编号 |
redirect_uri | 是 | 授权完成后的回调地址,必须在客户端白名单内 |
scope | 建议传 | 多个 scope 用空格分隔,例如 user.read user.write |
state | 强烈建议传 | 第三方应用生成随机值,回调后校验,防止 CSRF 和串单 |
如果用户尚未登录 RuoYi Office,会先进入普通登录页;登录成功后再回到 /auth/sso-login 继续授权。这一点由前端认证路由和请求拦截器共同完成,接入方无需自己处理平台登录态。

第三方系统的未登录页只需要提供一个“跳转统一登录”的入口。真实项目里通常不是一个演示按钮,而是在访问受保护页面时由后端或前端路由守卫自动拼接授权 URL 并跳转。
第三步:用户确认授权
sso-login.vue 会做三件事:
- 解析 URL 上的
client_id、redirect_uri、response_type、scope、state。 - 调用
GET /system/oauth2/authorize?clientId=...获取客户端名称、图标和历史授权 scope。 - 调用
POST /system/oauth2/authorize提交用户勾选结果。
如果传入的 scope 已经全部满足自动授权条件,前端会先以 auto_approve=true 尝试提交授权;后端返回重定向 URL 后会直接跳回第三方应用。否则页面展示复选框,目前内置文案为:
| scope | 授权页文案 | 后端用途 |
|---|---|---|
user.read | 访问你的个人信息 | 允许访问 GET /system/oauth2/user/get |
user.write | 修改你的个人信息 | 允许访问 PUT /system/oauth2/user/update |

确认授权页是用户能感知到的安全边界。图里的两个勾选项分别对应 user.read 和 user.write;如果业务只需要登录后读取用户身份,建议只申请 user.read,降低用户授权和后续审计压力。
更多 scope 可以在 sso-login.vue#formatScope 中扩展展示文案;真正的接口权限建议在后端通过 @PreAuthorize("@ss.hasScope('xxx')") 控制。
第四步:第三方回调接收 code
用户同意授权后,RuoYi Office 后端会创建授权码,并拼接回调地址:
http://127.0.0.1:18080/callback?code=24b35f...&state=from-portal-001
图中回调页 URL 上出现 code,说明 RuoYi Office 授权端已经完成用户确认并把浏览器跳回第三方系统。接下来应由第三方系统后端换 token,不要让前端直接携带 client_secret 调用 /token。
授权码由 OAuth2CodeServiceImpl 生成,默认 5 分钟有效,并且在换取访问令牌时会被删除。第三方应用需要在回调页完成两件事:
- 校验回调里的
state是否等于自己发起授权前保存的值。 - 把
code发送给第三方应用自己的后端,由后端换取 token。
不要在浏览器里保存或使用 client_secret。授权码模式的安全边界是“浏览器拿 code,后端拿 secret 换 token”。
第五步:后端使用 code 换 token
第三方应用后端调用 RuoYi Office 后端:
POST /admin-api/system/oauth2/token HTTP/1.1
Host: 127.0.0.1:48080
Authorization: Basic base64(ruoyioffice-sso-demo:change-me-in-prod)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=24b35f...&
redirect_uri=http%3A%2F%2F127.0.0.1%3A18080%2Fcallback&
state=from-portal-001成功响应的 data 中会包含:
| 字段 | 说明 |
|---|---|
access_token | 访问令牌,访问受保护接口时放到 Authorization: Bearer xxx |
refresh_token | 刷新令牌,用于访问令牌过期后的续期 |
token_type | 固定为 bearer |
expires_in | 访问令牌剩余有效秒数 |
scope | 本次最终授权范围 |
OAuth2GrantServiceImpl#grantAuthorizationCodeForAccessToken 会校验三组关键数据:clientId 必须和授权码记录一致、redirect_uri 必须和申请 code 时一致、state 必须一致。任一不一致都会拒绝换 token。
第六步:读取当前用户信息
第三方后端拿到 access_token 后,可以请求 OAuth2 用户接口:
GET /admin-api/system/oauth2/user/get HTTP/1.1
Host: 127.0.0.1:48080
Authorization: Bearer 24c1...该接口由 OAuth2UserController#getUserInfo 提供,需要 user.read scope。返回内容包含后台用户基础信息,并会附带部门和岗位:
| 信息 | 来源 |
|---|---|
| 用户基础资料 | AdminUserService#getUser |
| 部门 | DeptService#getDept |
| 岗位 | PostService#getPostList |

登录后,第三方系统通常会把 RuoYi Office 返回的用户资料映射到自己的本地会话,例如昵称、部门、岗位或员工编号。图中“修改昵称、刷新令牌、退出登录”等操作分别对应 user.write、refresh_token 和撤销 token 能力。
如需让第三方应用修改用户昵称、头像等资料,可以授权 user.write,然后调用 PUT /system/oauth2/user/update。生产场景更建议把写权限拆细,按业务接口单独定义 scope。
第七步:刷新与退出
如果客户端启用了 refresh_token 授权类型,可以在访问令牌过期后刷新:
POST /admin-api/system/oauth2/token HTTP/1.1
Authorization: Basic base64(ruoyioffice-sso-demo:change-me-in-prod)
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=8d7b...退出登录或解除授权时,第三方应用后端可以撤销令牌:
DELETE /admin-api/system/oauth2/token?token=24c1... HTTP/1.1
Authorization: Basic base64(ruoyioffice-sso-demo:change-me-in-prod)管理端 系统管理 -> OAuth 2.0 -> 访问令牌 页面也可以按用户编号、用户类型和客户端编号筛选令牌,并删除单个或批量删除令牌,用于强制下线和应急处置。
开放接口清单
| 接口 | 调用方 | 用途 |
|---|---|---|
GET /system/oauth2/authorize | RuoYi Office 前端 | 获取授权页所需客户端与历史授权信息 |
POST /system/oauth2/authorize | RuoYi Office 前端 | 提交授权确认,返回重定向 URL |
POST /system/oauth2/token | 第三方应用后端 | 换取或刷新访问令牌,需要 Basic Authorization |
DELETE /system/oauth2/token | 第三方应用后端 | 撤销访问令牌,需要 Basic Authorization |
POST /system/oauth2/check-token | 第三方应用后端 | 校验访问令牌,需要 Basic Authorization |
GET /system/oauth2/user/get | 第三方应用后端 | 获取当前用户信息,需要 Bearer Token 和 user.read |
PUT /system/oauth2/user/update | 第三方应用后端 | 更新当前用户资料,需要 Bearer Token 和 user.write |
表结构与持久化
| 表 | 说明 |
|---|---|
system_oauth2_client | 客户端配置,包含密钥、授权模式、scope、回调地址和令牌有效期 |
system_oauth2_code | 授权码记录,默认 5 分钟有效,换 token 时一次性消费 |
system_oauth2_approve | 用户对客户端 scope 的授权记录,用于自动授权判断 |
system_oauth2_access_token | 访问令牌记录,Redis 会缓存有效 token |
system_oauth2_refresh_token | 刷新令牌记录,刷新 access token 时复用 |

表结构里最重要的关系是:客户端定义允许的授权模式和 scope,授权码记录一次性登录过程,授权记录支撑自动授权,访问令牌和刷新令牌支撑后续接口访问与续期。排查 SSO 问题时,通常按 client -> code -> access_token -> refresh_token 的顺序追。
多租户场景下,OAuth2TokenServiceImpl 会把租户编号写入令牌记录。第三方应用如果跨租户接入,需要明确 tenant-id 传递、客户端归属和用户登录入口,避免不同租户下的授权混用。
生产接入建议
- 只在服务端保存密钥:
client_secret不要出现在浏览器、移动端包体或公开配置里。 - 回调地址尽量精确:
redirectUris使用具体域名和路径,减少开放重定向风险。 - state 必须校验:第三方应用应为每次授权生成随机
state,回调时核对并销毁。 - scope 最小化:只读登录场景优先只授予
user.read;写接口必须单独评估。 - 令牌短、刷新长:访问令牌设置较短有效期,刷新令牌按用户体验和风险配置。
- 异常时可强制撤销:客户端泄露、员工离职或发现异常访问时,先禁用客户端或删除访问令牌。
- 对外 API 单独收敛:如果第三方系统要访问更多业务数据,建议新建 open API,并用独立 scope 控制,不要直接暴露管理端高权限接口。
排查清单
| 现象 | 排查方向 |
|---|---|
| 跳到登录页而不是授权页 | 用户在 RuoYi Office 未登录,先登录后会继续回到 /auth/sso-login |
| 授权页不显示应用信息 | 检查 client_id 是否存在、客户端状态是否开启 |
| 点击同意后没有跳转 | 检查 response_type 是否为 code,redirect_uri 是否匹配客户端白名单 |
| token 接口提示客户端无效 | 检查 Basic Authorization 是否为 base64(clientId:secret),密钥是否正确 |
| token 接口提示授权类型无效 | 客户端 authorizedGrantTypes 是否包含 authorization_code 或 refresh_token |
| code 换 token 失败 | 检查 code 是否超过 5 分钟、是否已使用、redirect_uri 和 state 是否与申请时一致 |
| 读取用户信息提示无权限 | 检查本次授权 scope 是否包含 user.read,接口是否带 Bearer Token |
| refresh token 失败 | 检查客户端是否允许 refresh_token,刷新令牌是否过期或客户端编号是否一致 |
| 多租户下拿不到用户 | 检查登录时的租户、令牌记录的 tenantId 和请求头租户是否一致 |
