RBAC(Role-Based Access Control,基于角色的访问控制),通过角色绑定权限,然后给用户划分角色。在web应用中,可以将权限理解为url,一个权限对应一个url。
在实际应用中,url是依附在菜单下的,比如一个简单的生产企业管理系统,菜单可以大致分为以下几块:制造、资材、生产管理、人事、财务等等。每个菜单下又可以有子菜单,但最终都会指向一个url,点击这个url,通过Django路由系统执行一个视图函数,来完成某种操作。这里,制造部的员工登录系统后,肯定不能点击财务下的菜单,甚至都不会显示财务的菜单。
基于上述分析,在设计表关系时,起码要有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", 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与其权限信息进行匹配,匹配成功则允许访问,否则拦截请求。
为了实现rabc
功能可在任意项目中的可用,我们单独创建一个rbac
应用,以后其它项目需要权限管理时,直接拿到过,稍作配置即可。在rbac
应用下新建一个文件夹service
,写一个脚本init_permission.py
用来执行初始化权限的操作:用户登录后,取出其权限及所属菜单信息,写入session中
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:
# 定义session 键:
# 保存用户权限url列表
# 保存 权限菜单 和所有 菜单
SESSION_PERMISSION_URL_KEY = 'cool'
SESSION_MENU_KEY = 'awesome'
ALL_MENU_KEY = 'k1'
PERMISSION_MENU_KEY = 'k2'
这样,用户登录后,调用init_permission
,即可完成初始化权限操作。而且即使修改了用户权限,每次重新登录后,调用该方法,都会更新权限信息:
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中的配置如下:
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将信息取出来即可。
显示菜单要处理三个问题:
接下来我们通过自定义标签(关于自定义标签的方法,可以参考我之前的一篇关于模板的博文),来实现以上需求:
下面 我们在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
在渲染菜单时会用到自定义的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),
]
这里利用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))