深入探讨:搭建符合后台业务权限系统的经验手册

文章讲述了作者在后端开发中如何通过服务化和RBAC模型进行账号权限管理,包括使用PHP、Yii2、JWT等技术栈,强调了数据库设计、去标识化和数据权限分级的重要性,以及如何优化部署和前后端协作流程。

引言

工作 8年有余,基本都在做后端业务,期间搭建过十几个业务系统,总是要接触账户、权限的问题,深知账号的管理问题、数据安全问题的重要性,最佳的实现方式:服务化,尤其是在业务开展初期便需要搭建,否则后期公司业务变多,审计介入就会陷入改不动,理不清的技术困境,那是在做数据治理基本无望,而面对突发的安全风险就只能打补丁,这属实让人难以接受;

根据个人经验来看, 传统的 RBAC 即能满足 80% 的需求,跟部门关联就能满足 99% 的业务需求,于是就搭建一个权限项目以便参考。

技术栈

  1. 后端:
    • 语言:PHP1
    • 框架:Yii 2
    • 数据库:Mysql、Redis

  2. 前端:
  3. 数据存储:
    • 缓存:Redis

  4. 安全性:
    • 认证和授权:JWT (JSON Web Tokens) 2
    • 数据加密:

  5. 部署和运维:
    • 服务器:Ubuntu 20.04 WSL
    • 容器化:Docker
    • 自动化部署:[自动化部署工具]

  6. 其他:
    • 日志记录:Yii Logger
    • 后端测试框架:Yii.Codeception3
    • 前端测试框架: Vue Test Utils

因为前端框架引入的 axios 版本过低存在安全风险,应升级,需注意不要升级到 IDE 建议的 0.21.2,会导致拦截器只能生效一个,建议大于该版本,我是 0.27.2

数据库设计

ER 图
ER 图

说明

文章有对应资源下载的链接,可详细查看对应字段设计;

针对 department 的表设计初衷主要有两点

  • 第一点满足业务需求,因为通常数据权限最小颗粒度为 API 级别,而往往一线业务特性比较复杂,例如 GET /users (获取用户列表),正常是获取绑定他的数据,可如果业务存在层级关系(必然),主管/经理需要额外获取自己组下的人员信息,此时该表就体现作用,方便递归查询对应组内成员的数据4
  • 第二点是统筹管理,因为现在的企业基本都要对接 钉钉、企业微信等,而他们(hr)会设置对应组织架构,当触发上述行为通过 webhook 方式对应创建我们服务的数据,反之亦然,从而保持数据的一致性,避免乌龙。

针对 admin_rules 表的设计中重点如下

  1. 权限应当支持 菜单、按钮、数据权限(API)维度,因为服务化,则新增 project 项目的概念;
  2. 对接前端只是给 标识符、名称、排序(当前没用),至于其他的体验下来是没有必要的,因为在路由匹配规则是前端为主,来跟后端数据匹配然后替换;
  3. 这里需要特别注意此表没有唯一键判定,业务中也没有,即该数据权限(API) 理应包含在每一个菜单下,从而解决配置不清晰的问题;
  4. 数据权限新增敏感等级,也是为后续数据治理,风控做准备,设置 5级,如下图所示。
等级常规数据去标识化个人数据敏感数据机密数据查询采取措施
0级×××日志记录
1级××××日志记录、API 风控
2级××日志记录、API 风控
3级×日志记录、API 风控、数据审计
4级××日志记录、API 风控、数据审计
5级日志记录、API 风控、数据审计、数据审批

去标识化:通过对个人信息的技术处理,使其在不借助额外信息的情况下,无法识别的个人信息,或者关联个人信息主体的过程,而现在注册都要手机号 (实名), 所以我们自身是具备溯源的能力,所以只能叫做去标识化。

敏感数据分为两个类别: 个人信息、公司信息;
个人信息定义敏感:可以根据能否溯源到个人来定义,例如手机号、身份证号、账号&密码、照片、地址等等,也可以根据数量来定义,例如一次获取 X 条数据。
公司信息: 可以根据涉及公司核心业务来定义,例如详细销售报表,部分密钥,部分账号等,具体应当公司部门来指定;

时序图

访问网站应用时序图

①:setToken

如果下载 vue-template 基础模板 需要切换 permission-control 分支

解决 token 存取问题,使用 LocalStorage 持久化保存 token,使用 store 解决 Axios 请求自定义 Header 头的处理。

关于关闭浏览器是否需要重新登录的问题,而所谓的安全问题应当 ip 限制(即 VPN)来解决, 而不能过于强调 ”所谓的安全“ 来牺牲用户体验。

相关代码如下

  // user.js
  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ username: username.trim(), password: password }).then(response => {
        const { data } = response
        // 方便 store 进行更新
        commit('SET_TOKEN', data.token)
        setToken(data.token)
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },
// request.js
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
  timeout: 5000 // request timeout
})

// request interceptor
service.interceptors.request.use(
  config => {
    // 授权验证
    if (store.getters.token) {
      config.headers['Authorization'] = 'Bearer ' + store.getters.token
    }
    return config
  },
  error => {
    // do something with request error
    console.log(error) // for debug
    return Promise.reject(error)
  }
)

② checkAccess (后端)

读取缓存,具体可以看 ③ 返回权限数据 里说明的存取结构说明。

