组建策划之可插拔权限组件(上):RBAC

rbac组件全面优化及知识点详解

Posted by QQL on January 14, 2019

组件实现效果图

角色列表.gif

项目源代码

模型model

业务场景:不同用户拥有不同的权限,权限的本质则=能访问URL的数量。用户级别越高,权限越高,能访问的URL也越多
表设计:用户表、角色表、权限表、菜单表
用户表
 元数据Meta:abstract。让实际业务的用户表继承RBAC的用户表
 用户表字段可自行根据业务场景进行修改,但必须保留roles字段多对多角色表
角色表
 字段内容不变,需要与权限表进行多对多permissions
权限表
 字段内容不变,需要与菜单menu进行多对一
 pid与自身进行ForeignKey,to=Permissionto='self'一样的效果,有pid的为三级权限,即不能成为菜单的权限
菜单表
 能够成为一级菜单的权限

from django.db import models

class Menu(models.Model):
    """
    菜单
    """
    title = models.CharField(verbose_name='菜单', max_length=32)
    icon = models.CharField(verbose_name='图标', max_length=32)

    def __str__(self):
        return self.title


class Permission(models.Model):
    """
    权限表
    """
    title = models.CharField(verbose_name='标题', max_length=32)
    url = models.CharField(verbose_name='含正则的URL', max_length=128, unique=True)

    name = models.CharField(verbose_name='代码', max_length=64, unique=True, null=False, blank=False)

    # 让不能作为菜单的权限,关联能成为菜单的权限。为了进入非菜单URL后,左侧菜单栏自动展开和此URL关联的权限菜单
    pid = models.ForeignKey(verbose_name='默认选中权限', to='Permission', related_name='ps', null=True, blank=True,
                            help_text="对于无法作为菜单的URL,可以为其选择一个可以作为菜单的权限,那么访问时,则默认选中此权限",
                            limit_choices_to={'menu__isnull': False}, on_delete=models.SET_NULL)

    menu = models.ForeignKey(verbose_name='菜单', to='Menu', null=True, blank=True, help_text='null表示非菜单',
                             on_delete=models.SET_NULL)  # 如果有meue值就为1级菜单,没有则为2级菜单

    def __str__(self):
        return self.title


class Role(models.Model):
    """
    角色
    """
    title = models.CharField(verbose_name='角色名称', max_length=32)
    permissions = models.ManyToManyField(verbose_name='拥有的所有权限', to='Permission', blank=True)

    def __str__(self):
        return self.title


class UserInfo(models.Model):
    """
    用户表
    """
    username = models.CharField(verbose_name='用户名', max_length=32,null=True,blank=True)
    password = models.CharField(verbose_name='密码', max_length=64,null=True,blank=True)
    email = models.CharField(verbose_name='邮箱', max_length=32,null=True,blank=True)
    roles = models.ManyToManyField(verbose_name='拥有的所有角色', to=Role, blank=True)

    class Meta:
        abstract = True

编写业务的用户登陆,并初始化用户权限

在主业务中编写用户登录视图,登陆成功后进行权限的初始化

def login(request):
    if request.method == 'GET':
        return render(request, 'login.html')

    user = request.POST.get('username')
    pwd = request.POST.get('password')

    user_object = models.UserInfo.objects.filter(name=user, password=pwd).first()
    if not user_object:
        return render(request, 'login.html', {'error': '用户名或密码错误'})

    # 用户权限信息的初始化
    init_permission(user_object, request)

    return redirect('/index/')

在用户登陆后,需要进行初始化该用户的权限

#!/usr/bin/env python
# -*- coding:utf-8 -*-
from django.conf import settings


