企业微信单点登录(SSO)说明
本文档说明 RuoYi-Cloud 前端 login_sso.vue 及配套后端如何实现企业微信 OAuth 授权码单点登录,与账号密码登录的差异,以及接入与排查要点。
一、总体说明
| 项目 | 说明 |
|---|---|
| SSO 类型 | 企业微信网页授权(OAuth2 授权码 code) |
| 前端入口 | /loginSso?code={授权码} |
| 后端接口 | POST /auth/loginSso,请求体 { "code": "..." } |
| 用户匹配 | 企业微信 userid ↔ 本地 sys_user.qywx_id |
| 鉴权结果 | 与普通登录相同,返回 JWT(access_token),写入 Cookie Admin-Token |
与普通登录的区别:
- 不需要用户名、密码、图形验证码。
- 网关
ValidateCodeFilter对/auth/loginSso直接放行,不做验证码校验。 - 后端通过企业微信 API 用
code换取用户身份,再按qywx_id查本地用户(不校验密码)。
典型使用场景: 用户在企业微信客户端或配置了可信域名的浏览器中打开应用,企业微信 OAuth 回调到 /loginSso?code=xxx,页面自动完成登录。
二、端到端流程
ruoyi-system Redis 企业微信 API ruoyi-auth ruoyi-gateway 前端 login_sso.vue 企业微信 ruoyi-system Redis 企业微信 API ruoyi-auth ruoyi-gateway 前端 login_sso.vue 企业微信 #mermaid-svg-e0TYSyKnt2hQImbq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-e0TYSyKnt2hQImbq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-e0TYSyKnt2hQImbq .error-icon{fill:#552222;}#mermaid-svg-e0TYSyKnt2hQImbq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-e0TYSyKnt2hQImbq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-e0TYSyKnt2hQImbq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-e0TYSyKnt2hQImbq .marker.cross{stroke:#333333;}#mermaid-svg-e0TYSyKnt2hQImbq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-e0TYSyKnt2hQImbq p{margin:0;}#mermaid-svg-e0TYSyKnt2hQImbq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-e0TYSyKnt2hQImbq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-e0TYSyKnt2hQImbq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-e0TYSyKnt2hQImbq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-e0TYSyKnt2hQImbq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-e0TYSyKnt2hQImbq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-e0TYSyKnt2hQImbq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-e0TYSyKnt2hQImbq .sequenceNumber{fill:white;}#mermaid-svg-e0TYSyKnt2hQImbq #sequencenumber{fill:#333;}#mermaid-svg-e0TYSyKnt2hQImbq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-e0TYSyKnt2hQImbq .messageText{fill:#333;stroke:none;}#mermaid-svg-e0TYSyKnt2hQImbq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-e0TYSyKnt2hQImbq .labelText,#mermaid-svg-e0TYSyKnt2hQImbq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-e0TYSyKnt2hQImbq .loopText,#mermaid-svg-e0TYSyKnt2hQImbq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-e0TYSyKnt2hQImbq .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-e0TYSyKnt2hQImbq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-e0TYSyKnt2hQImbq .noteText,#mermaid-svg-e0TYSyKnt2hQImbq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-e0TYSyKnt2hQImbq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-e0TYSyKnt2hQImbq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-e0TYSyKnt2hQImbq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-e0TYSyKnt2hQImbq .actorPopupMenu{position:absolute;}#mermaid-svg-e0TYSyKnt2hQImbq .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-e0TYSyKnt2hQImbq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-e0TYSyKnt2hQImbq .actor-man circle,#mermaid-svg-e0TYSyKnt2hQImbq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-e0TYSyKnt2hQImbq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} altToken 不存在 altcode 为空code 存在 302 跳转 /loginSso?code=xxxcreated() 调用 loginSso()跳转 /login(普通登录)POST /auth/loginSso { code }跳过验证码,转发请求读取 qywxTokenGET gettoken(corpid, corpsecret)access_token缓存 qywxTokenGET auth/getuserinfo(code)userid, user_ticketPOST auth/getuserdetail(user_ticket)userid, mobile 等Feign getByQywxId(userid)LoginUser(角色、权限)tokenService.createToken(){ access_token, expires_in }setToken + 跳转 / 或 redirect路由守卫 GetInfo + GenerateRoutes
三、前端逻辑
3.1 路由与白名单
| 配置 | 位置 | 作用 |
|---|---|---|
路由 /loginSso |
ruoyi-ui/src/router/index.js |
加载 login_sso.vue,hidden: true |
| 免登录白名单 | ruoyi-ui/src/permission.js |
['/login', '/register', '/loginSso'],无 Token 也可访问 |
| 聊天嵌入排除 | ruoyi-ui/src/App.vue |
SSO 页不加载第三方客服嵌入脚本 |
3.2 login_sso.vue 核心流程
页面无 UI(空模板),在 created() 生命周期立即执行 SSO:
- 从 URL 读取
this.$route.query.code。 - 无 code :
$router.push('/login'),走普通登录。 - 有 code :
this.$store.dispatch('LoginSso', { code })- 成功:
$router.push(this.redirect || '/') - 失败:
console.log后跳转/login
注释中提及「请通过企业微信客户端打开」;当前实现是无 code 时静默转普通登录页,未弹错误提示。
3.3 Vuex 与 API
Store (ruoyi-ui/src/store/modules/user.js → LoginSso):
- 调用
loginSso(code)API。 - 将返回的
access_token、expires_in写入 Cookie 与 Vuex(与普通Login一致)。
API (ruoyi-ui/src/api/login.js):
javascript
POST /auth/loginSso
headers: { isToken: false }
body: { code }
3.4 登录后的路由守卫
SSO 只负责拿到 Token。后续与普通登录相同(permission.js):
- 检测到 Cookie 中有 Token。
- 若尚未加载用户信息:
GetInfo→GenerateRoutes→ 动态注册路由。 - 再进入目标页(默认首页
/)。
四、后端逻辑
4.1 入口:TokenController.loginSso
文件:ruoyi-auth/src/main/java/com/ruoyi/auth/controller/TokenController.java
步骤:
| 步骤 | 动作 | 企业微信 API |
|---|---|---|
| 1 | 从 Redis 读 qywxToken;若无则请求 |
GET /cgi-bin/gettoken?corpid=&corpsecret= |
| 2 | 用前端传入的 code 换用户票据 |
GET /cgi-bin/auth/getuserinfo?access_token=&code= |
| 3 | 用 user_ticket 拉取用户详情 |
POST /cgi-bin/auth/getuserdetail |
| 4 | 以 qywxUser.getUserid() 做本地登录 |
内部 sysLoginService.loginNoCaptcha(...) |
| 5 | 签发 JWT | tokenService.createToken(userInfo) |
失败时返回 R.fail(...),例如:
- 「系统未获取到 token,登录异常」
- 「系统未获取到人员票据登录异常」
- 「系统未获取到企业微信人员信息,登录异常」
4.2 本地用户校验:SysLoginService.loginNoCaptcha
文件:ruoyi-auth/src/main/java/com/ruoyi/auth/service/SysLoginService.java
与普通 login 相比去掉验证码和密码校验,保留:
- IP 黑名单检查
- 通过 Feign 调用
remoteUserService.getByQywxId(username, INNER) - 用户删除/停用状态校验
- 登录成功日志
4.3 用户查询:SysUserController.getByQywxId
文件:ruoyi-modules/ruoyi-system/.../SysUserController.java
- 内部接口:
GET /user/info/getByQywxId/{qywxId}(@InnerAuth) - SQL:
where u.qywx_id = #{qywxId}(SysUserMapper.xml) - 查不到用户:
R.fail("未查询到指定账户,请联系管理员绑定手机号") - 查到后组装
LoginUser(用户、角色、permissions,含ROLE_ACTIVITI_USER)
4.4 网关验证码过滤
文件:ruoyi-gateway/.../ValidateCodeFilter.java
/auth/login、/auth/register需要验证码(若开启)。/auth/loginSso显式跳过 ,直接chain.filter(exchange)。
五、配置项
5.1 企业微信应用配置
文件:ruoyi-auth/src/main/resources/bootstrap.yml(生产环境通常在 Nacos 共享配置中覆盖)
yaml
qywx:
corp-id: # 企业 ID
application-list:
- secret: <应用 Secret>
agentId:
application-name:
application-desc:
url: https://qyapi.weixin.qq.com
corp-id+application-list[0].secret用于获取access_token。agentId用于企业微信管理后台配置「网页授权回调域 / 应用主页」等(需与前端部署域名一致)。
5.2 企业微信管理后台(需运维配置)
- 在对应自建应用下配置可信域名 与OAuth 回调地址。
- 回调地址应指向前端:
https://{域名}/loginSso(企业微信会在其后追加?code=)。 - 用户须从企业微信内打开或满足 OAuth 环境,否则拿不到有效
code。
5.3 本地用户绑定
sys_user 表需维护 qywx_id 字段,值与企业微信成员 userid 一致。未绑定则 SSO 失败,需管理员在系统中维护后再试。
六、涉及文件索引
| 层级 | 文件 | 职责 |
|---|---|---|
| 前端页面 | ruoyi-ui/src/views/login_sso.vue |
解析 code、触发 SSO、跳转 |
| 前端路由 | ruoyi-ui/src/router/index.js |
注册 /loginSso |
| 前端守卫 | ruoyi-ui/src/permission.js |
SSO 页白名单、登录后拉用户信息 |
| 前端状态 | ruoyi-ui/src/store/modules/user.js |
LoginSso action |
| 前端 API | ruoyi-ui/src/api/login.js |
loginSso() |
| 前端 Token | ruoyi-ui/src/utils/auth.js |
Cookie Admin-Token |
| 认证服务 | ruoyi-auth/.../TokenController.java |
loginSso 接口 |
| 登录服务 | ruoyi-auth/.../SysLoginService.java |
loginNoCaptcha |
| 用户服务 | ruoyi-system/.../SysUserController.java |
getByQywxId |
| 用户 Mapper | ruoyi-system/.../SysUserMapper.xml |
selectUserByQywxId |
| 网关 | ruoyi-gateway/.../ValidateCodeFilter.java |
SSO 跳过验证码 |
| 领域模型 | ruoyi-auth/.../QywxLoginInfo.java, QywxUser.java |
企业微信响应映射 |
七、异常与排查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
直接跳到 /login |
URL 无 code |
是否从企业微信授权入口进入;回调 URL 是否配置为 /loginSso |
| 「未查询到指定账户...」 | sys_user.qywx_id 未绑定 |
核对企业微信 userid 与库中 qywx_id |
| 「系统未获取到 token」 | corp-id/secret 错误或网络不通 | 检查 bootstrap.yml/Nacos;手动调 gettoken |
| 「人员票据/login 异常」 | code 过期、重复使用或非 OAuth 环境 |
重新从企业微信入口打开;code 一次性有效 |
| 有 Token 但菜单空白 | GetInfo 失败或角色未配置 |
看 /system/user/getInfo 与角色菜单权限 |
| IP 黑名单 | loginNoCaptcha 中 IP 校验 |
检查系统登录 IP 黑名单配置 |
八、实现注意点(维护参考)
- Redis 缓存 TTL :
TokenController中setCacheObject("qywxToken", accessToken, 7200L, TimeUnit.MICROSECONDS)使用的是微秒单位,7200 微秒远小于企业微信 token 有效期(通常 7200 秒)。若线上频繁请求 gettoken,应改为TimeUnit.SECONDS并与QywxAccessTokenService(system 模块,约 7000 秒)对齐。 - 企业微信 API 错误码 :当前主要判断返回对象是否为
null,未统一校验errcode。生产环境建议在接口层解析errcode != 0并记录errmsg。 login_sso.vue中redirect:data 里定义了redirect但未从 query 赋值;若需 SSO 后回到原页面,可参考login.vue从this.$route.query.redirect读取。- 敏感配置 :
corp-id、secret不应提交到公开仓库;生产应放在 Nacos 等配置中心。
九、与普通登录对比简表
| 项目 | 普通登录 /login |
SSO /loginSso |
|---|---|---|
| 凭证 | 用户名 + 密码 + 验证码 | 企业微信 OAuth code |
| 接口 | POST /auth/login |
POST /auth/loginSso |
| 验证码 | 网关校验 | 跳过 |
| 用户识别 | 用户名 + 密码 | 企业微信 userid → qywx_id |
| Token 存储 | Cookie Admin-Token |
相同 |
| 登录后流程 | GetInfo + 动态路由 | 相同 |
文档基于仓库当前代码整理;若 Nacos 或企业微信后台配置与本地 bootstrap.yml 不一致,以运行环境为准,实操可以参考https://mp.csdn.net/mp_blog/creation/editor/146337519 。