概述
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))