def init_permission(current_user, request):
    """
    用户权限的初始化
    :param current_user: 当前用户对象
    :param request: 请求相关所有数据
    :return:
    """
    # 2. 权限信息初始化
    # 根据当前用户信息获取此用户所拥有的所有权限,并放入session。
    # 根据角色获取所有权限
    # permissions__id__isnull=False为了防止新增加了职位,但是没有数据为null,需要对该数据进行排除
    # distinct()防止同一个用户有多个职位,多个职位有相同的权限,而出现重复数据,需要进行去重处理
    permission_queryset = current_user.roles.filter(permissions__isnull=False).values("permissions__id",
                                                                                      "permissions__title",
                                                                                      "permissions__url",
                                                                                      "permissions__name",
                                                                                      "permissions__pid_id",
                                                                                      "permissions__pid__title",
                                                                                      "permissions__pid__url",
                                                                                      "permissions__menu_id",
                                                                                      "permissions__menu__title",
                                                                                      "permissions__menu__icon"
                                                                                      ).distinct()

    # 3. 获取权限+菜单信息
    permission_dict = {}

    menu_dict = {}

    for item in permission_queryset:
        ###### 处理权限 ######
        permission_dict[item['permissions__name']] = {
            'id': item['permissions__id'],
            'title': item['permissions__title'],
            'url': item['permissions__url'],
            'pid': item['permissions__pid_id'],
            'p_title': item['permissions__pid__title'],
            'p_url': item['permissions__pid__url'],
        }

        ###### 处理菜单 ######
        menu_id = item['permissions__menu_id']  # 拿到menu_id用于确认1级菜单
        if not menu_id:  # 没有menu_id(null)
            continue

        # menu_id有值,则该权限为menu_id的1级菜单所属的2级菜单
        node = {'id': item['permissions__id'],  # 拿到有menu_id的,权限id,即二级菜单的id
                'title': item['permissions__title'],
                'url': item['permissions__url']}

        if menu_id in menu_dict:  # 该menu_id已经为1级菜单,则为该1级菜单添加2级菜单
            menu_dict[menu_id]['children'].append(node)  # 在1级菜单下添加2级菜单
        else:  # 添加新的1级菜单
            menu_dict[menu_id] = {
                'title': item['permissions__menu__title'],
                'icon': item['permissions__menu__icon'],
                'children': [node, ]  # 添加2级菜单
            }

    # 将权限信息和菜单信息 放入session
    request.session[settings.PERMISSION_SESSION_KEY] = permission_dict
    request.session[settings.MENU_SESSION_KEY] = menu_dict


获得该用户对应的菜单和权限组成字典

菜单menu_dict
{
	"1": {
	"title": "客户管理",
	"icon": "fa-address-book-o",
		"children": [
			{
			"id": 22,
			"title": "客户列表",
			"url": "/stark/crm/customer/list/"
			},
			{
			"id": 26,
			"title": "公户",
			"url": "/stark/crm/customer/pub/list/"
			},
			{
			"id": 30,
			"title": "私户",
			"url": "/stark/crm/customer/pri/list/"
			}
		]
	},
	"2": {
		"title": "教学管理",
		"icon": "fa-bar-chart-o",
		"children": [
			{
			"id": 10,
			"title": "课程列表",
			"url": "/stark/crm/course/list/"
			},
			{
			"id": 18,
			"title": "班级列表",
			"url": "/stark/crm/classlist/list/"
			},
			{
			"id": 42,
			"title": "学生列表",
			"url": "/stark/crm/student/list/"
			},
			{
			"id": 46,
			"title": "上课记录",
			"url": "/stark/crm/courserecord/list/"
			}
		]
	},
........
}


权限permission_dcit
{
  'stark:crm_department_changelist': {
    'id': 1,
    'title': '部门列表',
    'url': '/stark/crm/department/list/',
    'pid': None,
    'pid_url': None,
    'pid_name': None
  },
  'stark:crm_department_add': {
    'id': 2,
    'title': '添加部门',
    'url': '/stark/crm/department/add/',
    'pid': 1,
    'pid_url': '/stark/crm/department/list/',
    'pid_name': 'stark:crm_department_changelist'
  },
  'stark:crm_department_change': {
    'id': 3,
    'title': '修改部门',
    'url': '/stark/crm/department/(?P<pk>\\d+)/change/',
    'pid': 1,
    'pid_url': '/stark/crm/department/list/',
    'pid_name': 'stark:crm_department_changelist'
  },
  'stark:crm_department_del': {
    'id': 4,
    'title': '删除部门',
    'url': '/stark/crm/department/(?P<pk>\\d+)/del/',
    'pid': 1,
    'pid_url': '/stark/crm/department/list/',
    'pid_name': 'stark:crm_department_changelist'
  },
  'stark:crm_userinfo_changelist': {
    'id': 5,
    'title': '用户列表',
    'url': '/stark/crm/userinfo/list/',
    'pid': None,
    'pid_url': None,
    'pid_name': None
  },
.......
}

