基於Django實現RBAC權限管理

概述

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))
相關文章
相關標籤/搜索