FIX: RBAC 禁用角色权限隔离(Codes/HasPermission/Routes 过滤 r.status=1)

- 三处联表查询统一 LeftJoin admin_role 并加启用状态过滤
- 修复前 opuser(禁用角色) 仍能拿到权限码并访问受保护接口
This commit is contained in:
夏犀麟 2026-08-26 21:43:15 +08:00
parent 45f9098e89
commit 2ed31f012e
3 changed files with 200 additions and 5 deletions

View File

@ -1,6 +1,11 @@
# service.xpcool.com 变更记录
> 倒序最新在上格式YYYY-MM-DD | 类型 | 摘要
2026-08-26 | FIX | RBAC 安全漏洞修复禁用角色status=0绑定的权限仍生效根因为 Codes/HasPermission/Routes 三处联表查询未过滤 admin_role 启用状态 roleCodes 过滤修复三处统一 LeftJoin admin_role 并加 r.status=1opuser 联调验证 codes /routes /接口 403admin 全链路回归通过
2026-08-26 | CFG | 修复 GoLand 直接 Run 找不到 config.yaml新增项目级运行配置 .run/service-dev.run.xml内置 GF_GCFG_FILE=config.dev.yaml + DB_DSN + JWT_SECRET 三环境变量.run/ 加入 .gitignore含口令README Local startup小节终端 .env.dev / GoLand 运行配置两种方式
2026-08-26 | FIX | 前端登录 502 联调根因为服务未启动10100 无监听 .env.dev 启动后 login/info/codes/menu-routes 全链路 curl 200dev server20100/20101/20102/20103代理全部恢复
2026-08-26 | CFG | CHANGELOG 纳入 git 管理.gitignore 放行 .workbuddy/memory/CHANGELOG.md中央工作空间迁移至 ../workbuddy.xpcool.com相对路径索引
2026-08-26 | CHG | 统一 API 前缀 /api/service/admin/api/service/adminuser/api/service/useropen/api/service/open服务端口统一 10100dev/prod/testDB admin_menu 权限映射前缀同步前端 vite 代理与 api 文件同步适配 POST + 动作式路径路由回归 29 条一致

View File

