11 min read

如何在 Django 与 DRF 中优雅地校验权限

0X00 Django 中的权限结构、定义

我们知道在创建了一个 Django 项目之后,默认就有两个公开可用的 model:User 和 Group,这两个 model 的一项功能就是用来做权限管理的。系统中会有很多项权限,单个 user 可以配置拥有哪些权限,也可以将权限配置给 group。然后校验单个权限的时候其实就是将 user 本身的权限,和 user 所在的所有组的权限做一个并集,看本次操作的权限是否在这个并集里。在,那就校验通过;不在,那就只有 HTTP 403 了。

HTTP 401 和 HTTP 403 的区别:401 的描述是 Forbidden,而 401 是 Unauthorized,前者是没有权限,而后者干脆没通过认证。举个例子,你想查看公司财务的详细报表,财务经理一看你就是个一线小程序员,就给你一个 401,告诉你这不是你可以看的东西。如果你想看别的公司财务的详细报表,别人公司财务经理一看你根本不是他们公司的人,就直接给你了个 401 了。(俗话说十个比喻九个不准,我这个比喻当然也并不非常准确,不过对于分不清 401 和 403 的同学而言应该也问题不大🤣)

Django 自己对每一个 model 都创建了 create,update,delete 的权限,我们可以直接拿来用,也可以自己添加新权限。Django 自己是针对各个 model 做的权限,所以最简单的权限建立是在 model 层进行的。就比如下面这种,如果我想要为 Student 这个 model 建立相关的权限,就可以通过修改 Meta 类里的 permissions 来实现。

class Student(models.Model):
  	user = models.OneToOneField('django.contrib.auth.models.User')
  	name = models.CharField() # 纯展示,就不详细定义了
    birthday = models.DatetimeField()
    phone = models.CharField()
    
    class Meta:
				verbose_name = '学生'
        verbose_name_plural = verbose_name
        permissions = (
        		# ('权限名', '权限描述'),
          	('check_classmate_score', '查看同班同学的成绩'),
          	('send_class_notify', '发送班级通知'),
        )

不过这里也看到了,每次对权限进行 CUD 的时候都是在改 model 的,所以每次改动完 model 记得都要进行一次migrate操作才行。不过不用担心性能问题,这个 migrate 只对 Permission 表进行 CUD 操作,而并非改表,所以非常快就搞定了。注意的一点是,不管你把这些 permissions 写在哪个 model 下,最终他们创建好的数据都还是在 Permission 表里,也就是数据库(我这里用的是 MySQL)里的auth_permission表了,这个表结构和数据是下面这样的。

+-----------------+--------------+------+-----+---------+----------------+
| Field           | Type         | Null | Key | Default | Extra          |
+-----------------+--------------+------+-----+---------+----------------+
| id              | int(11)      | NO   | PRI | <null>  | auto_increment |
| name            | varchar(255) | NO   |     | <null>  |                |
| content_type_id | int(11)      | NO   | MUL | <null>  |                |
| codename        | varchar(100) | NO   |     | <null>  |                |
+-----------------+--------------+------+-----+---------+----------------+
+-----+------------------------------------+-----------------+-------------------------------+
| id  | name                               | content_type_id | codename                      |
+-----+------------------------------------+-----------------+-------------------------------+
| 1   | Can add log entry                  | 1               | add_logentry                  |
| 2   | Can change log entry               | 1               | change_logentry               |
| 3   | Can delete log entry               | 1               | delete_logentry               |
| 4   | Can add group                      | 2               | add_group                     |
| 5   | Can change group                   | 2               | change_group                  |
| 6   | Can delete group                   | 2               | delete_group                  |
| 7   | Can add permission                 | 3               | add_permission                |
| 8   | Can change permission              | 3               | change_permission             |
| 9   | Can delete permission              | 3               | delete_permission             |
| 10  | Can add user                       | 4               | add_user                      |
| 11  | Can change user                    | 4               | change_user                   |
| 12  | Can delete user                    | 4               | delete_user                   |
+-----+------------------------------------+-----------------+-------------------------------+

0X01 在 Django 中校验与分配权限

权限在表里之后“唯一”生效的地方就是 django-admin 了,就是说如果登录 django-admin 的用户没有add_user的权限,那你创建用户的时候就会被拒绝。但是我们平时更多的时候并不是在 django-admin 里,而是自己实现的前台页面,那该怎么自己校验权限呢?Django 中给 user 实例了两个方法:user.has_permuser.has_perms。显然,前者校验单个权限,后者校验多个权限。咱们先来看一下是如何校验的:

In [1]: from django.contrib.auth.models import User

