前后端分离权限逻辑

944 阅读8分钟

背景

近年来,随着 React、Angular、Vue 等前端框架的崛起,前后端分离的架构模式得到了广泛的应用和推广。然而,这种架构模式也带来了新的挑战,其中之一就是权限控制问题。在这里,我们将一起探讨在前后端分离的架构中如何实现有效的权限控制逻辑。

image.png 在前端后端分离的架构下,服务端负责提供资源数据,而前端则需要根据用户的角色信息来动态显示系统资源、菜单资源和按钮资源。前端通常会通过向服务端请求用户的角色信息,然后根据角色信息来判断用户的权限,进而决定展示哪些系统资源、菜单资源和按钮资源。这种权限控制机制能够确保用户只能访问其具有权限的资源,从而提高系统的安全性和用户体验。

RBAC模型

RBAC 全称 Role-Based-Access-Control, 即基于角色的访问控制。接下来逐步了解下RBAC模型。 首先,每个业务的产生都有其特定的背景和需求,权限系统作为一个必要的组成部分也不例外。 主要包含如下几个重要原因

  1. 保护数据安全:一些敏感或有限制的数据需要进行权限控制,以防止非法分子的越权窃取。
  2. 细分用户权限:在公司内部或团队中,不同的人员需要扮演不同的角色,各司其职。权限系统可以根据用户的角色来限制其操作权限,确保每个人只能访问和操作他们需要的资源。
  3. 提供定制化服务:许多产品提供不同的定价版本,如个人版本、团队版本、公司版本等。权限系统可以根据用户的订阅级别来控制他们可以访问和使用的功能和数据范围,从而实现定制化服务。

总之,权限系统是为了保护数据安全、细分用户权限和提供定制化服务而存在的。基于角色的权限访问控制模型(RBAC)是一种流行的实现方式,它通过将用户与角色关联,并为每个角色分配相应的权限,来实现对资源的控制。

基础模型

在设计一个权限系统时,最基础的需求是给用户分配权限。一个用户可以被分配多个权限。然而,这种简单的权限模型在用户数量较少的系统中可能很容易维护,但对于具有成千上万用户的系统来说,会变得非常困难。为了解决这个问题,引入了角色的概念。通过给角色授予权限,不同的角色拥有不同的权限,而用户只需要关联某个角色,就能够获得该角色所拥有的全部权限。

这种方式的好处是简化了权限管理的复杂性。当需要修改某个权限时,只需要修改与该权限相关联的角色,而不需要逐个修改每个用户的权限。这样可以大大减少维护工作量,并提高系统的可扩展性和灵活性。

角色继承

基础模型是一种经典且简单的模型,也是目前最通用的模型。然而,在处理复杂一点的业务时,比如公司组织架构划分,需要对该模型进行调整。在这种情况下,我们可以采用角色继承模型来解决问题。角色继承模型考虑了上下级之间的关系,并且高级别角色会继承低级别角色的权限。这样一来,可以更好地满足复杂业务需求。

image.png 比如公司的部门数据是不应该给所有员工看的,部门负责人或者经理是可以看本部门所有数据的,而组长只能操作自己小组的数据,不能操作其他小组的数据。

角色权限控制

上述其实已经满足大部分业务了,但是实际生活中,难免会遇到这样的情况:

  • 一个人不能 同时 扮演会计和审核员两个角色;
  • 部门经理只有一个;
  • 员工成为正式岗位前可能会经过实习期、试用期阶段,可用的权限不一样;

以上三个场景分为对应了:角色互斥性、角色唯一性、角色先决条件 等特征,这正是角色模型的内容。这个模型的好处就是比如在使用某个系统的时候,不同角色操作同一份数据时可能存在冲突,此时需要限定同一时间只能使用一个身份进行数据操作。

新RBAC模型

传统的基于角色的访问控制(RBAC)存在一个问题,即用户想要拥有某些权限必须先赋予对应的角色。当用户权限发生变化时,需要通过添加一个角色来应对变化,这在编码上会导致维护困难。比如部门管理中,部门经理可以修改某些数据,用伪代码可以这么判断:

if (user.hasRole('manager')) {
    //修改数据代码
}

当业务变更,组长也可以修改数据时:

if (user.hasRoles(['manager', 'team_leader'])) {
    //修改数据代码
}

