基於Django實現RBAC許可權管理
概述
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與其許可權資訊進行匹配,匹配成功則允許訪問,否則攔截請求。
我們先來實現第一步:提取使用者許可權資訊,並寫入session
為了實現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中配置一個白名單;
- 將登入的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))