相关代码

    public function checkAccess(): bool
    {
        $path = Yii::$app->requestedRoute;
        if(str_ends_with($path, '/index')) {
            $path = substr($path, 0, -strlen('/index'));
        }
        $uniKey = Yii::$app->request->method. ' /'. $path;
        if(isset(self::$whiteList[$uniKey])) {
            return true;
        }

        $roles = User::getRoles();
        // 默认 admin 拥有所有权限
        if(in_array('admin', $roles)) {
            return true;
        }

        $redis = Has::t()->redis;
        $rules = [];
        foreach ($roles AS $v) {
            $tempRules = $redis->get(PrefixWrapper::getRole(APP_NAME, $v, AdminRule::RULE_TYPE_API));
            if($tempRules) {
                $rules = array_merge($rules, json_decode($tempRules, true));
            }
        }
        $rules = array_flip($rules);
        return isset($rules[$uniKey]);
    }

因为采取 Restful 的请求方式,会存在 path 存在参数,即 PATCH user/1 ,所以这里是调用Yii::$app->requestedRoute 方法, 从而获取 user/update(接合 method ,就变成我配置的 API 路径),从而解决二次正则匹配的场景,避免性能问题。

PS: 因为配置 Yii 通过 rules 路由规则匹配到控制器的方法,如果换其他语言其实一样,只是那样可能要写在更外一层了。

后面如果服务化,可以封装下并且实现 redis 连接池,内部服务使用 RPC 调用服务间调用,从而解决网络开销和连接开销。(当然也可以换语言,例如 Go 其实就很好的实现这个,小巧而性能强悍)

③ 返回权限数据

这里主要说明下, 用户的权限数据应当从 Redis 获取到的,而并非读取表来获取,从而提升性能;

对应缓存设计应当选用 K-V 结构即可,存储 Key 格式应当遵循 project:table:project-role:type, 这里 project-role 和 type 是项目和角色的关联和类型,是为了控制 value 的大小,避免性能问题,如下图所示。
在这里插入图片描述

的返回示例, 这里是没有采用递归处理的,这样前端反而会好算一些。

{
    "code": 200,
    "msg": "成功",
    "data": {
        "menu": [
            {
                "name": "account-manage",
                "title": "账号管理",
                "sort": 0
            },
            {
                "name": "account",
                "title": "员工管理",
                "sort": 0
            },
            {
                "name": "role",
                "title": "角色管理",
                "sort": 0
            },
            {
                "name": "department",
                "title": "部门管理",
                "sort": 0
            },
            {
                "name": "rule",
                "title": "权限管理",
                "sort": 0
            },
            {
                "name": "personal-center",
                "title": "个人中心",
                "sort": 0
            }
        ],
        "list": [],
        "button": [
            "rule-add"
        ]
    }
}

④加载路由配置

当获取后端吐出的 rules,将原本从 roles 的判定改为 rules 的判定, 相关代码如下所示。

modules\permission.js 代码

export function filterAsyncRoutes(routes, rules) {
  const res = []
  // 循环第一个数组
  routes.forEach(route => {
    const tmp = { ...route }
    // 查找 routes 具有相同 Name 值的元素 即 Name
    const matchingElement = rules.find(item => item.name === tmp.name)
    // 如果找到匹配的元素,将其添加到匹配元素数组中
    if (matchingElement) {
      tmp.sort = matchingElement.sort
      if (tmp.meta) {
        tmp.meta.title = matchingElement.title
      }
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, rules)
      }
      res.push(tmp)
    }
  })
  return res
}

另外调用父级需要额外判定是否为 admin 角色, 该 store 是获取用户信息的时候存取的。

const actions = {
  generateRoutes({ commit }, rules) {
    return new Promise(resolve => {
      let accessedRoutes = asyncRoutes
      // 判断是否为 admin 角色
      if (!store.state.user.roles.includes('admin')) {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, rules)
        accessedRoutes.push({ path: '*', redirect: '/404', hidden: true })
      }
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

⑤: v-auth 按钮权限的实现

在 directive 目录,生成相关代码

/**
 * @description 鉴权指令
 * 判断当前按钮权限是否存在,
 * 当传入的权限当前用户没有时,会移除该组件
 * 用例:<Tag v-auth="'user-button'">text</Tag>
 * */
import store from '@/store'

export default {
  inserted(el, binding) {
    const { value } = binding
    const buttons = store.state.user.buttons
    const roles = store.state.user.roles
    if (value && value.length && buttons && buttons.length && !roles.includes('admin')) {
      const isPermission = buttons.includes(value)
      if (!isPermission) {
        el.parentNode && el.parentNode.removeChild(el)
      }
    }
  }
}

相关页面展示

权限管理页面
部门管理页面

角色管理页面

员工管理页面

项目部署

TODO 待定,想开发 go 的后台后,在统一打包分享。


  1. 编程语言可以平替,尤其涉及账号中台服务建议切换 Java、Go,只是提者图方便# 使用技术栈 ↩︎

  2. lcobucci/jwt: 解决 token 生成 ↩︎

  3. Yii的单元测试框架 Codeception 基于 PHPUnit,Codeception 建议遵从 PHPUnit 的文档的进行开发: ↩︎

  4. 因为业务需求很灵活,而从数据敏感程度上二者区别不大,如果新增 API 就会造成前端和后端额外的工作量,也增加测试成本,属实没必要。 ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值