首页Django

Django 实现RBAC权限管理 动态菜单

2020年9月11日 11:052500

RBAC(Role-Based Access Control,基于角色的访问控制),通过角色绑定权限,然后给用户划分角色。在web应用中,可以将权限理解为url,每个权限一组url。

在实际应用中,url是依附在菜单下的,比如一个简单的企业管理系统,菜单可以大致分为:销售、生产、财务、人事等。每个菜单下又可以有子菜单,但最终都会指向一个url,点击url,触发Django路由系统执行视图函数。在未获得授权前提下,生产部账号登录后,是看不到财务部和销售部的菜单和访问权限。

设计表关系

基于上述思路,在设计表关系时,要有4张表:菜单、权限、角色、用户:

1、一个菜单下可以有多个子菜单,也可以有一个父级菜单:菜单和菜单是自引用关系

2、一个权限依附在一个菜单下,一个菜单下可以有多个权限:菜单和权限多对一关系

3、一个角色下绑定多个权限,一个权限也可以属于多个角色:权限和角色多对多关系

4、一个用户可以绑定多个角色,从而实现灵活的权限组合:用户和角色多对多关系

其中角色和权限、用户和角色是两个多对多关系,由Django自动生成两张关联表,最终一种产生6张表,用来实现权限管理。

创建数据模型

为了让权限控制功能可在任意项目中的复用,我们单独创建一个rbac应用,以后其他项目需要用到权限管理时,直接拿过来稍加配置即可。

# models.py

from django.db import models


class Menu(models.Model):
    """菜单数据模型"""
    title = models.CharField('菜单',max_length=32,unique=True)
    # 定义菜单的自引用关系
    parent = models.ForeignKey('Menu',verbose_name='上级',on_delete=models.CASCADE,null=True,blank=True)

    class Meta:
        verbose_name = '菜单'
        verbose_name_plural = '菜单管理'

    def __str__(self):
        """显示层级菜单"""
        title_list = [self.title]
        p = self.parent
        while p:
            title_list.insert(0,p.title)
            p = p.parent
        return '-'.join(title_list)


class Permission(models.Model):
    """权限数据模型"""
    title = models.CharField('权限',max_length=32,unique=True)
    url = models.CharField('链接',max_length=128,unique=True)
    menu = models.ForeignKey('Menu',verbose_name='菜单',on_delete=models.DO_NOTHING,blank=True,null=True)

    class Meta:
        verbose_name = '权限'
        verbose_name_plural = '权限管理'

    def __str__(self):
        """显示带菜单前缀的权限"""
        return '{menu}-{permission}'.format(menu=self.menu,permission=self.title)


class Role(models.Model):
    """角色数据模型"""
    title = models.CharField('角色',max_length=32,unique=True)
    permissions = models.ManyToManyField('Permission',verbose_name='权限')

    class Meta:
        verbose_name = '角色'
        verbose_name_plural = '角色管理'

    def __str__(self):
        return self.title


class Userinfo(models.Model):
    """用户数据模型"""
    username = models.CharField('用户名',max_length=32)
    password = models.CharField('密码',max_length=64)
    nickname = models.CharField('昵称',max_length=32)
    mobile = models.CharField('手机',max_length=32)
    # 定义用户和角色的多对多关系
    roles = models.ManyToManyField('Role',verbose_name='用户组')

    class Meta:
        verbose_name = '用户'
        verbose_name_plural = '用户管理'

    def __str__(self):
        return self.nickname

权限的初始化和验证

通过session会话管理,将请求之间需要'记住'的信息保存在session中。用户登录成功后,可以从数据库中取出该用户角色下对应的权限信息,并将这些信息写入到session中暂存。

所以每次用户的Http request传递过来后,服务端尝试从request.session中取出权限信息,如果为空,说明用户未登录,重定向至登录页面。否则说明已经登录(即权限信息已写入request.session中),将用户请求的url与其获得的权限信息进行匹配,成功则允许访问,否则发送拦截请求。

settings中指定session保存权限信息的key

#  settings.py

# 定义session 键:
# 保存用户权限url列表
# 保存 权限菜单 和所有 菜单
SESSION_PERMISSION_URL_KEY = 'cool'
SESSION_MENU_KEY = 'awesome'
ALL_MENU_KEY = 'k1'
PERMISSION_MENU_KEY = 'k2'

提取用户权限信息,写入session

在rbac应用下新建一个文件夹service,创建一个init_permission.py文件用来执行初始化权限的操作:用户登录后,取出权限及所属的菜单信息,写入session中

# service文件夹下init_permission.py文件


from rbac.models import Userinfo,Menu
from django.conf import settings  # 通过这种方式导入配置,具有可迁移性