In [2]: shawn = User.objects.create(username='shawn')

In [3]: shawn.has_perm('auth.add_user')	# 单个权限校验直接传权限的 code_name
Out[3]: False

In [4]: shawn.has_perms(['auth.add_user', 'auth.del_user'])  # 多个全线同时检验时将多个 code_name 放在列表中
Out[4]: False

In [5]: admin = User.objects.create(username='admin')

In [6]: admin.is_superuser = True		# 设置 admin 用户为超级管理员

In [7]: admin.save()

In [8]: admin.has_perm('auth.add_user')
Out[8]: True

In [9]: admin.has_perms(['auth.add_user', 'auth.del_user'])
Out[9]: True

我们从 Django 源码中可以看到,如果正在校验的用户是“活跃的”而且是“超级管理员”,那就直接不校验了,通过。否则就去校验一波。同时校验多个权限的时候用了all去逐个校验,遇到一个没有的权限也就 False 了。具体的细节这里就不多说了,感兴趣的话可以看一下源码,在django.contrib.auth.models.User中。

def has_perm(self, perm, obj=None):
    """
    Return True if the user has the specified permission. Query all
    available auth backends, but return immediately if any backend returns
    True. Thus, a user who has permission from a single auth backend is
    assumed to have permission in general. If an object is provided, check
    permissions for that object.
    """
    # Active superusers have all permissions.
    if self.is_active and self.is_superuser:
            return True

    # Otherwise we need to check the backends.
    return _user_has_perm(self, perm, obj)

def has_perms(self, perm_list, obj=None):
    """
    Return True if the user has each of the specified permissions. If
    object is passed, check if the user has all required perms for it.
    """
    return all(self.has_perm(perm, obj) for perm in perm_list)

然后要考虑的就是如何将权限分给用户了,我们可以在 Django 源码中看到 user 和 permission 的关联关系是:ManyToMany ,所以直接修改用户的user_permissions字段就可以了。针对 group 也是一样的。

In [36]: shawn = User.objects.get(username='shawn')

In [37]: perm = Permission.objects.get(codename='add_user')

In [38]: shawn.user_permissions.add(perm)

In [39]: shawn.save()

In [40]: shawn.has_perm('auth.add_user')
Out[40]: True
这里有一个比较干扰人的问题,就是为什么我的 codename 明明是 add_user 但是校验的时候却要用auth.add_user。当是这个问题也是困扰了我比较久,还因为这个问题导致了我程序出现 bug(比如本来该校验car.open_door的地方我校验了open_door)。比较明显的一处是因为“重名“,因为我们 model 多了之后会出现好多处重名的权限名,所以校验的时候应该在codename 前面加上 app_name。可以看一下我们 settings 里面的 INSTALLED_APPS 配置,有一条就是django.contrib.auth

0X02 DRF 中的权限设计

我们在配置 DRF 的时候通常会在 settings 里写下类似这样的配置,这意味着给默认的 permission_class 设置成了IsAuthenticated,也就是说登录即可。

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
	  #..............
}

但是这也只是一个默认的权限配置,你用 DRF 的ViewSet创建的新 API 默认是登录即可,但是总会有其他的情况,这种时候通常来说我我们会重写 ViewSet 中的两个方法:get_permissionsget_queryset。先说正经的权限,也就是get_permissions好了。

class APIView(View):
    permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES
    # .........
    
		def get_permissions(self):
        """
        Instantiates and returns the list of permissions that this view requires.
        """
        return [permission() for permission in self.permission_classes]
    
    def check_permissions(self, request):
        """
        Check if the request should be permitted.
        Raises an appropriate exception if the request is not permitted.
        """
        for permission in self.get_permissions():
            if not permission.has_permission(request, self):
                self.permission_denied(
                    request, message=getattr(permission, 'message', None)
                )

    def check_object_permissions(self, request, obj):
        """
        Check if the request should be permitted for a given object.
        Raises an appropriate exception if the request is not permitted.
        """
        for permission in self.get_permissions():
            if not permission.has_object_permission(request, self, obj):
                self.permission_denied(
                    request, message=getattr(permission, 'message', None)
                )