在settings.py中配置初始化用户权限session的key

# 自定制key
MENU_SESSION_KEY = "u8fkksjdfkjsf"
PERMISSION_SESSION_KEY = "u8fkkasssjdfkjsfffff"

在中间键中,过滤出登陆用户能够访问的URL,即该用户拥有的权限

在rbac这个app内创建middleware/rbac.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import re
from django.utils.deprecation import MiddlewareMixin
from django.shortcuts import HttpResponse, reverse, redirect
from django.conf import settings


class RbacMiddleware(MiddlewareMixin):
    """
    用户权限信息校验
    """

    def process_request(self, request):
        """
        当用户请求刚进入时候出发执行
        :param request:
        :return:
        """

        """
        1. 获取当前用户请求的URL
        2. 获取当前用户在session中保存的权限列表 ['/customer/list/','/customer/list/(?P<cid>\\d+)/']
        3. 权限信息匹配(匹配路径这种有正则符号的字符串不能使用==,需要使用re.match(样本,比较的素材)进行正则匹配)
        """
        # 1.白名单中的URL无需权限验证即可访问
        current_url = request.path_info  # 当前访问的URL
        for valid_url in settings.VALID_URL_LIST:
            if re.match(valid_url, current_url):
                return None

        # 2.获取当前用户权限列表
        permission_dict = request.session.get(settings.PERMISSION_SESSION_KEY)
        if not permission_dict:  # 如果session中没有该列表,则需要进行登陆
            return HttpResponse('未获取到用户权限信息,请登录!')
            # return redirect(reverse('login')) 可自行根据name进行反向生成

        # 3.定义路径导航格式
        url_record = [
            {'title': '首页', 'url': '#'}
        ]

        # 4.需要登录,但无需权限校验的URL
        for url in settings.NO_PERMISSION_LIST:
            if re.match(url, current_url):
                request.current_selected_permission = 0  # 给request注册一个全局属性:当前选中的URL,0为没有选中任何菜单URL
                request.breadcrumb = url_record  # 注册全局属性breadcrumb用来更方便获取路径导航数据url_record
                request.menu_name = '首页'  # 当前路径导航名称
                return None

        flag = False

        for item in permission_dict.values():  # 获取每个权限name对应的values
            reg = "^%s$" % item['url']  # 匹配URL的完整路径,防止出现多个匹配
            if re.match(reg, current_url):
                flag = True
                request.current_selected_permission = item['pid'] or item['id']  # 当前菜单选中的URL的id,如果有pid则为pid,没有pid则为id
                if not item['pid']:  # 没有pid,则为二级菜单
                    # extend列表,拼接该权限的路径导航,class:active选中该路径
                    url_record.extend([{'title': item['title'], 'url': item['url'], 'class': 'active'}])
                else:  # 有pid则为pid二级菜单所属的普通权限
                    # 有pid,则拼接路径,三级路径需要在二级菜单的后面,并选中该权限active
                    url_record.extend([
                        {'title': item['p_title'], 'url': item['p_url']},  # 该权限对应的二级菜单
                        {'title': item['title'], 'url': item['url'], 'class': 'active'},
                    ])
                request.breadcrumb = url_record
                break

        # 获取路径导航对应的目录名
        for url in url_record:
            if "class" in url:
                request.menu_name = url.get("title")

        if not flag:  # 没有匹配的权限,则无权访问
            return HttpResponse('无权访问')  # 可进行自定制,例如返回404页面

request.breadcrumb为页面生成路径导航,格式:

url_record [
{'title': '首页', 'url': '#'}, 
{'title': '权限分配', 'url': '/rbac/distribute/permissions/', 'class': 'active'}
]

request.current_selected_permission用于左侧菜单判断,菜单ID对应当前的访问的菜单ID,则对应的菜单会展开

页面以layout_plus.html为基础,所有页面继承该页面

layout_plus.html开放了两个templatetags接口,分别为左侧菜单栏和路径导航,更方便进行自定制