概述
RBAC(Role-Based Access Control,基于角色的访问控制),经过角色绑定权限,而后给用户划分角色。在web应用中,能够将权限理解为url,一个权限对应一个url。css
在实际应用中,url是依附在菜单下的,好比一个简单的生产企业管理系统,菜单能够大体分为如下几块:制造、资材、生产管理、人事、财务等等。每一个菜单下又能够有子菜单,但最终都会指向一个url,点击这个url,经过Django路由系统执行一个视图函数,来完成某种操做。这里,制造部的员工登陆系统后,确定不能点击财务下的菜单,甚至都不会显示财务的菜单。html
设计表关系
基于上述分析,在设计表关系时,起码要有4张表:用户,角色,权限,菜单:python
- 用户能够绑定多个角色,从而实现灵活的权限组合 :用户和角色,多对多关系
- 每一个角色下,绑定多个权限,一个权限也能够属于多个角色:角色和权限,多对多关系
- 一个权限附属在一个菜单下,一个菜单下能够有多个权限:菜单和权限:多对一关系
- 一个菜单下可能有多个子菜单,也可能有一个父菜单:菜单和菜单是自引用关系
其中角色和权限、用户和角色,是两个多对多关系,由Django自动生成另外两种关联表。所以一共会产生6张表,用来实现权限管理。jquery
下面咱们新建一个项目,并在项目下新建rbac
应用,在该应用的models.py
中来定义这几张表:web
from django.db import models class Menu(models.Model): """ 菜单 """ title = models.CharField(max_length=32, unique=True) parent = models.ForeignKey("Menu", null=True, blank=True) # 定义菜单间的自引用关系 # 权限url 在 菜单下;菜单能够有父级菜单;还要支持用户建立菜单,所以须要定义parent字段(parent_id) # blank=True 意味着在后台管理中填写能够为空,根菜单没有父级菜单 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", null=True, blank=True) 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") # 定义角色和权限的多对多关系 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) email = models.EmailField() roles = models.ManyToManyField("Role") # 定义用户和角色的多对多关系 def __str__(self): return self.nickname
权限的初始化和验证
咱们知道Http是无状态协议,那么服务端如何判断用户是否具备哪些权限呢?经过session会话管理,将请求之间须要”记住“的信息保存在session中。用户登陆成功后,能够从数据库中取出该用户角色下对应的权限信息,并将这些信息写入session中。正则表达式
因此每次用户的Http request过来后,服务端尝试从request.session中取出权限信息,若是为空,说明用户未登陆,重定向至登陆页面。不然说明已经登陆(即权限信息已经写入request.session中),将用户请求的url与其权限信息进行匹配,匹配成功则容许访问,不然拦截请求。数据库
咱们先来实现第一步:提取用户权限信息,并写入session
为了实现rabc
功能可在任意项目中的可用,咱们单首创建一个rbac
应用,之后其它项目须要权限管理时,直接拿到过,稍做配置便可。在rbac
应用下新建一个文件夹service
,写一个脚本init_permission.py
用来执行初始化权限的操做:用户登陆后,取出其权限及所属菜单信息,写入session中django
from ..models import UserInfo, Menu def init_permission(request, user_obj): """ 初始化用户权限, 写入session :param request: :param user_obj: :return: """ 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) menu_list = list(Menu.objects.values('id', 'title', 'parent_id')) # 注:session在存储时,会先对数据进行序列化,所以对于Queryset对象写入session,加list()转为可序列化对象 from django.conf import settings # 经过这种方式导入配置,具备可迁移性 # 保存用户权限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, }
能够在项目的settings中指定session保存权限信息的key:markdown
# 定义session 键: # 保存用户权限url列表 # 保存 权限菜单 和全部 菜单 SESSION_PERMISSION_URL_KEY = 'cool' SESSION_MENU_KEY = 'awesome' ALL_MENU_KEY = 'k1' PERMISSION_MENU_KEY = 'k2'
这样,用户登陆后,调用init_permission
,便可完成初始化权限操做。并且即便修改了用户权限,每次从新登陆后,调用该方法,都会更新权限信息:session
from django.shortcuts import render, redirect, HttpResponse from rbac.models import UserInfo from rbac.service.init_permission import init_permission def login(request): if request.method == "GET": return render(request, "login.html") else: 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('/index/')
第二步,检查用户权限,控制访问
要在每次请求过来时检查用户权限,对于这种对请求做统一处理的需求,利用中间件再合适不过(关于中间件的信息,能够参考个人另外一篇博文)。咱们在rbac
应用下新建一个目录middleware
,用来存放自定义中间件,新建rbac.py
,在其中实现检查用户权限,控制访问:
from django.conf import settings from django.shortcuts import HttpResponse, redirect 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写入配置 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('无权限访问')
说明:
- 有些访问不须要权限,或者在测试时,咱们能够在settings中配置一个白名单;
- 将登陆的url写入settings中,加强可移植性;
- url本质是正则表达式,在匹配用户请求的url是否在其权限范围内时,须要做严格匹配,这个也能够在settings中配置
- 中间件定义完成后,加入settings中的MIDDLEWARE列表中最后面(加到前面可能尚未session信息)
settings中的配置以下:
LOGIN_URL = '/login/' REGEX_URL = r'^{url}$' # url做严格匹配 # 配置url权限白名单 SAFE_URL = [ r'/login/', '/admin/.*', '/test/', '/index/', '^/rbac/', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', '......', 'rbac.middleware.rbac.RbacMiddleware' # 加入自定义的中间件到最后 ]
菜单显示
用户登陆后,应该根据其权限,显示其能够操做的菜单。前面咱们咱们已经将用户的权限和菜单信息保存在了request.session
中,所以如何从中提取信息,并将其渲染成页面显示的菜单,就是接下来要解决的问题。
提取信息很简单,由于在用户登陆后调用init_permission
初始化权限时,已经将权限和菜单信息进行了初步处理,并写入了session,这里只须要经过key将信息取出来便可。
显示菜单要处理三个问题:
- 第一,只显示用户权限对应的菜单,所以不一样用户看到的菜单多是不同的
- 第二,对用户当前访问的菜单下的url做展开显示,其他菜单折叠;
- 第三,菜单的层级是不肯定的(并且,后面要实现权限的后台管理,容许管理员添加菜单和权限);
自定义标签
接下来咱们经过自定义标签(关于自定义标签的方法,能够参考我以前的一篇关于模板的博文),来实现以上需求:
- 它接收request参数,从中提取session保存的权限和菜单数据;
- 对数据做结构化处理
- 将数据渲染为html字符串。
下面 咱们在rabc
应用的目录下新建templatetags
目录,写一个脚本custom_tag.py
,写一个函数rbac_menu
,并加上自定义标签的装饰器:
from django import template from django.utils.safestring import mark_safe register = template.Library() def get_structure_data(request): pass def get_menu_html(menu_data): pass @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函数来实现
其中,咱们将数据处理部分和数据渲染部分抽象为两个函数:
数据处理
from django.conf import settings import re, os 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 = [ # {'id': 1, 'title': '订单管理', 'parent_id': None}, # {'id': 2, 'title': '库存管理', 'parent_id': None}, # {'id': 3, 'title': '生产管理', 'parent_id': None}, # {'id': 4, 'title': '生产调查', 'parent_id': None} # ] # 定制数据结构 all_menu_dict = {} for item in all_menu: item['status'] = False item['open'] = False item['children'] = [] all_menu_dict[item['id']] = item # all_menu_dict = { # 1: {'id': 1, 'title': '订单管理', 'parent_id': None, 'status': False, 'open': False, 'children': []}, # 2: {'id': 2, 'title': '库存管理', 'parent_id': None, 'status': False, 'open': False, 'children': []}, # 3: {'id': 3, 'title': '生产管理', 'parent_id': None, 'status': False, 'open': False, 'children': []}, # 4: {'id': 4, 'title': '生产调查', 'parent_id': None, 'status': False, 'open': False, 'children': []} # } # permission_url = [ # {'title': '查看订单', 'url': '/order', 'menu_id': 1}, # {'title': '查看库存清单', 'url': '/stock/detail', 'menu_id': 2}, # {'title': '查看生产订单', 'url': '/produce/detail', 'menu_id': 3}, # {'title': '产出管理', 'url': '/survey/produce', 'menu_id': 4}, # {'title': '工时管理', 'url': '/survey/labor', 'menu_id': 4}, # {'title': '入库', 'url': '/stock/in', 'menu_id': 2}, # {'title': '排单', 'url': '/produce/new', 'menu_id': 3} # ] 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='rbac-menu-item'> <div class='rbac-menu-header'>{menu_title}</div> <div class='rbac-menu-body {active}'>{sub_menu}</div> </div> """ url_str = """ <a href="{permission_url}" class="{active}">{permission_title}</a> """ """ menu_data = [ {'id': 1, 'title': '订单管理', 'parent_id': None, 'status': True, 'open': False, 'children': [{'title': '查看订单', 'url': '/order', 'menu_id': 1, 'status': True, 'open': False}]}, {'id': 2, 'title': '库存管理', 'parent_id': None, 'status': True, 'open': True, 'children': [{'title': '查看库存清单', 'url': '/stock/detail', 'menu_id': 2, 'status': True, 'open': False}, {'title': '入库', 'url': '/stock/in', 'menu_id': 2, 'status': True, 'open': True}]}, {'id': 3, 'title': '生产管理', 'parent_id': None, 'status': True, 'open': False, 'children': [{'title': '查看生产订单', 'url': '/produce/detail', 'menu_id': 3, 'status': True, 'open': False}, {'title': '排单', 'url': '/produce/new', 'menu_id': 3, 'status': True, 'open': False}]}, {'id': 4, 'title': '生产调查', 'parent_id': None, 'status': True, 'open': False, 'children': [{'title': '产出管理', 'url': '/survey/produce', 'menu_id': 4, 'status': True, 'open': False}, {'title': '工时管理', 'url': '/survey/labor', 'menu_id': 4, 'status': True, 'open': False}]} ] """ 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
样式和JS文件处理
在渲染菜单时会用到自定义的css和js文件,这些也应该打包好,保证rbac的可迁移性。所以,在这个自定义标签的脚本中,额外定义两个标签,用来加载css和js文件:
@register.simple_tag def rbac_css(): """ rabc要用到的css文件路径,并读取返回;注意返回字符串用mark_safe,不然传到模板会转义 :return: """ css_path = os.path.join('rbac', 'style_script','rbac.css') css = open(css_path,'r',encoding='utf-8').read() return mark_safe(css) @register.simple_tag def rbac_js(): """ rabc要用到的js文件路径,并读取返回 :return: """ js_path = os.path.join('rbac', 'style_script', 'rbac.js') js = open(js_path, 'r', encoding='utf-8').read() return mark_safe(js)
这样,菜单显示就完成了。用户登陆后,假如访问index.html
页面,那么只要在该模板中调用上面的自定义标签便可:
{% load custom_tag %} {% load static %} <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <!-- 经过调用自定义标签中的函数,导入rbac中的css和js --> <style> {% rbac_css %} </style> <script src="{% static 'jquery-3.2.1.js' %}"></script> <script> $(function () { {% rbac_js %} }) </script> </head> <body> <!-- 生成菜单 --> {% rbac_menu request %} </body> </html>
权限的后台管理
权限的后台管理,就是提供对Model中定义的那几张表的增删改查功能。这里以用户表UserInfo
为例来讲明。
路由分发
由于权限管理做为一个单独的模块,因此须要在项目的全局urls.py中做一个路由分发:
from django.conf.urls import url, include urlpatterns = [ url(r'^rbac/', include('rbac.urls') ) ]
在rbac应用的urls.py中定义具体的路由:
from django.conf.urls import url from . import views urlpatterns = [ url(r'^users/$', views.users), url(r'^users/new/$', views.users_new), url(r'^users/edit/(?P<id>\d+)/$', views.users_edit), url(r'^users/delete/(?P<id>\d+)/$', views.users_delete), url(r'^$', views.index), ]
视图中处理增删改查
定义ModelForm
这里利用Django的ModelForm,简化这些操做(关于ModelForm的使用,能够参考个人博客)。首先在rbac应用的forms.py中定义UserInfo的ModelForm:
from django.forms import ModelForm from .models import UserInfo, Role, Permission, Menu class UserInfoModelForm(ModelForm): class Meta: model = UserInfo fields = '__all__' labels = { 'username': '用户名', 'password': '密码', 'nickname': '昵称', 'email': '邮箱', 'roles': '角色', }
视图逻辑
这里要注意的就是,若是是修改,那么须要给model_form对象传入一个实例对象。
from django.shortcuts import render, redirect, reverse from .models import UserInfo, Role, Permission, Menu from .forms import UserInfoModelForm, RoleModelForm, PermissionModelForm, MenuModelForm def index(request): # 提供后台管理的入口 return render(request, 'rbac/index.html') def users(request): """查询全部用户信息""" user_list = UserInfo.objects.all() return render(request, 'rbac/users.html', {'user_list': user_list}) def users_new(request): if request.method =="GET": # 传入ModelForm对象 model_form = UserInfoModelForm() return render(request, 'rbac/common_edit.html', {'model_form': model_form, 'title': '新增用户'}) else: model_form = UserInfoModelForm(request.POST) if model_form.is_valid(): model_form.save() return redirect(reverse(users)) else: return render(request, 'rbac/common_edit.html',{'model_form': model_form, 'title': '新增用户'}) def users_edit(request,id): user_obj = UserInfo.objects.filter(id=id).first() if request.method == 'GET': model_form = UserInfoModelForm(instance=user_obj) return render(request, 'rbac/common_edit.html', {'model_form': model_form, 'title': '编辑用户'}) else: model_form = UserInfoModelForm(request.POST, instance=user_obj) if model_form.is_valid(): model_form.save() return redirect(reverse(users)) else: return render(request, 'rbac/common_edit.html', {'model_form': model_form, 'title': '编辑用户'}) def users_delete(request, id): user_obj = UserInfo.objects.filter(id=id).first() user_obj.delete() return redirect(reverse(users))