这样代码维护就成了问题,所以基于资源的权限访问控制模型(Resource-Based-Access-Control)就流行了起来,在传统模型基础上,它让用户也可以直接关联权限,这样就更加灵活了。

还是拿上面的场景来说,现在直接关联权限后:

if(user.hasPermission("修改权限标识")) {
    //修改数据代码
}

这种基于权限点判断的方式更加灵活,这样用户从部门经理变更为组长的身份后,不需要修改代码,代码的拓展性强。

前端权限设计方案

前端权限的意义

权限控制是指对用户的操作和视图进行控制的机制。通常情况下,权限的实现需要前后端开发人员共同合作。后端开发人员可以被比喻为守门员,他们负责守住系统的最后一道防线,确保只有具备相应权限的用户才能进行敏感操作。而前端开发人员则扮演着阻挡者的角色,在用户进行敏感操作之前,通过界面设计和交互逻辑的控制,减轻了后端守门员的压力,提高了系统的安全性。综上,前端权限的控制, 主要有这⼏⽅⾯的好处

  • 降低非法操作的可能性
    • 在⻚⾯中展示出⼀个就算点击了也最终会失败的按钮, 势必会增加非法操作的可能性
  • 尽可能排除不必要请求,减轻服务器压⼒
    • 没必要或者操作失败的请求、不具备权限的请求, 就不需要发送, 请求少了也会减轻服务器的压⼒
  • 提高用户体验
    • 根据⽤户具备的权限为该⽤户展现⾃⼰权限范围内的内容,避免在界⾯上给⽤户带来困扰, 让⽤户专注于分内之事

方案设计

前端权限设计方案通常是通过接收后台发送的数据,然后将数据注入到应用中。这样,整个应用就可以开始对页面的展现内容以及导航逻辑进行控制,从而实现权限控制的目的。虽然前端做的权限控制可以提供一层防护,但其根本目的是为了优化用户体验。

接下来详细分析下集团管理后台权限控制如何实现的?

其中二级+三级对应菜单权限、四级对应按钮权限控制

系统权限

单个角色用户包含多个系统资源权限,用户通过登录界面后获取其包含的一级资源列表 路由守卫:

router.beforeEach(async (to, from, next) => {
     /* 1.先判断目标地址是否在白名单,若在免登录白名单,直接进入 */
     if (whiteList.includes(to.path)) {
            return next();
     }
     /**
         2.判断用户是否登录,若未登录则跳转到登录页
         通过next()函数将用户重定向到登录页面,并携带重定向参数redirect。
         如果用户当前访问的路径是登录页面、默认页面或管理页面,则直接跳转到登录页面;
         否则跳转到登录页面并携带重定向参数,以便用户在登录后可以跳转回原来想要访问的页面
     */
     if (!getToken()) {
        let redirect = [loginRoutePath, defaultRoutePath, manageRoutePath].includes(to.path) ? '' : `?redirect=${encodeURIComponent(location.href)}`;
        next(`${loginRoutePath}${redirect}`);
        return;
     }
     /*
         3.,判断用户信息是否存在,若不存在则获取用户信息,且走获取所有系统接口store方法获取系统列表
     */
     if (isEmpty(userStore.currentUser)) {
        userStore.getUserInfoHandle();//获取用户信息
        appStore.getSystemsList();//获取系统列表,存本地,
     }
})

store目录下modules 的app.js文件,调取当前用户包含的所有一级资源,并缓存在state.systemList,并将当前系统缓存到本地浏览器

相关代码store/modules/app.js

export const useAppStore = defineStore('app', {
    state: () => ({
        systemList: [],//系统列表
        ......
    }),
    actions:{
        /*2.获取系统列表*/
        getSystemsList() {
            return new Promise(async (resolve) => {
                const system = await getSystems();
                if (system?.data?.status === 200 && system?.data?.items) {
                    //缓存系统列表到state.systemList
                    this.systemList = system.data.items;
                    let current = this.systemList.find((el) => el.id === Number(this.systemId));
                    //存储当前系统
                    setSystem(JSON.stringify(current));
                    /*
                    setSystem实现为
                    const currentSysKey = '_c_s';
                    export function setSystem(system) {
                        return Cookies.set(currentSysKey, system);
                    }
                    存到本地为
                    _c_s:{
                        "id":xxx,
                        "platform":xxx,
                        "logicalDel":xxx,
                        "createTime":"2019-10-23 15:10:35",
                        "updateTime":"2022-11-24 13:51:25",
                        "name":"xxx",
                        "description":"xxx",
                         ......
                     }
                    */
                }
                resolve();
            });
        },
    }
})

