[BD]用户级权限的实现

115 阅读4分钟
前言:
虽然用户组可以实现大部分权限管理, 但是总会有几个特例的用户
1.比如, 张三是一级管理员组的, 一级管理员是可以看到文章管理菜单的, 现在我不希望他看到
2.又比如, 王五是普通用户组的, 普通用户组是不能点击创建文章的, 但是我希望他能
为了实现这种效果, 就是强调用户级权限 > 用户组权限, 但是有几个问题要考虑
1.例如上面王五的问题, 就算给王五显示了创建文章的按钮, 但是它并没有创建文章的权限(POST,后端鉴权)
所以这个可能只能是用于减少权限, 而不是用于增加权限

2.第二个问题就是强调用户级权限 > 用户组权限,会不会产生权限的漏洞

实现步骤

一.添加两个数据表
# 1.cc_user_menu 用户级权限菜单表(从菜单表拉出一些权限用来做可配置的, 而不是全部可配置,
# 比如我只把文章管理和添加文章拉出来作为可配置的菜单权限)
CREATE TABLE `cc_user_menu` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `menu_rule_id` int(11) DEFAULT '0' COMMENT '关联菜单',
  `type` tinyint(1) DEFAULT NULL COMMENT '类型',
  `remark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
  `created_at` timestamp NULL DEFAULT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户级权限';

# 2.cc_user_menu_config (用户权限菜单配置表, 用于配置用户上面菜单的效果, user_menu_id表示上面的ID)
CREATE TABLE `cc_user_menu_config` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `uid` int(11) DEFAULT '0' COMMENT '用户ID',
  `user_menu_id` int(11) DEFAULT NULL COMMENT '用户级菜单ID',
  `value` tinyint(1) DEFAULT '0' COMMENT '效果:-1.禁用,0.跟随用户组,1.启用',
  `created_at` timestamp NULL DEFAULT NULL COMMENT '时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户级权限菜单配置';

二.用户权限菜单模块 (userMenu)
  • 1.curd创建好菜单
  • 2.修改列表主页 web\src\views\backend\userMenu\index.vue
<template>
    <div class="default-main ba-table-box">
        <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />

        <!-- 表格顶部菜单 -->
        <TableHeader
            :buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
            :quick-search-placeholder="t('quick Search Placeholder', { fields: t('userMenu.quick Search Fields') })"
        />

        <!-- 表格 -->
        <!-- 要使用`el-table`组件原有的属性,直接加在Table标签上即可 -->
        <Table ref="tableRef" />

        <!-- 表单 -->
        <PopupForm />
    </div>
</template>

<script setup lang="ts">
import { ref, provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'

const { t } = useI18n()
const tableRef = ref()
const optButtons = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
    new baTableApi('/admin/userMenu/'),
    {
        pk: 'id',
        column: [
            { type: 'selection', align: 'center', operator: false },
            { label: t('userMenu.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
            { label: t('userMenu.menu_rule_id'), prop: 'menu_rule_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
            { label: t('userMenu.type'), prop: 'type', align: 'center', operator: '=', sortable: false, replaceValue: { } },
            { label: t('userMenu.remark'), prop: 'remark', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE', sortable: false },
            { label: t('userMenu.created_at'), prop: 'created_at', align: 'center', operator: '=', sortable: 'custom', width: 160 },
            { label: t('operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
        ],
        dblClickNotEditColumn: [undefined],
    },
    {
        defaultItems: { type: null, remark: null, created_at: null },
    }
)

provide('baTable', baTable)

onMounted(() => {
    baTable.table.ref = tableRef.value
    baTable.mount()
    baTable.getIndex()?.then(() => {
        baTable.initSort()
        baTable.dragSort()
    })
})
</script>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
    name: 'userMenu',
})
</script>
  • 3.修改自带的添加表单 (主要加上树形下拉用户列表, 和类型选择)
<template>
    <el-dialog
        class="ba-operate-dialog"
        :close-on-click-modal="false"
        :model-value="['add', 'edit'].includes(baTable.form.operate!)"
        @close="baTable.toggleForm"
        width="50%"
    >
        <template #header>
            <div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
                {{ baTable.form.operate ? t(baTable.form.operate) : '' }}
            </div>
        </template>
        <el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
            <div
                class="ba-operate-form"
                :class="'ba-' + baTable.form.operate + '-form'"
                :style="'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
            >
                <el-form
                    v-if="!baTable.form.loading"
                    ref="formRef"
                    @submit.prevent=""
                    @keyup.enter="baTable.onSubmit(formRef)"
                    :model="baTable.form.items"
                    label-position="right"
                    :label-width="baTable.form.labelWidth + 'px'"
                    :rules="rules"
                >
                    <FormItem
                        type="remoteSelect"
                        prop="menu_rule_id"
                        label="关联菜单"
                        v-model="baTable.form.items!.menu_rule_id"
                        :placeholder="t('Click Select')"
                        :input-attr="{
                            params: { isTree: true },
                            field: 'title',
                            'remote-url': remoteUrl,
                        }"
                    />
                    <FormItem 
                        :label="t('userMenu.type')" 
                        type="radio" 
                        v-model="baTable.form.items!.type" prop="type" 
                        :data="{ content: { '1': '菜单', '2': '按钮' }}"
                    />
                    <FormItem :label="t('userMenu.remark')" type="string" v-model="baTable.form.items!.remark" prop="remark" :placeholder="t('Please input field', { field: t('userMenu.remark') })" />
                </el-form>
            </div>
        </el-scrollbar>
        <template #footer>
            <div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
                <el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
                <el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
                    {{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
                </el-button>
            </div>
        </template>
    </el-dialog>
</template>

<script setup lang="ts">
import { reactive, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { ElForm, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { remoteMenuUrl } from '/@/api/backend/adminapi'

const formRef = ref<InstanceType<typeof ElForm>>()
const baTable = inject('baTable') as baTableClass

const { t } = useI18n()

const rules: Partial<Record<string, FormItemRule[]>> = reactive({
    created_at: [buildValidatorData({ name: 'date', title: t('userMenu.created_at') })],
})
let remoteUrl = remoteMenuUrl()
</script>
  • 4.封装API中添加树形菜单列表的路由
// 树形菜单列表的路由
export function remoteMenuUrl() {
    const adminInfo = useAdminInfo()
    let url = getUrl() + '/admin/api/remoteMenuUrl'
    return url;
}
  • 5.后端中获取树形菜单列表 (app\admin\controller\Api.php)
/**
 * 获取远程下拉数据(菜单树形目录)
 */
public function remoteMenuUrl()
{
    // 读取用户组所有权限规则
    $where = [
        ['type', 'in', ['menu_dir', 'menu']], 
        ['status', '=', '1']
    ];

    $model = new \app\admin\model\MenuRule();
    $rules = $model
        ->where($where)
        ->order('weigh desc,id asc')
        ->select()->toArray();
    $data = $this->tree->assembleChild($rules);
    $data = $this->tree->assembleTree($this->tree->getTreeArray($data, 'title'));
    $this->success('', [
        'options' => $data
    ]);
}

  • 6.用户级菜单列表的控制器
<?php

namespace app\admin\controller;
use app\admin\model\MenuRule;
use app\common\controller\Backend;

class UserMenu extends Backend
{
    protected $model = null;
    protected $preExcludeFields = ['id'];
    protected $quickSearchField = ['id'];

    public function initialize()
    {
        parent::initialize();
        $this->model = new \app\admin\model\UserMenu;
    }

    public function index()
    {
        $this->request->filter(['strip_tags', 'trim']);
        if ($this->request->param('select')) {
            $this->select();
        }

        list($where, $alias, $limit, $order) = $this->queryBuilder();
        $res = $this->model
            ->field($this->indexField)
            ->withJoin($this->withJoinTable, $this->withJoinType)
            ->alias($alias)
            ->where($where)
            ->order($order)
            ->paginate($limit);

        $list = $res->items();
        $menuRuleModel = new MenuRule();
        foreach ($list as &$item) {
            $item['type'] = $this->model->type[$item['type']];
            // 调用第7点的方法
            $item['menu_rule_id'] = $menuRuleModel->getFullMenuTitle($item['menu_rule_id']);
        }

        $this->success('', [
            'list'   => $list,
            'total'  => $res->total(),
            'remark' => get_route_remark(),
        ]);
    }
}
  • 7.系统自带的菜单模型中添加一个方法
public function getFullMenuTitle($id)
{
    $second = $this->where("id", $id)->value('title');
    $pid = $this->where("id", $id)->value('pid');
    $first = $this->where("id", $pid)->value('title');
    return $first . '-'. $second;
}
  • 8.用户级菜单模型
<?php
namespace app\admin\model;
use think\Model;

/**
 * UserMenu
 */
class UserMenu extends Model
{
    // 表名
    protected $name = 'user_menu';
    // 自动写入时间戳字段
    protected $autoWriteTimestamp = false;
    const TYPE_MENU = 1;
    const TYPE_BUTTON = 2;
    public $type = [
        self::TYPE_MENU => '目录',
        self::TYPE_BUTTON => '按钮'
    ];
}
三.用户权限菜单配置模块 (userMenuConfig)
  • 1.添加上面的用户表
  • 2.生成CURD代码
  • 3.编辑自带的添加表单, 下拉选项中, 添加1.用户级菜单, 2.用户, 3.设置值
1.用户菜单配置-列表
http://localhost:1818/#/admin/userMenuConfig
web\src\views\backend\userMenuConfig\index.vue

<template>
    <div class="default-main ba-table-box">
        <el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />

        <!-- 表格顶部菜单 -->
        <TableHeader
            :buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
            :quick-search-placeholder="t('quick Search Placeholder', { fields: t('userMenuConfig.quick Search Fields') })"
        />

        <!-- 表格 -->
        <!-- 要使用`el-table`组件原有的属性,直接加在Table标签上即可 -->
        <Table ref="tableRef" />

        <!-- 表单 -->
        <PopupForm />
    </div>
</template>

<script setup lang="ts">
import { ref, provide, onMounted } from 'vue'
import baTableClass from '/@/utils/baTable'
import { defaultOptButtons } from '/@/components/table'
import { baTableApi } from '/@/api/common'
import { useI18n } from 'vue-i18n'
import PopupForm from './popupForm.vue'
import Table from '/@/components/table/index.vue'
import TableHeader from '/@/components/table/header/index.vue'

const { t } = useI18n()
const tableRef = ref()
const optButtons = defaultOptButtons(['edit', 'delete'])
const baTable = new baTableClass(
    new baTableApi('/admin/userMenuConfig/'),
    {
        pk: 'id',
        column: [
            { type: 'selection', align: 'center', operator: false },
            { label: t('userMenuConfig.id'), prop: 'id', align: 'center', width: 70, operator: 'RANGE', sortable: 'custom' },
            { label: t('userMenuConfig.uid'), prop: 'uid', align: 'center', operator: 'RANGE', sortable: false },
            { label: t('userMenuConfig.user_menu_id'), prop: 'user_menu_id', align: 'center', operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
            { label: t('userMenuConfig.value'), prop: 'value', align: 'center', operator: 'RANGE', sortable: false },
            { label: t('userMenuConfig.created_at'), prop: 'created_at', align: 'center', operator: '=', sortable: 'custom', width: 160 },
            { label: t('operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false },
        ],
        dblClickNotEditColumn: [undefined],
    },
    {
        defaultItems: { uid: 0, user_menu_id: null, value: 0, created_at: null },
    }
)

provide('baTable', baTable)

onMounted(() => {
    baTable.table.ref = tableRef.value
    baTable.mount()
    baTable.getIndex()?.then(() => {
        baTable.initSort()
        baTable.dragSort()
    })
})
</script>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
    name: 'userMenuConfig',
})
</script>
<style scoped lang="scss"></style>
2.用户菜单配置-添加
web\src\views\backend\userMenuConfig\popupForm.vue
<template>
    <!-- 对话框表单 -->
    <el-dialog
        class="ba-operate-dialog"
        :close-on-click-modal="false"
        :model-value="['add', 'edit'].includes(baTable.form.operate!)"
        @close="baTable.toggleForm"
        width="50%"
    >
        <template #header>
            <div class="title" v-drag="['.ba-operate-dialog', '.el-dialog__header']" v-zoom="'.ba-operate-dialog'">
                {{ baTable.form.operate ? t(baTable.form.operate) : '' }}
            </div>
        </template>
        <el-scrollbar v-loading="baTable.form.loading" class="ba-table-form-scrollbar">
            <div
                class="ba-operate-form"
                :class="'ba-' + baTable.form.operate + '-form'"
                :style="'width: calc(100% - ' + baTable.form.labelWidth! / 2 + 'px)'"
            >
                <el-form
                    v-if="!baTable.form.loading"
                    ref="formRef"
                    @submit.prevent=""
                    @keyup.enter="baTable.onSubmit(formRef)"
                    :model="baTable.form.items"
                    label-position="right"
                    :label-width="baTable.form.labelWidth + 'px'"
                    :rules="rules"
                >
                    <FormItem
                        type="remoteSelect"
                        prop="uid"
                        label="用户"
                        v-model="baTable.form.items!.uid"
                        :placeholder="t('Click Select')"
                        :input-attr="{
                            params: { isTree: false },
                            field: 'title',
                            'remote-url': remoteUrlUserLocal,
                        }"
                    />
                    <FormItem
                        type="remoteSelect"
                        prop="menu_rule_id"
                        label="用户级菜单"
                        v-model="baTable.form.items!.user_menu_id"
                        :placeholder="t('Click Select')"
                        :input-attr="{
                            params: { isTree: false },
                            field: 'title',
                            'remote-url': remoteUrlUserMenuLocal,
                        }"
                    />
                    <FormItem 
                        :label="方式" 
                        type="radio" 
                        v-model="baTable.form.items!.value" 
                        prop="value" 
                        :data="{ content: { '-1': '禁用','0': '跟随用户组', '1': '启用' }}"
                    />
                </el-form>
            </div>
        </el-scrollbar>
        <template #footer>
            <div :style="'width: calc(100% - ' + baTable.form.labelWidth! / 1.8 + 'px)'">
                <el-button @click="baTable.toggleForm('')">{{ t('Cancel') }}</el-button>
                <el-button v-blur :loading="baTable.form.submitLoading" @click="baTable.onSubmit(formRef)" type="primary">
                    {{ baTable.form.operateIds && baTable.form.operateIds.length > 1 ? t('Save and edit next item') : t('Save') }}
                </el-button>
            </div>
        </template>
    </el-dialog>
</template>

<script setup lang="ts">
import { reactive, ref, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import type baTableClass from '/@/utils/baTable'
import FormItem from '/@/components/formItem/index.vue'
import type { ElForm, FormItemRule } from 'element-plus'
import { buildValidatorData } from '/@/utils/validate'
import { remoteUrlUser,remoteUrlUserMenu } from '/@/api/backend/adminapi'

const formRef = ref<InstanceType<typeof ElForm>>()
const baTable = inject('baTable') as baTableClass

const { t } = useI18n()


let remoteUrlUserLocal = remoteUrlUser()
let remoteUrlUserMenuLocal = remoteUrlUserMenu()
</script>
<style scoped lang="scss"></style>
3.封装URL(获取用户列表和用户菜单列表[后面改为可定义菜单列表吧])
    // 获取远程用户
    export function remoteUrlUser() {
        const adminInfo = useAdminInfo()
        let url = getUrl() + '/admin/api/remoteUrlUser'
        return url;
    }

    // 获取远程URL: 用户级菜单列表
    export function remoteUrlUserMenu() {
        const adminInfo = useAdminInfo()
        let url = getUrl() + '/admin/api/remoteUrlUserMenu'
        return url;
    }
4.编辑获取权限的方法 app\admin\controller\Index.php
<?php
declare (strict_types=1);

namespace app\admin\controller;

use app\common\facade\Token;
use ba\ClickCaptcha;
use think\facade\Config;
use think\facade\Validate;
use app\common\controller\Backend;
use app\admin\model\AdminLog;
use app\admin\model\UserMenuConfig;

class Index extends Backend
{

    public function index()
    {
        $adminInfo          = $this->auth->getInfo();
        $adminInfo['super'] = $this->auth->isSuperAdmin();
        unset($adminInfo['token'], $adminInfo['refreshToken']);

        $menus = $this->auth->getMenus(); // 可见菜单
        $menus = $this->_filterMenu($menus, $this->auth->getAdmin()->id);

        if (!$menus) {
            $this->error(__('No background menu, please contact super administrator!'));
        }
        $this->success('', [
            'adminInfo'  => $adminInfo,
            'menus'      => $menus,
            'siteConfig' => [
                'siteName' => get_sys_config('site_name'),
                'version'  => get_sys_config('version'),
                'cdnUrl'   => full_url(),
                'apiUrl'   => Config::get('buildadmin.api_url'),
                'upload'   => get_upload_config(),
            ],
            'terminal'   => [
                'installServicePort' => Config::get('terminal.install_service_port'),
                'npmPackageManager'  => Config::get('terminal.npm_package_manager'),
            ]
        ]);
    }

    /**
     * 过滤掉被禁用的菜单
     */
    public function _filterMenu($menus, $uid)
    {
        $userMenuConfigModel = new UserMenuConfig();
        $disableList = $userMenuConfigModel->getUserDisableMenu($uid);
    
        // 遍历菜单,删除被禁用的菜单及其子订单
        foreach ($menus as $key => $item) {
            if (isset($item['children'])) {
                $menus[$key]['children'] = $this->_filterMenu($item['children'], $uid);
            }
            if (in_array($item['id'], $disableList)) {
                unset($menus[$key]);
            }
        }
        return $menus;
    }
}
5.可定义菜单模型(app\admin\model\UserMenuConfig.php)
<?php

namespace app\admin\model;
use think\Model;

class UserMenuConfig extends Model
{
    // 表名
    protected $name = 'user_menu_config';

    // 自动写入时间戳字段
    protected $autoWriteTimestamp = false;

    const VALUE_DISABLE = -1;
    const VALUE_GROUP = 0;
    const VALUE_ABLE = 1;
    public $value = [
        self::VALUE_DISABLE => '禁用',
        self::VALUE_GROUP => '跟随用户组',
        self::VALUE_ABLE => '强制启用',
    ];
    
    // 获取用户被禁用的菜单
    public function getUserDisableMenu($uid)
    {
        $userMenuModel = new UserMenu();
        $list = $this->field(['user_menu_id'])->where('uid', $uid)
        ->where('value', self::VALUE_DISABLE)
        ->select()->toArray();
        
        $list = array_column($list, 'user_menu_id'); // 获取的是配置表的菜单ID, 需要再通过这个ID获取系统菜单ID
        
        $result = $userMenuModel->field('menu_rule_id')->whereIn('id', $list)->select()->toArray(); // 获取到最终禁用的菜单
        $result = array_column($result, 'menu_rule_id'); 

        return $result;
    }
}
6.增加共用的用户列表, 用户菜单列表获取
    // app\admin\controller\Api.php
    // 远程下拉列表之用户列表
    public function remoteUrlUser()
    {
        // 读取用户组所有权限规则
        $model = new \app\admin\model\AdminUser();
        $data = $model->getUserList();
        $this->success('', [
            'options' => $data
        ]);
    }

    // 获取用户级菜单权限
    public function remoteUrlUserMenu()
    {
        $model = new UserMenu();
        $data = $model->getRemoteSelect();
        $this->success('', [
            'options' => $data
        ]);
    }
其他.用户菜单配置模型
<?php

namespace app\admin\controller;
use app\admin\model\UserMenu;
use app\common\controller\Backend;

class UserMenuConfig extends Backend
{
    /**
     * UserMenuConfig模型对象
     * @var \app\admin\model\UserMenuConfig
     */
    protected $model = null;
   
    protected $preExcludeFields = ['id'];
    protected $quickSearchField = ['id'];

    public function initialize()
    {
        parent::initialize();
        $this->model = new \app\admin\model\UserMenuConfig;
    }


    /**
     * 若需重写查看、编辑、删除等方法,请复制 @see \app\admin\library\traits\Backend 中对应的方法至此进行重写
     */
    public function index()
    {
        $this->request->filter(['strip_tags', 'trim']);
        if ($this->request->param('select')) {
            $this->select();
        }

        list($where, $alias, $limit, $order) = $this->queryBuilder();
        $userModel = new \app\admin\model\AdminUser();

        $query = $this->model
            ->field($this->indexField)
            ->withJoin($this->withJoinTable, $this->withJoinType)
            ->alias($alias);

        $res = $query->where($where)
            ->order($order)
            ->paginate($limit);

        $list = $res->items();
        $userMenuModel = new UserMenu();
        foreach ($list as &$item) {
            $item['uid'] = $userModel->getNickname($item['uid']);
            $item['user_menu_id'] = $userMenuModel->getMenuName($item['user_menu_id']);
        }

        $this->success('', [
            'list'   => $res->items(),
            'total'  => $res->total(),
            'remark' => get_route_remark(),
        ]);
    }
}

目前一级菜单可以隐藏了, 不过隐藏二级菜单会导致一级菜单也隐藏, 继续...