def init_permission(request,user_obj):
    """初始化用户权限,并写入session中"""
    permission_item_list = user_obj.roles.values('permissions__url',
                                                 'permissions__title',
                                                 'permissions__menu_id').distinct()
    permission_url_list = []  # 用户权限url列表,用于中间件验证权限
    permission_menu_list = []  # 用户权限url所属菜单列表 [{"title":xxx, "url":xxx, "menu_id": xxx},{},]

    for item in permission_item_list:
        permission_url_list.append(item['permissions__url'])
        if item['permissions__menu_id']:
            temp = {
                'title':item['permissions__title'],
                'url':item['permissions__url'],
                'menu_id':item['permissions__menu_id']
            }
            permission_menu_list.append(temp)
    # 注意:session在存储时,会先对数据进行序列化,因此对于Queryest对象写入session,加list()转为可序列化对象
    menu_list = list(Menu.objects.values('id','title','parent_id'))
    # 保存用户权限url列表
    request.session[settings.SESSION_PERMISSION_URL_KEY] = permission_url_list
    # 保存权限菜单和所有菜单,用户登录后做展示
    request.session[settings.SESSION_MENU_KEY] = {
        settings.ALL_MENU_KEY:menu_list,
        settings.PERMISSION_MENU_KEY:permission_menu_list,
    }

用户登录后,调用init_permission即可完成权限初始化操作。而且即使修改了用户权限,每次重新登录后,调用该方法,都会更新权限信息。

制作简单的登录程序

<!--login.html-->


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>用户登录</title>
</head>
<body>
    <form action="" method="post">
        {% csrf_token %}
        <input type="text" name="username" placeholder="请输入用户名">
        <input type="password" name="password" placeholder="请输入密码">
        <button type="submit">登录</button>
    </form>
</body>
</html>
# views.py

from django.shortcuts import render,redirect
from rbac.models import Userinfo
from rbac.service.init_permission import init_permission


def login(request):
    """用户前台登录"""
    if request.method == 'POST':
        username = request.POST.get('username')
        password = request.POST.get('password')
        user_obj = Userinfo.objects.filter(username=username,password=password).first()
        if not user_obj:
            return render(request,'login.html',{'error':'用户名或密码错误'})
        else:
            init_permission(request,user_obj)  # 调用init_permission初始化权限
            return redirect('/book/')  # 重定向一个指定页面
    else:
        return render(request,'login.html')

自定义中间件验证用户权限

settings中设置中间件、白名单

# settings.py


LOGIN_URL = '/login/'
REGEX_URL = r'^{url}$'  # url作严格匹配
# 配置url权限白名单
SAFE_URL = [
    '/login/',
    '/admin/.*',
    '/index/',
]



MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    '......',
    'rbac.middleware.rbac.RbacMiddleware'  # 加入自定义中间件到最后
]

在rbac应用下新建一个middleware目录,用来存放自定义中间件,新建rbac.py用来检查用户权限,控制访问

# rbac.py


from django.conf import settings
from django.shortcuts import redirect,HttpResponse
import re


class MiddlewareMixin(object):
    def __init__(self,get_response=None):
        self.get_response = get_response
        super(MiddlewareMixin,self).__init__()

    def __call__(self, request):
        response = None
        if hasattr(self, 'process_request'):
            response = self.process_request(request)
        if not response:
            response = self.get_response(request)
        if hasattr(self, 'process_response'):
            response = self.process_response(request, response)
        return response


class RbacMiddleware(MiddlewareMixin):
    """检查用户的访问url是否有权限"""

    def process_request(self, request):
        request_url = request.path_info
        permission_url = request.session.get(settings.SESSION_PERMISSION_URL_KEY)
        print('访问url', request_url)
        print('权限--', permission_url)

        # 如果url在白名单内,放行
        for url in settings.SAFE_URL:
            if re.match(url,request_url):
                return None

        # 如果未取到permission_url,重定向至登录页面(为了项目复用性,将登录url写入settings中)
        if not permission_url:
            return redirect(settings.LOGIN_URL)

        # 循环permission_url,作为正则匹配用户request_url
        # 正则应该进行一些限定,以防止处理:/user/ -- /user/add/匹配成功的情况
        flag = False
        for url in permission_url:
            url_pattern = settings.REGEX_URL.format(url=url)
            if re.match(url_pattern, request_url):
                flag = True
                break

        if flag:
            return None
        else:
            # 如果是调试模式,显示可访问url
            if settings.DEBUG:
                info = '<br/>' + ('<br/>'.join(permission_url))
                return HttpResponse('无权限,请尝试访问以下地址:%s' % info)
            else:
                return HttpResponse('无权限访问')

说明:

1、有些访问步需要权限的,可以在settings中配置白名单

2、将登陆的url写在settings中增强项目可移植性