菜单权限

在前端实现菜单权限管理时,通常会将路由分为动态路由和静态路由

  • 初始路由文件中只会注入静态路由,这些静态路由是固定的,与用户的权限角色无关
  • 动态路由则会根据用户的角色权限动态生成和注入,确保用户在登录后只能访问其具有权限的页面

这种动态和静态路由结合的方式可以有效管理菜单权限,提高系统安全性和灵活性。

路由定义

在根目录的src/router/index.js项目路由文件中,定义动态路由和静态路由

  • 动态路由是根据用户角色动态渲染的路由,例如只有特定角色的用户登录后才能访问的列表页等。
  • 静态路由是固定的路由,与用户角色无关,例如一级系统主页、登录页、SSO跳转页等。这些路由不受用户角色权限的影响,始终保持固定不变。
//静态路由
export const constantRouterMap = [
    {
        path: '/',
        name: 'Layout',
        redirect: '/home',
        component: Layout,
        meta: {
            title: '首页',
        },
        children: [{ path: '/home', name: 'home', component: () => import('@/views/home/index.vue') }],
    },
    { path: '/login', component: () => import('@/views/login/index.vue'), hidden: true },
    ......
];
//动态路由
export const asyncRouterMap = [
    {
        path: '/Bmanage',
        name: 'Bmanage',
        component: Layout,
        meta: {
            name: 'B单管理',
            icon: 'Operation',
        },
        children: [......],
    },
    ......
]
//路由文件只注入静态路由
export  default  new Router({
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRouterMap,
})

路由生成

跳转到sso的连接会携带ticket参数,该参数由当前用户token和所点击的系统id拼接而成

//点击一级系统主页面中某个系统会按照当前代码跳转
location.assign(`${location.origin}#sso/?timestamp=${timestamp}&ticket=${getToken()}!${currentSystem.id}`);
  • 其中token用于保存用户的身份信息,用于认证用户的身份;
  • 而系统id则用于获取相应的资源参数,以便系统能够正确地识别和加载用户所需的资源;

这种拆分方式能够有效地区分用户身份信息和系统资源参数,从而实现更加精准和安全的身份认证和资源管理。

sso.vue中根据token和系统id,获取系统的模块、菜单、按钮等权限资源列表

const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
//获取ticket
const ticket = computed(() => route.query.ticket);

const init = async () => {
    //获取token
    const arr = ticket.value.split('!');
    //设置token
    setToken(arr[0]);
    //获取系统id
    const systemId = arr[1];
    //获取系统的模块,菜单,按钮等权限资源列表
    const [err, res] = await asyncFunErr(getSystemResource(systemId));
    if (err) return;
    if (res.data.status === 200) {
        let value = JSON.stringify(res.data.items);
        //将后台返回的权限资源存储到本地
        localStorage.setItem(resourcesKey, value);
         //关键:根据拿到的后台返回资源,生成路由
        appStore.getSystemResources(res.data.items, systemId);
        //跳转到首页
        router.push('/');
    } else {
        ElMessage.error(res.data.msg);
    }
};

onMounted(() => {
    ElLoading.service({
        text: '资源权限加载中',
        background: 'rgba(0, 0, 0, 0.5)',
    });

    init();
});

onBeforeUnmount(() => {
    ElLoading.service().close();
});

在store/modules/app/index.js里的actions定义了getSystemResources方法,根据拿到的后台返回资源,生成路由

state: () => ({
    systemId: '',//系统id
    routes: [],//路由
    systemList: [],//系统列表
    isCollapse: false,//是否折叠
}),
actions:{
    //拿到后台返回的资源,生成路由表
    getSystemResources(resources, systemId) {
        //设置系统id
        this.systemId = systemId;
         //过滤23级(模块和页面权限列表) 
        let accessedRouters = filterAsyncRouter(asyncRouterMap, resources);
        //设置路由的base,微前端下,修改路由配置,所有路由增加前缀
        routerBaseUrl(accessedRouters, qiankunBasePath);
        //设置layout,微前端下,修改路由配置,将所有 layout 组件替换为微前端下的 layout 组件
        setLayout(accessedRouters, Layout);
        //设置路由
        this.routes = accessedRouters;
         //添加路由,生成可访问的路由表  
        accessedRouters.forEach((item) => {
            router.addRoute(item);
        });
    }
}

