1. 程式人生 > >基於Django實現RBAC許可權管理

基於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))