基于Django实现RBAC权限管理
概述
RBAC(Role-Based Access Control,基于角色的訪問控制),通過角色綁定權限,然后給用戶劃分角色。在web應用中,可以將權限理解為url,一個權限對應一個url。
在實際應用中,url是依附在菜單下的,比如一個簡單的生產企業管理系統,菜單可以大致分為以下幾塊:制造、資材、生產管理、人事、財務等等。每個菜單下又可以有子菜單,但最終都會指向一個url,點擊這個url,通過Django路由系統執行一個視圖函數,來完成某種操作。這里,制造部的員工登錄系統后,肯定不能點擊財務下的菜單,甚至都不會顯示財務的菜單。
設計表關系
基于上述分析,在設計表關系時,起碼要有4張表:用戶,角色,權限,菜單:
- 用戶可以綁定多個角色,從而實現靈活的權限組合 :用戶和角色,多對多關系
- 每個角色下,綁定多個權限,一個權限也可以屬于多個角色:角色和權限,多對多關系
- 一個權限附屬在一個菜單下,一個菜單下可以有多個權限:菜單和權限:多對一關系
- 一個菜單下可能有多個子菜單,也可能有一個父菜單:菜單和菜單是自引用關系
其中角色和權限、用戶和角色,是兩個多對多關系,由Django自動生成另外兩種關聯表。因此一共會產生6張表,用來實現權限管理。
下面我們新建一個項目,并在項目下新建rbac應用,在該應用的models.py中來定義這幾張表:
from django.db import modelsclass 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.parentwhile p:title_list.insert(0, p.title)p = p.parentreturn '-'.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.titleclass 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中
from ..models import UserInfo, Menudef 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 reclass MiddlewareMixin(object):def __init__(self, get_response=None):self.get_response = get_responsesuper(MiddlewareMixin, self).__init__()def __call__(self, request):response = Noneif 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 responseclass RbacMiddleware(MiddlewareMixin):"""檢查用戶的url請求是否是其權限范圍內"""def process_request(self, request):request_url = request.path_infopermission_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 = Falsefor url in permission_url:url_pattern = settings.REGEX_URL.format(url=url)if re.match(url_pattern, request_url):flag = Truebreakif flag:return Noneelse:# 如果是調試模式,顯示可訪問urlif 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_saferegister = template.Library()def get_structure_data(request):passdef 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, osdef 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'] = Falseitem['open'] = Falseitem['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_infofor url in permission_url:# 添加兩個狀態:顯示 和 展開url['status'] = Truepattern = url['url']if re.match(pattern, request_rul):url['open'] = Trueelse:url['open'] = False# 將url添加到菜單下all_menu_dict[url['menu_id']]["children"].append(url)# 顯示菜單:url 的菜單及上層菜單 status: truepid = url['menu_id']while pid:all_menu_dict[pid]['status'] = Truepid = all_menu_dict[pid]['parent_id']# 展開url上層菜單:url['open'] = True, 其菜單及其父菜單open = Trueif url['open']:ppid = url['menu_id']while ppid:all_menu_dict[ppid]['open'] = Trueppid = 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, 不顯示continueelse:if item.get('url'): # 說明循環到了菜單最里層的urlmenu_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, includeurlpatterns = [url(r'^rbac/', include('rbac.urls') ) ]在rbac應用的urls.py中定義具體的路由:
from django.conf.urls import url from . import viewsurlpatterns = [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, Menuclass UserInfoModelForm(ModelForm):class Meta:model = UserInfofields = '__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, MenuModelFormdef 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))總結
以上是生活随笔為你收集整理的基于Django实现RBAC权限管理的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: jQuery 2.0.3 源码分析 事件
- 下一篇: 十大经典排序算法(动态演示+代码)