From 2ed31f012ef3892e6428f1dec7e62db900e5eed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Wed, 26 Aug 2026 21:43:15 +0800 Subject: [PATCH] =?UTF-8?q?FIX:=20RBAC=20=E7=A6=81=E7=94=A8=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E6=9D=83=E9=99=90=E9=9A=94=E7=A6=BB=EF=BC=88Codes/Has?= =?UTF-8?q?Permission/Routes=20=E8=BF=87=E6=BB=A4=20r.status=3D1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三处联表查询统一 LeftJoin admin_role 并加启用状态过滤 - 修复前 opuser(禁用角色) 仍能拿到权限码并访问受保护接口 --- .workbuddy/memory/CHANGELOG.md | 5 + internal/service/admin/admin/login/login.go | 188 ++++++++++++++++++++ internal/service/admin/system/menu/menu.go | 12 +- 3 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 internal/service/admin/admin/login/login.go diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index e1a9dfc..4d2027f 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -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=1,opuser 联调验证 codes 空/routes 空/接口 403,admin 全链路回归通过 + +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 200,dev server(20100/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/admin、user→/api/service/user、open→/api/service/open);服务端口统一 10100(dev/prod/test);DB admin_menu 权限映射前缀同步;前端 vite 代理与 api 文件同步适配(全 POST + 动作式路径),路由回归 29 条一致 diff --git a/internal/service/admin/admin/login/login.go b/internal/service/admin/admin/login/login.go new file mode 100644 index 0000000..456b6ec --- /dev/null +++ b/internal/service/admin/admin/login/login.go @@ -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_menu(type=2 行)按 " " 匹配得到。 + 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 +} diff --git a/internal/service/admin/system/menu/menu.go b/internal/service/admin/system/menu/menu.go index f87e447..ed9a0e6 100644 --- a/internal/service/admin/system/menu/menu.go +++ b/internal/service/admin/system/menu/menu.go @@ -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 {