我们从源码中可以看到这个方法其实很很简单,就只是把self.permission_classes一遍,通过 codename 进行实例化,最终返回一个实例化的 permission 列表。然后在check_permissions或者check_object_permissions的时候对这个列表轮询一遍,调用 permission 实例的has_object_permission(request, view)`方法来判断是否有权限执行本次请求,最终通过返回的 True/False 来确定是否有权限继续操作,如果没有权限就直接终止请求了。

根据上述条件,我们就可以自己写一个权限校验类了,一个简单的实现时下面这种样子的:

from rest_framework import permissions


class PermsRequired(permissions.BasePermission):
		"""需要有多个权限之一,也就是说有其中一个权限即可"""
  
    def __init__(self, *perms):
        self.perms = perms	# 接受多个参数,全部都是权限

    def __call__(self):
        return self  # 被 call(前期可以理解成被当成函数调用) 的时候返回自身

    def has_permission(self, request, view):
        user = request.user		# 既然传进来了 request,那就可以拿到 user 了

        if user.is_superuser:	# superuser 拥有一切权限
            return True

        user_perms = user.get_all_permissions()	 # 取得用户所拥有的的所有权限
        
        # 取本次需要的权限和用户所有的权限的交集,如果有交集则校验通过(也就是 “或” 校验
        return True if user_perms & set(self.perms) else False
      
      	# 如果想要“与”校验,可以改成下面这种
        # return all([perm in user_perms for perm in set(self.perms)])

0X03 DRF 如何校验权限

现在我们知道 Django 和 DRF 中的权限大概是怎么实现的了,那么具体怎么用在 DRF 框架的 web 程序中呢?下面看一段实例代码。这坨代码就是我们最常见的一个 DRF 开出来的 ViewSet 了,同时支持GET/POST/PUT/PATCH/DELETE五种 HTTP 方法。

在 DRF 的 ViewSet 中,可以使用 self.action 来访问到当前请求的 function。默认情况下的 CRUD 对应的方法名是:list/create/retrieve/partial_update/update/delete这五个,分别对应了GET /,POST /,GET /id/,PATCH /id/,PUT /id/,DELETE /id/

class StudentViewSet(viewsets.ModelViewSet):
  	queryset = models.Student.objects.all()
  	# ...........

    def get_permissions(self):
      	if self.action == 'list':		# 如果是对根目录进行 GET 则需要列表权限
 						self.permission_classes = (
                perms.PermsRequired('student.list_student'),
            )
        if self.action == 'create':		# 如果对根目录进行 POST 则需要创建权限
            self.permission_classes = (
                perms.PermsRequired('student.create_student'),
            )

        return super(StudentViewSet, self).get_permissions()

    def get_serializer_class(self):
      	# ........
        return self.serializer_class

    def get_queryset(self):
        user = self.request.user

        # 如果用户拥有查看所有学生信息的权限,则返回所有学生信息
        if user.has_perm('student.query_all_students'):
            return self.queryset
        
        # 如果用户有查看自己班里学生信息的权限,则返回自己班里的学生信息
        if user.has_perm('ratel.query_self_class_students'):
            return self.queryset.filter(
                student_class=user.student.student_class
            )
        # ...还有其他校验
        
        # 如果什么权限都没找到,那就只能返回自己的数据了
        return models.Student.objects.filter(id=user.student.id)

我们可以从上面看到有两个地方在校验权限,一个是get_permissions(),另一个是get_queryset()。前者是严谨的“权限”,也就是说“你想要执行某某某操作,你就需要拥有某某某权限,没有就不信”;后者是广义的“权限”,会从大到小来校验,比如这里可以先判断你是否拥有查看所有人信息的权限,有,就给你所有人的信息;没有,就看你能不能看班里人的信息,有,就给你班里人的信息;没有,就只返回给你自己的数据。

这两种用法是在日常工作中用法最多的了。其他再有的话就是在 serializer 的 validate 中进行的校验,比如有一个功能叫做“强制删除”,这个功能可以跳过一切检测(比如检测学生是否在校,学生是否毕业超过3 年,学生饭卡是否还有钱),就直接删除。这种权限可能只会分配给高级别管理员使用,但是又不可能针对这种功能再单独开新的 API 来做,这样太奇怪了。所以我们可以通过一个参数来控制前置删除,比如force_delete。这样我们就可以写出类似下面的代码

def validate(self, validated_data):
  	user = self.context['request'].user
    force_delete = validated_data.get('force_delete', False)
    
    if force_delete:
      	if not user.has_perm('student.force_delete'):
        		raise serializers.ValidationError('你没有强制删除用户的权限')
      	
  	return validated_data

0X04 THE END

今天是个美好的周日,中午的时候想要喝了咖啡写篇博客就去玩 火焰纹章 的的,然而越看这个权限越越觉得自己不懂,之前一度以为自己挺明白的,结果一直是处在知其然的状态,并没有知其所以然(虽然现在知道也也不彻底)。然后就一点点看之前的代码,看实现方案,看着看着又得进去看源码,结果计划一个多小时搞定的东西搞了快四个小时。不过还好,也不亏,收获颇丰~