Go 后台接入 SSO 后菜单正常但接口 403,怎么排查权限链路?

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 即插即用方案。

相关推荐
Canmag 兴隆磁性1 小时前
C 系列充磁机
c语言·开发语言·学习·永磁材料·充磁·退磁
benchmark_cc2 小时前
批量获取量化数据时,如何设置合理的超时和重试机制?——QuantDash 高性能实战指南
开发语言·人工智能·python·pandas·量化·quantdash
2601_966949652 小时前
使用 Pandas 读取批量数据时如何避免内存溢出?QuantDash 量化数据工程师避坑指南
开发语言·python·pandas·tushare·akshare·quantdash
65岁退休Coder2 小时前
LangChain v1.3.4 笔记 - 08 MCP & 相关概念
后端·python·langchain
郑州光合科技余经理2 小时前
海外版多语言团购系统架构:主数据互通与核销边界
java·开发语言·前端·后端·系统架构·php·ai编程
ttwuai2 小时前
Go 后台图片上传到对象存储后,预览 403/404 怎么排查?
开发语言·golang
绿浪19842 小时前
c# 结构体 能不能直接封送检测
开发语言·c#
vipxieliang3 小时前
ValidX 的 Date 和 DateTime vs JPA 的 Temporal 对比
java·后端
触底反弹3 小时前
🔥 从「为什么」到「怎么用」:TypeScript 类型约束与泛型完全指南
后端·面试·typescript