3、url本质是正则表达式,在匹配用户请求的url是否在其权限范围内时,需要严格匹配,这个也可以在settings中配置

4、中间件定义完成后,写入settings中的MIDDLEWARE列表最后面

菜单显示

用户登录后,应该根据其权限,显示可以操作的菜单。前面我们已经将用户的权限和菜单保存在request.session中,因此如何从中提取信息,并将其渲染成菜单,就是接下来要解决的问题。

提取信息很简单,因为在用户登录后调用init_permission初始化权限时,已经将权限和菜单信息进行了初步处理,并写入了session,这里只需要通过key把信息取出来。

显示菜单要处理两个问题:

1、只显示用户权限对应的菜单,因此不同用户看到的菜单可能不一样

2、菜单的层级不确定(管理员动态在后台添加菜单和权限)

接下来我们通过自定义标签来实现以上需求:

1、接收request参数,从中提取session保存的权限和菜单数据

2、对数据作结构化处理

3、将数据渲染为html

在rbac应用下新建templatetags目录,写一个custom_tag.py,当中写一个函数rbac_menu,并加上自定义标签的装饰器。

# custon_tsg.py

from django import template
from django.utils.safestring import mark_safe
from django.conf import settings
import re, os

register = template.Library()


def get_structure_data(request):
    """处理菜单结构"""
    menu = request.session[settings.SESSION_MENU_KEY]
    all_menu = menu[settings.ALL_MENU_KEY]
    permission_url = menu[settings.PERMISSION_MENU_KEY]

    # 定制数据结构
    all_menu_dict = {}
    for item in all_menu:
        item['status'] = False
        item['open'] = False
        item['children'] = []
        all_menu_dict[item['id']] = item

    request_rul = request.path_info

    for url in permission_url:
        # 添加两个状态:显示 和 展开
        url['status'] = True
        pattern = url['url']
        if re.match(pattern, request_rul):
            url['open'] = True
        else:
            url['open'] = False

        # 将url添加到菜单下
        all_menu_dict[url['menu_id']]["children"].append(url)

        # 显示菜单:url 的菜单及上层菜单 status: true
        pid = url['menu_id']
        while pid:
            all_menu_dict[pid]['status'] = True
            pid = all_menu_dict[pid]['parent_id']

        # 展开url上层菜单:url['open'] = True, 其菜单及其父菜单open = True
        if url['open']:
            ppid = url['menu_id']
            while ppid:
                all_menu_dict[ppid]['open'] = True
                ppid = all_menu_dict[ppid]['parent_id']

    # 整理菜单层级结构:没有parent_id 的为根菜单, 并将有parent_id 的菜单项加入其父项的chidren内
    menu_data = []
    for i in all_menu_dict:
        if all_menu_dict[i]['parent_id']:
            pid = all_menu_dict[i]['parent_id']
            parent_menu = all_menu_dict[pid]
            parent_menu['children'].append(all_menu_dict[i])
        else:
            menu_data.append(all_menu_dict[i])

    return menu_data


def get_menu_html(menu_data):
    """显示:菜单 + [子菜单] + 权限(url)"""
    option_str = """
          <div class='menu'>
                <h3>{menu_title}</h3>
                <span class='{active}'>{sub_menu}</span>
            </div>
    """

    url_str = """
        <a href="{permission_url}" class="{active}" title="{permission_title}">{permission_title}</a>
    """

    menu_html = ''
    for item in menu_data:
        if not item['status']: # 如果用户权限不在某个菜单下,即item['status']=False, 不显示
            continue
        else:
            if item.get('url'): # 说明循环到了菜单最里层的url
                menu_html += url_str.format(permission_url=item['url'],
                                            active="rbac-active" if item['open'] else "",
                                            permission_title=item['title'])
            else:
                menu_html += option_str.format(menu_title=item['title'],
                                               sub_menu=get_menu_html(item['children']),
                                               active="" if item['open'] else "rbac-hide")

    return menu_html


@register.simple_tag
def rbac_menu(request):
    """
    显示多级菜单:
    请求过来 -- 拿到session中的菜单,权限数据 -- 处理数据 -- 显示
    数据处理部分抽象出来由单独的函数处理;渲染部分也抽象出来由单独函数处理
    """
    menu_data = get_structure_data(request)
    menu_html = get_menu_html(menu_data)

    return mark_safe(menu_html)  # 因为标签无法使用safe过滤器,这里用mark_safe函数来实现

到这里,菜单显示就完成了,用户登录后,假如访问index.html页面,那么只需要在该模板文件中调用上面的自定义标签即可。

# html模板文件

<!-- 自定义标签 -->
{% load custom_tag %}

<!-- 生成菜单 -->
{% rbac_menu request %}