Go 后台接入 SSO 后菜单正常但接口 403,怎么排查权限链路?
SSO 接入后如果出现"能登录、能看到菜单,但点保存/删除接口返回 403",不要先改前端菜单,也不要把按钮隐藏当成权限生效。排查顺序应该是:先确认请求头里有没有后台 accessToken,再确认 token 解析出的用户 ID 是否能查到角色,最后看接口权限中间件能不能把 `METHOD + path` 映射到菜单按钮权限。菜单能显示,只说明路由侧通过了一部分;接口 403 说明后端 RBAC 这一层没有通过。
这篇按 GoFrame + Vue3 后台的常见链路写,代码样本来自 XYGo Admin 当前仓库。它正好有一个公开 Issue #10 在问"单点登录能否支持",但仓库当前不是一个现成 SSO 模块,所以这里不写"复制就能接企业微信/钉钉"。更准确的说法是:接 SSO 前,先把本地 token、用户、角色、菜单、接口权限这条链路验明白。
1. 先把问题分成两类:401 和 403 不是一回事
很多后台排障会把"登录失效"和"没有权限"混在一起。SSO 接入时尤其容易这样,因为第三方平台返回 code、ticket 或 id_token 后,本地系统还要再生成自己的后台 token。
在这个仓库的后台鉴权中,`server/internal/middleware/auth.go` 做的是登录态校验。它只放行 `/admin/auth/login` 和 `/admin/auth/refresh`,其他 `/admin/**` 请求都要带 `Authorization: Bearer ***`。
// server/internal/middleware/auth.go
func AdminAuth(r *ghttp.Request) {
path := r.URL.Path
if path == "/admin/auth/login" || path == "/admin/auth/refresh" {
r.Middleware.Next()
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
r.SetError(gerror.NewCode(consts.CodeNotAuthorized, "未登录"))
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
authUser, err := token.Parse(r.Context(), tokenStr)
if err != nil {
r.SetError(gerror.NewCode(consts.CodeNotAuthorized, "登录已失效,请重新登录"))
return
}
contexts.SetUser(r.Context(), authUser)
r.Middleware.Next()
}
所以第一步先看状态码语义:
# 1. 没有 Authorization,应该先按 401/未登录方向查
curl -i https://api.example.com/admin/system/user/list
# 2. 带了 token 但仍失败,再看是 token 过期还是接口权限拒绝
curl -i \
-H "Authorization: Bearer $ACCESS_TOKEN" \
https://api.example.com/admin/system/user/list
如果这里已经提示"未登录"或"登录已失效",问题在 SSO 回调、本地 token 生成、前端 token 存储或请求拦截器,不要去改角色菜单。只有 token 能解析出本地用户,才进入 403 排查。
2. SSO 回调不能只返回第三方用户 ID,要落到本地 admin_user
SSO 最常见的坑是:企业微信、钉钉或统一身份平台返回了 `openid/unionid/sub`,前端也拿到了登录态,但本地后台没有对应的 `admin_user`,或者有用户却没有角色。
可以用三张表先做一次自查。表名按常见后台模型写,实际字段以你的项目为准:
-- 1. 第三方账号是否映射到本地后台用户
select provider, external_id, user_id
from admin_sso_account
where provider = 'dingtalk' and external_id = '第三方返回的用户ID';
-- 2. 本地后台用户是否存在、是否禁用
select id, username, status, is_super
from admin_user
where id = 10001;
-- 3. 这个用户有没有角色
select user_id, role_id
from admin_user_role
where user_id = 10001;
如果第 1 条查不到,说明 SSO 只完成了"外部身份认证",还没完成"本地账号绑定"。如果第 2 条 `status` 是禁用,应该拒绝登录。第 3 条为空时,菜单、按钮和接口权限都会很尴尬:有些项目会让用户进入空白后台,有些项目会在接口层全部 403。
我的建议是把 SSO 回调写成这个顺序:
// 示例:SSO 回调后不要直接把 externalId 当成本地 userId
func HandleSsoCallback(ctx context.Context, externalId string) (*LoginResult, error) {
localUser, err := FindOrBindAdminUser(ctx, "dingtalk", externalId)
if err != nil {
return nil, err
}
if localUser.Status != 1 {
return nil, errors.New("账号已禁用")
}
if len(localUser.RoleIds) == 0 && !localUser.IsSuper {
return nil, errors.New("账号未分配后台角色")
}
return IssueAdminToken(ctx, localUser.Id)
}
这里不要图省事。第三方身份 ID 不是后台用户 ID,更不是权限 ID。SSO 只回答"这个人是谁",后台系统还要回答"这个人在本系统能操作什么"。
3. 菜单能看到,不代表接口权限已经过了
前端按钮权限通常只解决"看不看得见"。前端 `web/src/directives/core/auth.ts` 里,`v-auth` 会读取当前路由 `meta.authList`,没有权限就把按钮 DOM 移除。
// web/src/directives/core/auth.ts
const authList = (router.currentRoute.value.meta.authList as Array<{ authMark: string }>) || []
if (!authList.length) return
const hasPermission = authList.some((item) => item.authMark === binding.value)
if (!hasPermission) {
removeElement(el)
}
这个逻辑对用户体验有用,但它不是安全边界。用户仍然可以打开 DevTools、复制接口、自己发请求。真正决定保存、删除、导出能不能执行的是后端接口权限。
后端接口权限在 `server/internal/middleware/admin_permission.go`。它会把请求拼成 `METHOD + path`,例如 `POST /admin/system/user/save`,再去已启用菜单里找对应权限点。找到后,再查当前用户的角色菜单关系。
// server/internal/middleware/admin_permission.go
perm := strings.ToUpper(r.Method) + " " + r.URL.Path
items := getPermItems(r.Context(), perm)
menuIds := resolveMenuIds(r, items)
allowed, err := userHasMenuPermission(r.Context(), user.Id, menuIds)
if !allowed {
r.SetError(gerror.NewCode(consts.CodeNoPermission, "无操作权限"))
return
}
这里有两个排查点很容易漏。
第一,菜单表里的接口权限要和真实路由完全一致,包括 Method。`GET /list` 和 `POST /list` 是两个权限点,`/admin/user/save` 和 `/admin/system/user/save` 也不是一回事。
第二,菜单显示权限和按钮权限不是一层。菜单能进页面,只能说明路由菜单被分配了;保存、删除、导出通常还要有按钮或接口权限子项。
可以直接用 SQL 检查:
-- 查某个接口有没有挂到菜单权限点上
select id, name, perms, status
from admin_menu
where perms like '%POST /admin/system/user/save%';
-- 查角色是否拿到了这个菜单/按钮权限
select role_id, menu_id
from admin_role_menu
where menu_id in (
select id from admin_menu
where perms like '%POST /admin/system/user/save%'
);
-- 查用户是否有这些角色
select user_id, role_id
from admin_user_role
where user_id = 10001;
如果第一条为空,问题在菜单权限配置或代码生成器生成的权限点。第二条为空,问题在角色授权。第三条为空,问题在 SSO 用户绑定角色。
4. 还要查权限缓存,不要只盯数据库
有些后台为了性能会缓存接口权限映射。当前代码里也有权限缓存:`permCache.data` 保存 `perm -> menuId/name` 的映射,首次访问时从启用菜单加载。
// server/internal/middleware/admin_permission.go
func ensurePermCacheLoaded(ctx context.Context) {
permCache.RLock()
loaded := permCache.loaded
permCache.RUnlock()
if !loaded {
loadPermCache(ctx)
}
}
func RefreshPermCache(ctx context.Context) {
permCache.Lock()
permCache.loaded = false
permCache.Unlock()
loadPermCache(ctx)
}
这意味着你刚改完菜单权限,数据库已经对了,但接口仍旧 403,可能是缓存没有刷新。排查时可以在菜单保存、角色授权、代码生成权限点写入后确认是否调用了刷新逻辑。没有刷新就重启服务能"临时变好",但那不是根因修复。
可以在日志里加一行临时输出,确认缓存里到底有没有目标接口:
func debugPerm(ctx context.Context, perm string) {
items := getPermItems(ctx, perm)
g.Log().Infof(ctx, "perm=%s items=%+v", perm, items)
}
线上不要长期打印所有权限点,接口量大时会污染日志。只在排障分支或本地环境打开。
5. 前端 token 存储也要一起看
前端侧,用户状态在 `web/src/store/modules/user.ts` 里保存 `accessToken` 和 `refreshToken`。退出登录时会清理用户信息、token、路由状态和旧的本地存储。
// web/src/store/modules/user.ts
const accessToken = ref('')
const refreshToken = ref('')
const setToken = (newAccessToken: string, newRefreshToken?: string) => {
accessToken.value = newAccessToken
if (newRefreshToken) {
refreshToken.value = newRefreshToken
}
}
SSO 接入后,前端至少要读回这几项:
# 浏览器控制台里看持久化用户状态,具体 key 以项目配置为准
localStorage.getItem('user')
# 网络面板里确认后台接口请求头
Authorization: Bearer eyJ...
如果登录回调页拿到了 token,但 Pinia/localStorage 没写进去,后续接口会走未登录。反过来,如果前端还保留旧 token,新用户登录后可能看到旧用户的工作台或菜单缓存。项目在退出时会清理旧的 `user` 存储并重置路由状态,这个细节在接 SSO 时也要保留。
6. 一个可执行的排查清单
遇到"SSO 登录成功,但保存接口 403",我会按这个清单查:
1. Network:请求是否带 Authorization: Bearer xxx
2. 后端 AdminAuth:token.Parse 是否能得到本地 admin user
3. 数据库 admin_user:用户是否存在、status 是否启用
4. 数据库 admin_user_role:用户是否绑定角色
5. 数据库 admin_menu:目标接口 METHOD + path 是否写入 perms
6. 数据库 admin_role_menu:角色是否拥有目标菜单/按钮权限
7. 后端 AdminPermission:permCache 是否刷新到最新权限点
8. 前端 v-auth:按钮是否只是被隐藏,不能拿它当接口安全证明
再给一个最小化 curl 验证:
TOKEN='替换成后台 accessToken'
curl -i \
-H "Authorization: Bearer $TOKEN" \
https://api.example.com/admin/auth/profile
curl -i \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":1,"nickname":"test"}' \
https://api.example.com/admin/system/user/save
`/admin/auth/profile` 成功而 `/admin/system/user/save` 403,基本就不是 SSO 登录问题了,而是角色、菜单按钮或接口权限映射问题。
还有一个小细节:不要只在浏览器里点一次按钮就下结论。排权限链路时最好同时保留接口响应、后端日志和三张关系表查询结果。这样开发、运维和负责 SSO 的同事能对着同一组证据说话,不会变成"我这里能看到菜单,你那里为什么说没权限"的互相猜测。
7. 适用场景和不适用场景
适用场景:GoFrame 或其他 Go 后台项目里,SSO 回调后仍使用本地后台用户、角色、菜单和接口权限;前端是 Vue3/Pinia/动态路由;后端接口按 RBAC 做二次校验。
不适用场景:完全无状态的 API 网关鉴权、只靠 OAuth scope 不落本地角色的系统、Kubernetes Ingress/OIDC 网关层问题、普通 CORS 预检失败、浏览器 cookie SameSite 导致的跨域登录失败。这些问题也会表现为"登录后访问失败",但排查入口不一样。
本文核验时点是 2026-08-20。仓库当前最新 Release 是 `v1.4.6`,公开仓库 pushed_at 为 2026-08-16T15:33:46Z。本文引用的源码路径包括 `server/internal/middleware/auth.go`、`server/internal/middleware/admin_permission.go`、`web/src/directives/core/auth.ts`、`web/src/store/modules/user.ts`,以及公开 Issue #10"单点登录能否支持"。
如果要看完整上下文,可以从 GitHub 仓库 进入源码;产品文档入口在 XYGo Admin 文档。这里把 XYGo Admin 当作 GoFrame + Vue3 + RBAC 的真实代码样本,不把它写成 SSO 即插即用方案。