具体的过滤异步路由和按钮的方法filterAsyncRouter、filterButtonPermission方法如下,同时也包含 hasPermission判断权限方法

这里通过type过滤非按钮级资源

/**
 * 判断权限方法,根据用户角色资源过滤路由 
 * */
function hasPermission(hashMenus, route) {
    return hashMenus[route.path] || hashMenus['/' + route.path];
}

/**
 * 递归过滤异步路由表,返回符合用户角色权限的路由表
 * @param asyncRouterMap
 * @param rolesRouterMap 接口中获取到的用户角色资源
 * @returns {*}
 */
export default function filterAsyncRouter(asyncRouterMap, rolesRouterMap) {
    let hashMenus = {};
    let setMenu2Hash = function (array, base) {
        array.forEach((key) => {
            // 获取非按钮级资源 并赋值给hashMenus
            if (key.path && key.type < 4) {
                let hashKey = ((base ? base + '/' : '') + key.path).replace(/^//, '');
                hashMenus['/' + hashKey] = true;
            }
        });
    };
    // 如果是动态资源权限是数组就执行资源赋值
    if (Array.isArray(rolesRouterMap)) {
        setMenu2Hash(rolesRouterMap);
    }
    //最终返回动态路由path与当前角色系统资源path一致的(非按钮级资源)数据
    return asyncRouterMap.filter((route) => {
        if (hasPermission(hashMenus, route)) {
            if (route.children && route.children.length) {
                route.children = filterAsyncRouter(route.children, rolesRouterMap);
            }
            return true;
        }
        return false;
    });
}

按钮权限

对于按钮权限,我们可以通过自定义指令并传入相应的权限值来控制按钮的显示和禁用。通过这种方式,我们可以实现在Vue应用中根据用户权限动态控制按钮的显示和禁用,从而实现按钮权限管理的功能。

定义指令

getSystemResources方法,根据拿到的后台返回资源,生成可访问路由的同时,调用了filterButtonPermission方法,对系统4级权限(按钮权限)列表进行过滤,并将其存到了state.btnPermission变量中

state: () => ({
    btnPermission: {},//按钮权限
    ......
}),
actions:{
    //拿到后台返回的资源,生成路由和按钮权限列表
    getSystemResources(resources, systemId) {
        ......
         //过滤4级(按钮权限列表) 
        this.btnPermission = filterButtonPermission(resources);
    }
}

过滤按钮级权限方法

这里依旧根据type进行的区分,type为4表示按钮级别权限

/**
 * 过滤按钮级权限
 *  @param {Array} list
 *  @returns {Object}
 * */
export function filterButtonPermission(list) {
    let result = {};
    list.forEach((item) => {
        if (item.type < 3) return;
        let key = item.type === 4 ? item.httpMethod.toLowerCase() + ',' + item.path : item.httpMethod.toLowerCase() + ',' + item.path + ',type3';
        result[key] = true;
    });

    return result;
}

在项目根目录的src/directive/has.js中定义方法

export  default {
  bind: function (el, binding) {
    if (!Vue.prototype.$_has(binding.value)) {
      Vue.nextTick(function () {
        el.className = el.className + ' is-disabled'
        el.setAttribute('disabled', 'disabled')
      })
    }
  },
}

然后在全局挂载定义

Vue.prototype.$_has = function (rArray) {
    //页面按钮权限校验
    let resources = [];
    //初始化为true,表示初始权限为通过。
    let permission = true;
    if (Array.isArray(rArray)) {
        rArray.forEach(function (e) {
            resources = resources.concat(e);
        });
    } else {
        resources = resources.concat(rArray);
    }
    resources.forEach(function (p) {
        //如果没有权限,返回false
        if (!store.getters.resourcePermission[p]) {
            return permission = false;
        }
    });
    return permission;
}

模板应用

<el-button @click="add" type="primary" v-has="permission.add">
  '添加'
</el-button>

export  const permission = {
  add: ['post,/assessment/product/add'],
  export: ['post,/assessment/product/export'],
  update: ['post,/assessment/product/update'],
  log: ['post,/assessment/product/log'],
}