@ -0,0 +1,188 @@
package admin_admin_login
import (
"context"
"regexp"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"golang.org/x/crypto/bcrypt"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/jwt"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// IAdminAuth 管理端认证服务接口。
type IAdminAuth interface {
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
Refresh(context.Context, string) (*dto.TokenPair, uint64, error)
HasPermission(context.Context, uint64, string) (bool, error)
Info(context.Context, uint64) (*dto.AdminInfo, error)
Codes(context.Context, uint64) ([]string, error)
// PermissionForPath 解析接口所需的权限码,
// 从 admin_menutype=2 行)按 "<METHOD> <path>" 匹配得到。
PermissionForPath(context.Context, string, string) (string, error)
}
var localAdminAuth IAdminAuth
func AdminAuth() IAdminAuth {
if localAdminAuth == nil {
panic("AdminAuth implementation not registered")
}
return localAdminAuth
}
func RegisterAdminAuth(i IAdminAuth) { localAdminAuth = i }
type adminAuth struct{ tokens *jwt.Service }
func NewAdminAuth(tokens *jwt.Service) IAdminAuth { return &adminAuth{tokens} }
func (s *adminAuth) Login(ctx context.Context, in dto.AdminLoginInput) (*dto.TokenPair, uint64, error) {
// 管理端只允许账号密码登录,状态异常或密码错误均返回统一错误,避免枚举账号。
var a entity.AdminUser
if err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Username: in.Username}).Scan(&a); err != nil {
return nil, 0, gerror.Wrap(err, "query administrator")
}
if a.Id == 0 {
return nil, 0, response.Error(consts.CodeAdminNotFound, "administrator not found")
}
if a.Status != 1 || bcrypt.CompareHashAndPassword([]byte(a.PasswordHash), []byte(in.Password)) != nil {
return nil, 0, response.Error(consts.CodeAdminPasswordWrong, "username or password incorrect")
}
// 通过 issue 签发令牌对并把刷新令牌 JTI 落库,保证后续可刷新、可撤销。
return s.issue(ctx, a.Id, "")
}
// Refresh 用有效的刷新令牌轮换管理员令牌对:校验 → 撤销旧会话 → 签发新对并落库。
func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair, uint64, error) {
c, err := s.tokens.Parse(refresh, "refresh", "admin")
if err != nil {
return nil, 0, response.Error(consts.CodeUnauthorized, "invalid refresh token")
}
// 单次使用:撤销旧刷新会话并校验影响行数,重复使用旧令牌直接拒绝。
result, err := dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update()
if err != nil {
return nil, 0, gerror.Wrap(err, "撤销旧刷新令牌失败")
}
affected, err := result.RowsAffected()
if err != nil || affected != 1 {
return nil, 0, response.Error(consts.CodeUnauthorized, "刷新令牌已失效")
}
return s.issue(ctx, c.Subject, c.Terminal)
}
// issue 签发令牌对并把刷新令牌的 JTI 落库,支持撤销与设备会话追踪。
func (s *adminAuth) issue(ctx context.Context, id uint64, terminal string) (*dto.TokenPair, uint64, error) {
access, refresh, exp, err := s.tokens.Issue(id, "admin", terminal)
if err != nil {
return nil, 0, gerror.Wrap(err, "issue token")
}
claims, err := s.tokens.Parse(refresh, "refresh", "admin")
if err != nil {
return nil, 0, gerror.Wrap(err, "解析新刷新令牌失败")
}
if _, err = dao.AuthRefreshSession.Ctx(ctx).Data(do.AuthRefreshSession{
SubjectId: id, Scope: "admin", Terminal: terminal,
Jti: claims.JTI, ExpiredAt: gtime.NewFromTimeStamp(claims.ExpireAt),
}).Insert(); err != nil {
return nil, 0, gerror.Wrap(err, "保存刷新令牌会话失败")
}
return &dto.TokenPair{AccessToken: access, RefreshToken: refresh, ExpiresIn: exp}, id, nil
}
func (s *adminAuth) HasPermission(ctx context.Context, adminID uint64, permission string) (bool, error) {
// 多角色权限通过管理员-角色-菜单四表关联查询,菜单中的 permission 即接口权限标识。
// 必须过滤角色启用状态 r.status=1否则被禁用角色绑定的权限仍然生效。
count, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").LeftJoin("admin_menu m", "rm.menu_id=m.id").Where("ur.admin_user_id", adminID).Where("m.permission", permission).Where("m.status", 1).Where("r.status", 1).Count()
if err != nil {
return false, gerror.Wrap(err, "check permission")
}
return count > 0, nil
}
func (s *adminAuth) Info(ctx context.Context, adminID uint64) (*dto.AdminInfo, error) {
var a entity.AdminUser
if err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: adminID}).Scan(&a); err != nil {
return nil, gerror.Wrap(err, "query admin info")
}
if a.Id == 0 {
return nil, response.Error(consts.CodeAdminNotFound, "administrator not found")
}
roles, err := s.roleCodes(ctx, adminID)
if err != nil {
return nil, err
}
return &dto.AdminInfo{AdminID: a.Id, Username: a.Username, Nickname: a.Nickname, Roles: roles}, nil
}
func (s *adminAuth) Codes(ctx context.Context, adminID uint64) ([]string, error) {
// 权限码 = 该管理员所有启用角色r.status=1绑定的菜单 permission含菜单与按钮级
// 同时用作 vben 前端按钮权限码与后端 X-Permission 校验标识。
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").LeftJoin("admin_menu m", "rm.menu_id=m.id").Where("ur.admin_user_id", adminID).Where("m.status", 1).Where("r.status", 1).WhereGT("m.permission", "").Fields("DISTINCT m.permission").Array()
if err != nil {
return nil, gerror.Wrap(err, "query access codes")
}
codes := make([]string, 0, len(list))
for _, v := range list {
codes = append(codes, v.String())
}
return codes, nil
}
func (s *adminAuth) roleCodes(ctx context.Context, adminID uint64) ([]string, error) {
list, err := dao.AdminUserRole.Ctx(ctx).As("ur").LeftJoin("admin_role r", "ur.role_id=r.id").Where("ur.admin_user_id", adminID).Where("r.status", 1).Fields("DISTINCT r.code").Array()
if err != nil {
return nil, gerror.Wrap(err, "query admin roles")
}
codes := make([]string, 0, len(list))
for _, v := range list {
codes = append(codes, v.String())
}
return codes, nil
}
func (s *adminAuth) PermissionForPath(ctx context.Context, method, path string) (string, error) {
// 从菜单表 type=2按钮/API行反查当前请求所需的权限码。
// 未配置映射的接口一律拒绝访问(返回空则中间件拦截)。
var list []entity.AdminMenu
if err := dao.AdminMenu.Ctx(ctx).Where(do.AdminMenu{Type: 2, Status: 1}).Scan(&list); err != nil {
return "", gerror.Wrap(err, "query permission mappings")
}
req := method + " " + path
for _, m := range list {
if matchRoute(m.Path, req) {
return m.Permission, nil
}
}
return "", nil
}
// matchRoute 匹配 "METHOD /path" 模式,{id} 视为动态段。
func matchRoute(pattern, req string) bool {
if pattern == "" {
return false
}
var b strings.Builder
b.WriteByte('^')
for i := 0; i < len(pattern); i++ {
c := pattern[i]
switch {
case c == '{':
if j := strings.IndexByte(pattern[i:], '}'); j > 0 {
b.WriteString("[^/]+")
i += j
} else {
b.WriteString(regexp.QuoteMeta(string(c)))
}
case c == ' ' || c == '/' || c == '-' || c == '_' || c == '.' ||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'):
b.WriteByte(c)
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteByte('$')
ok, err := regexp.MatchString(b.String(), req)
return err == nil && ok
}

View File

@ -1,4 +1,4 @@
package menu
package admin_system_menu
import (
"context"
@ -14,7 +14,7 @@ import (
)
type IAdminMenu interface {
// Routes returns the visible menu tree of an admin as vben route items.
// Routes 返回管理员的可见菜单树vben 路由项)。
Routes(context.Context, uint64) ([]*dto.RouteItem, error)
}
@ -32,8 +32,8 @@ func AdminMenu() IAdminMenu {
}
func RegisterAdminMenu(i IAdminMenu) { localAdminMenu = i }
// routeName converts a permission code like "system:admin" to a unique
// vben route name, e.g. "SystemAdmin".
// routeName 将权限码(如 "system:admin")转换为唯一的
// vben 路由名,如 "SystemAdmin"。
func routeName(permission string) string {
if permission == "" {
return ""
@ -42,14 +42,16 @@ func routeName(permission string) string {
}
func (s *adminMenu) Routes(ctx context.Context, adminID uint64) ([]*dto.RouteItem, error) {
// 当前管理员所有启用角色可见的菜单type=1含按钮行但不作为路由节点。
// 当前管理员所有启用角色r.status=1可见的菜单type=1含按钮行但不作为路由节点。
var list []entity.AdminMenu
if err := dao.AdminUserRole.Ctx(ctx).As("ur").
LeftJoin("admin_role r", "ur.role_id=r.id").
LeftJoin("admin_role_menu rm", "ur.role_id=rm.role_id").
LeftJoin("admin_menu m", "rm.menu_id=m.id").
Where("ur.admin_user_id", adminID).
Where("m.type", 1).
Where("m.status", 1).
Where("r.status", 1).
Where("m.deleted_at IS NULL").
Fields("m.*").
Scan(&list); err != nil {