fix(permissions):修复提权漏洞
This commit is contained in:
1 parent
ec182c5a71
commit
652c6903f6
6 files changed
+201
-39
No files matched your search
@@ -86,6 +86,64 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
|
||||
}, nil
|
||||
}
|
||||
|
||||
// assertCanManageAdminUser 校验当前调用者是否有资格操作目标管理员账号。
|
||||
//
|
||||
// 账号管理(改资料 / 改密 / 禁用 / 删除 / 配特批权限)按 user:* 授权、允许下放,
|
||||
// 但下放的前提是「操作本身不能带来提权」。两条底线:
|
||||
// - 目标是超级管理员 → 非超管一律不可操作。否则重置一次超管密码就能接管整个系统,
|
||||
// 角色管理限定超管的那道闸门形同虚设。
|
||||
// - 目标的有效权限不是调用者的子集 → 不可操作。否则一个只有 user:edit 的账号可以
|
||||
// 重置权限更高账号的密码,登进去就完成了横向提权。
|
||||
//
|
||||
// 超级管理员短路放行;操作自己不受限(自身权限的变更另有 sanitizeGrantedPermissions 把关)。
|
||||
// 与 sanitizeGrantedPermissions 的「不能授予自己不具备的权限」同源:能碰的对象不得强于自己。
|
||||
func assertCanManageAdminUser(ctx context.Context, targetUserID int64, targetRole string) error {
|
||||
operatorRole := common.GetCtxUserRole(ctx)
|
||||
if operatorRole == "super_admin" {
|
||||
return nil
|
||||
}
|
||||
if targetRole == "super_admin" {
|
||||
return common.NewBusinessError(consts.CodeForbidden, "不能操作超级管理员账号")
|
||||
}
|
||||
|
||||
operatorID := common.GetCtxUserID(ctx)
|
||||
// 无调用者身份(命令行工具、内部调用)时不做判定,交由上层接口鉴权把关
|
||||
if operatorID == 0 || operatorID == targetUserID {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetPerms, err := GetEffectivePermissions(ctx, targetUserID, targetRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownPerms, err := GetEffectivePermissions(ctx, operatorID, operatorRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownSet := make(map[string]bool, len(ownPerms))
|
||||
for _, p := range ownPerms {
|
||||
ownSet[p] = true
|
||||
}
|
||||
for _, p := range targetPerms {
|
||||
if !ownSet[p] {
|
||||
return common.NewBusinessError(consts.CodeForbidden, "目标账号拥有你不具备的权限,无法对其执行此操作")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertCanGrantSuperAdmin 拦截「非超管把账号设成超级管理员」。
|
||||
//
|
||||
// role 字段的取值校验(ValidateAdminRole)只管值合不合法,管不了谁有资格赋这个值。
|
||||
// 少了这道判断,一个拿到 user:create / user:edit 的账号可以直接把自己或新建账号
|
||||
// 提成 super_admin —— 这是比分配角色更直接的提权入口,必须与角色分配同级管控。
|
||||
func assertCanGrantSuperAdmin(ctx context.Context, role string) error {
|
||||
if role != "super_admin" || common.GetCtxUserRole(ctx) == "super_admin" {
|
||||
return nil
|
||||
}
|
||||
return common.NewBusinessError(consts.CodeForbidden, "只有超级管理员才能设置超级管理员账号")
|
||||
}
|
||||
|
||||
// CreateUser creates a new admin user.
|
||||
func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
@@ -138,6 +196,9 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
|
||||
if err := common.ValidateAdminRole(role); err != nil {
|
||||
return nil, common.NewBadRequestError(err.Error())
|
||||
}
|
||||
if err := assertCanGrantSuperAdmin(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建账号时附带角色属于角色分配,与角色管理同级:仅超管可用。
|
||||
// 否则拥有 user:create 的人可以造一个挂着高权限角色、密码由自己设定的账号,
|
||||
@@ -204,6 +265,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
|
||||
if targetUser.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("不能修改超级管理员信息")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := do.SysAdminUsers{}
|
||||
if req.DisplayName != nil {
|
||||
@@ -226,6 +290,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
|
||||
if err := common.ValidateAdminRole(*req.Role); err != nil {
|
||||
return nil, common.NewBadRequestError("角色无效")
|
||||
}
|
||||
if err := assertCanGrantSuperAdmin(ctx, *req.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Role = *req.Role
|
||||
}
|
||||
|
||||
@@ -263,6 +330,9 @@ func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("不能删除超级管理员")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete user first, then revoke sessions to avoid leaving the user
|
||||
// in a state where sessions are gone but the user still exists.
|
||||
@@ -321,6 +391,9 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("不能修改超级管理员状态")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(do.SysAdminUsers{
|
||||
Status: req.Status,
|
||||
@@ -366,6 +439,21 @@ func (s *sAdmin) ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPa
|
||||
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
|
||||
}
|
||||
|
||||
// 改密等于账号接管:此前不校验目标是谁,拿到 user:edit 就能重置超级管理员的密码。
|
||||
var targetUser *struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("role").Scan(&targetUser)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetUser == nil {
|
||||
return nil, common.NewNotFoundError("管理员")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwordHash, err := crypto.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
@@ -222,6 +221,18 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("超级管理员无需配置权限")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 特批权限与角色权限一样进有效权限的并集,因此必须走同一道 sanitize:
|
||||
// 校验权限点存在 + 去重排序 + 禁止授予自己不具备的权限。
|
||||
// 此前这里只做「权限点是否存在」的校验,一个拿到 user:edit 的账号可以给自己
|
||||
// 特批 system:plugin / billing:refund,绕开「角色管理仅超管」的全部管控。
|
||||
perms, err := sanitizeGrantedPermissions(ctx, req.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
@@ -233,18 +244,10 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate permission points against predefined set
|
||||
validPerms := buildValidPermissionSet()
|
||||
for _, p := range req.Permissions {
|
||||
if !validPerms[p] {
|
||||
return gerror.Newf("无效的权限点: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new permissions
|
||||
if len(req.Permissions) > 0 {
|
||||
data := make([]do.SysAdminRolePerms, len(req.Permissions))
|
||||
for i, p := range req.Permissions {
|
||||
if len(perms) > 0 {
|
||||
data := make([]do.SysAdminRolePerms, len(perms))
|
||||
for i, p := range perms {
|
||||
data[i] = do.SysAdminRolePerms{
|
||||
AdminUserId: req.Id,
|
||||
PermissionPoint: p,
|
||||
@@ -285,6 +288,9 @@ func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScop
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("超级管理员无需配置数据范围")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ctxAs 构造带调用者身份的上下文。
|
||||
// 键名与 middleware 写入 ctx 的保持一致(见 internal/logic/common/session.go)。
|
||||
func ctxAs(role string, userID int64) context.Context {
|
||||
ctx := context.WithValue(context.Background(), "role", role)
|
||||
return context.WithValue(ctx, "userId", userID)
|
||||
}
|
||||
|
||||
// TestAssertCanGrantSuperAdmin 守住「谁能把账号设成超级管理员」。
|
||||
//
|
||||
// sys_admin_users.role 是超管的唯一判定依据,鉴权时直接短路放行。若允许非超管写这个字段,
|
||||
// 一个拿到 user:create / user:edit 的账号可以直接把自己提成超管 —— 比分配角色更直接,
|
||||
// 且完全绕过 superAdminOnlyRules。字段级取值校验(ValidateAdminRole)管不了这件事。
|
||||
func TestAssertCanGrantSuperAdmin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
operator string
|
||||
targetRole string
|
||||
wantBlocked bool
|
||||
}{
|
||||
{"超管可以设置超管", "super_admin", "super_admin", false},
|
||||
{"超管可以设置普通管理员", "super_admin", "admin", false},
|
||||
{"非超管不能设置超管", "admin", "super_admin", true},
|
||||
{"非超管可以设置普通管理员", "admin", "admin", false},
|
||||
{"无身份上下文时不能设置超管", "", "super_admin", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := assertCanGrantSuperAdmin(ctxAs(c.operator, 1), c.targetRole)
|
||||
if c.wantBlocked && err == nil {
|
||||
t.Fatalf("应被拦截,实际放行")
|
||||
}
|
||||
if !c.wantBlocked && err != nil {
|
||||
t.Fatalf("应放行,实际被拦截: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertCanManageAdminUserBlocksSuperAdminTarget 守住「非超管不得操作超管账号」。
|
||||
//
|
||||
// 账号管理(改密/禁用/删除/配特批权限)按 user:* 授权、允许下放。若不限制目标,
|
||||
// 拿到 user:edit 的账号重置一次超管密码就能接管系统,角色管理限定超管那道闸门形同虚设。
|
||||
//
|
||||
// 只覆盖不触库的分支:超管调用者短路、目标为超管、无调用者身份、操作自己。
|
||||
// 「目标权限须是调用者子集」需要读库,由集成测试覆盖。
|
||||
func TestAssertCanManageAdminUserBlocksSuperAdminTarget(t *testing.T) {
|
||||
if err := assertCanManageAdminUser(ctxAs("super_admin", 1), 2, "super_admin"); err != nil {
|
||||
t.Fatalf("超管操作任意账号应放行: %v", err)
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctxAs("admin", 2), 1, "super_admin"); err == nil {
|
||||
t.Fatal("非超管操作超管账号应被拦截,实际放行")
|
||||
}
|
||||
// 无调用者身份(命令行工具、内部调用):交由上层接口鉴权把关,此处不拦
|
||||
if err := assertCanManageAdminUser(ctxAs("admin", 0), 3, "admin"); err != nil {
|
||||
t.Fatalf("无调用者身份时不应拦截: %v", err)
|
||||
}
|
||||
// 操作自己:自身权限的变更另有 sanitizeGrantedPermissions 把关
|
||||
if err := assertCanManageAdminUser(ctxAs("admin", 5), 5, "admin"); err != nil {
|
||||
t.Fatalf("操作自己不应被拦截: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,21 @@ import (
|
||||
// tenantPublicPaths lists tenant routes that skip JWT auth.
|
||||
// Keep in sync with api/tenant/v1/ structs tagged group:"public" middleware:"-".
|
||||
var tenantPublicPaths = map[string]bool{
|
||||
"/api/tenant/auth/register": true,
|
||||
"/api/tenant/auth/login": true,
|
||||
"/api/tenant/auth/refresh": true,
|
||||
"/api/tenant/auth/2fa/verify": true,
|
||||
"/api/tenant/email/send-code": true,
|
||||
"/api/tenant/email/reset-password": true,
|
||||
"/api/tenant/members/join": true,
|
||||
"/api/tenant/members/invite-info": true,
|
||||
"/api/tenant/agreements/current": true,
|
||||
"/api/tenant/oauth/authorize": true,
|
||||
"/api/tenant/marketplace/models": true,
|
||||
"/api/tenant/help/categories": true,
|
||||
"/api/tenant/help/search": true,
|
||||
"/api/tenant/auth/register": true,
|
||||
// 注册页在提交前展示剩余注册次数,必须匿名可访问(只读不计数)
|
||||
"/api/tenant/auth/register-rate-limit": true,
|
||||
"/api/tenant/auth/login": true,
|
||||
"/api/tenant/auth/refresh": true,
|
||||
"/api/tenant/auth/2fa/verify": true,
|
||||
"/api/tenant/email/send-code": true,
|
||||
"/api/tenant/email/reset-password": true,
|
||||
"/api/tenant/members/join": true,
|
||||
"/api/tenant/members/invite-info": true,
|
||||
"/api/tenant/agreements/current": true,
|
||||
"/api/tenant/oauth/authorize": true,
|
||||
"/api/tenant/marketplace/models": true,
|
||||
"/api/tenant/help/categories": true,
|
||||
"/api/tenant/help/search": true,
|
||||
}
|
||||
|
||||
// tenantPublicPrefixes lists path prefixes that skip JWT auth (for dynamic routes like /current/{code}).
|
||||
|
||||
@@ -160,16 +160,6 @@ SELECT r.id, unnest(ARRAY[
|
||||
FROM sys_admin_roles r WHERE r.code = 'support'
|
||||
ON CONFLICT (role_id, permission_point) DO NOTHING;
|
||||
|
||||
-- ── 存量账号回填 ──
|
||||
-- 非超管账号一律挂到「管理员」角色,保持升级前后的可用权限不缩水
|
||||
-- (升级前 admin 账号的实际权限来自 sys_admin_role_perms,特批权限继续并集生效)。
|
||||
INSERT INTO sys_admin_user_roles (admin_user_id, role_id)
|
||||
SELECT u.id, r.id
|
||||
FROM sys_admin_users u
|
||||
CROSS JOIN sys_admin_roles r
|
||||
WHERE u.role <> 'super_admin' AND r.code = 'admin'
|
||||
ON CONFLICT (admin_user_id, role_id) DO NOTHING;
|
||||
|
||||
|
||||
-- 移除不再使用的配置项
|
||||
DELETE FROM sys_options
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
IconCheckCircleFill,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { hasPermission, isSuperAdmin } from '@/utils/permission'
|
||||
import { ADMIN_ROLES } from '@/utils/permission'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -405,8 +405,15 @@ const ALL_MENU_GROUPS = [
|
||||
},
|
||||
]
|
||||
|
||||
// 权限判定读 store 而不是 utils/permission 里的 localStorage 版本:
|
||||
// localStorage 不是响应式数据源,computed 求值一次后就再也不会失效,
|
||||
// fetchMe() 拉到新权限、或切换账号后菜单不会跟着变。
|
||||
// utils 版本仍用于按钮级 v-if(每次渲染都会重新调用,不依赖响应式追踪)。
|
||||
const isSuperAdminUser = computed(() => authStore.user?.role === ADMIN_ROLES.SUPER_ADMIN)
|
||||
const permissionSet = computed(() => new Set(authStore.permissions))
|
||||
|
||||
// 按当前用户权限过滤菜单:整组条目都被过滤掉时,分组本身也不再渲染,
|
||||
// 避免出现点开是空的分组。super_admin 在 hasPermission 内直接放行。
|
||||
// 避免出现点开是空的分组。super_admin 拥有全部权限,直接放行。
|
||||
const menuGroups = computed(() =>
|
||||
ALL_MENU_GROUPS
|
||||
.map(group => ({
|
||||
@@ -414,8 +421,9 @@ const menuGroups = computed(() =>
|
||||
items: group.items.filter(item => {
|
||||
// superOnly 的条目不参与权限点判定:它们是权限体系本身的元操作,
|
||||
// 无法通过授予权限点下放(后端 superAdminOnlyRules 同样硬性拦截)
|
||||
if ((item as any).superOnly) return isSuperAdmin()
|
||||
return !item.perm || hasPermission(item.perm)
|
||||
if ((item as any).superOnly) return isSuperAdminUser.value
|
||||
if (isSuperAdminUser.value) return true
|
||||
return !item.perm || permissionSet.value.has(item.perm)
|
||||
}),
|
||||
}))
|
||||
.filter(group => group.items.length > 0)
|
||||
|
||||
Reference in new issue
Block a user