[TOC]
## 第8章 权限控制
我看过太多同学编写的API在互联网上疯狂的裸奔了。殊不知这太危险了。API必须提供分层保护机制,根据不同用户的种类来限制其可以访问的API,从而保护接口。比如管理员可以访问哪些接口,普通用户可以访问哪些接口,小程序可以访问哪些,APP又能够访问哪些?灵活而强大的可配置Scope,可以帮助你事半功倍...
### 8-1 删除模型注意事项
~~~
@api.route('/<int:uid>', methods=['DELETE'])
@auth.login_required
def delete_user(uid):
with db.auto_commit():
user = User.query.filter_by(id=uid).first_or_404()
user.delete()
return DeleteSuccess()
~~~
1. 注册路由,设计路由
2. `methods`方式要使用 `DELETE`
3. 使用 @auth.login\_required 做身份验证
4. 使用 `with db.auto_commit()`自动提交数据库(会自动关闭数据库连接,起到保护作用)
5. 查询用户的时候需要使用`filter_by(id=uid)`,如果使用`get_or_404(uid)`就会造成每次删除用户操作都是成功的,显然这不符合逻辑,资源只能删除一次
6. 使用`first_or_404()`触发查询数据库的操作,以上所写的内容只是记录要做的事情,遇到 `first`才开始查询
7. `user.delete()`方法是自定义的修改 `status`的方法,并不是真正删除数据,这种属于软删除
8. 最后返回删除成功的操作提示
### 8-2 g变量中读取uid防止超权
按照上节的写法,只要用户能能够访问 `delete_user`这个接口就可以删除任意用户,这就是**超权现象**,这是非常可怕的事情,理论上来说一个用户只能删除自己的账户,不能删除别人的账户。
怎么解决这个问题呢?解决这个问题的方法就是不能让用户自己指定任意的 `id` 进行删除操作,它只能删除自己的 `id`对应的账户。我们只需要从 `token`中取出 `uid`然后查询删除就可以了。
如何从 `token` 中获取 `uid`呢?
很简单,我们在做登录保护---验证密码(`verify_password`)的时候已经将 `token`反序列化获得了用户信息 `user_info`,并将其存放到了`g`变量中,现在我们只需要从 `g`变量中读取出来就可以了。
~~~
uid = g.user.uid
~~~
因为我们之前将 `token`反序列化之后将 `uid`和 `ac_type`存在了 `namedtuple`的实例化对象中的,所以这里我们可以使用`g.user.uid`来访问。
修改后代码如下:
~~~
from flask import g
......
@api.route('/', methods=['DELETE'])
@auth.login_required
def delete_user():
uid = g.user.uid
with db.auto_commit():
user = User.query.filter_by(id=uid).first_or_404()
user.delete()
return DeleteSuccess()
~~~
那么问题来了,如果同一时刻有两个用户同时访问 `delete_user`这个接口,那么这个 `g`到底指向的是哪个用户的请求呢?会不会发生数据错乱的问题呢?
显然不会,因为 `g`变量是线程隔离的。即使有两个用户同时访问 `delete_user`接口,但是由于它们的线程号不同,所以`g`变量所指代的请求也是不同 的,不会出现两个数据请求错乱这种问题。
### 8-3 生成超级管理员账号
#### 方法1
使用离线脚本创建超级用户,编写脚本文件 ginger/fake.py,直接运行向数据库添加文件。
~~~
# 本文件是一个离线脚本用来创建测试数据
from app import create_app
from app.models.base import db
from app.models.user import User
app = create_app()
with app.app_context():
with db.auto_commit():
# 创建一个超级管理员
user = User()
user.nickname = 'Super'
user.email = 'super@qq.com'
user.password = '0000000'
user.auth = 2
db.session.add(user)
~~~
#### 方法2
在数据库里,选中一个 user 作为超级用户,将该条记录的 `auth` 字段改为2。
### 8-4 不太好的权限管理方案
此处代码需要做出修改,因为 `get_or_404`会将 `status=0`的用户也搜出来,所以需要改为`filter_by(id=uid).first_or_404()`
![](https://ws4.sinaimg.cn/large/006tNc79gy1fywja7dse3j31hq08cgn8.jpg)
`get_user`是普通用户访问的接口,所以还需要建一个只有管理员才能访问的接口`super_get_user`,同时`super_get_user`接口还需要打上装饰器`@auth.login_required`。
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyxq62mrmjj31io088tag.jpg)
那么加上`@auth.login_required`可以实现我们的目的吗?不能实现。因为`@auth.login_required`只会验证用户是否携带了令牌、以及这个令牌是否合法,它无法分辨一个用户是不是管理员。我们就可以回想一下在生成令牌的时候`generate_auth_token`函数内部只是将 `uid`、`type`添加到令牌当中去,这两个信息无法表明该用户是管理员还是普通用户。![](https://ws4.sinaimg.cn/large/006tNc79gy1fyxqcp1fqvj31xq0aawgw.jpg)
只有我们在生成令牌的时候在令牌内记录用户的身份,然后当用户携带令牌访问的接口的时候我们才能从令牌里读取这个用户的身份。
如果我们可以从令牌里读取用户的身份的话,那么我们就可以对用户的操作禁止或者放行。代码编写思路:
1. 在验证用户登陆后将用户的 `auth`记录并返回, ginger/app/models/user.py 内:
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyxr52afulj31mw0c077y.jpg)
2. 将用户的身份信息传入序列化器,生成 `token`
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyxrd6066tj31xi0r4agb.jpg)
3. 在读取 `token`后将身份信息返回并保存在 `g`变量中,以便使用的时候方便读取,ginger/app/libs/token\_auth.py 内:(`@auth.verify_password`是在`@auth.login_required`内起作用的,所以全局搜索不到`verify_password`函数的调用情况) ![](https://ws3.sinaimg.cn/large/006tNc79gy1fyxrivvrggj31es0u0tir.jpg)
4. 编写接口的时候取出保存在 `g`变量中的用户信息,并判断是否允许用户调用接口,ginger/app/v1/user.py 内: ![](https://ws3.sinaimg.cn/large/006tNc79gy1fyxrlr3q40j31q20dmwh6.jpg)
现在看似我们已经很完美的解决了这个问题,但是这种写法非常差:
1. 太啰嗦、不够优雅,理论上我们需要在对普通用户有限制的接口里都需要判断管理员身份,接口一多的话写起来就很烦
2. 我们把权限想的太简单了,我们在这里做了一个很简单的普通用户和管理员的区别。但是在一个真正的复杂的项目里面,这种权限的分组可能只有普通用户和管理员两个吗?可不可能除了这两个之外还有超级管理员呢?可不可能还有其他的级别呢?这种写法可以应用到你的简单项目里面。但是做项目和做框架差别是很大的,做框架的话我们一定要考虑到普适性。
### 8-5 比较好的权限管理方案
上节我们只是考虑到了最简单的情况,下面看一下复杂一点你的情况:
![](https://ws1.sinaimg.cn/large/006tNc79gy1fyxs0mjhhyj31w60k7tre.jpg)
好一点的解决方案:
假如我们在代码里做三张表,每一个表里都记录着某种权限,现在假如有一个请求进来了,之前的代码大家都知道如果一个请求访问带有`@auth.login_required`的接口的话,它必须是携带有这个令牌的,而且从前面几节课里面我们也知道从 `token`里我们可以知道当前的请求它的权限类型(用户、管理员、超级管理员)并且我们也能够从请求中获取需要访问的接口,那么我们就可以带着**权限类型**、**接口**去对应权限的表里查询,如果能查询得到则表示允许访问,查询不到则表示禁止访问。
之前的解决方案有一点不好就是我们是进入到`super_get_user`接口之后,在`super_get_user`接口中判断是否能够访问该接口,这个不太好。现在这个解决方案的好处就是我们在进入接口之前就能判断用户是否具有访问某个接口的权限,如果没有的话就根本不会让用户进入接口。
![](https://ws2.sinaimg.cn/large/006tNc79gy1fyxs99hfxbj31f80u01kx.jpg)
### 8-6 实现Scope权限管理 一
首先我们需要为上节中的表起个名字,对于每一个具体的表来说的话,他们需要一个具体的名字。
![](https://ws3.sinaimg.cn/large/006tNc79gy1fyxswjxf5oj31gd0u04qp.jpg)
实现步骤:
1. 我们假设项目里只有1和2,也就是只有管理员和普通用户两种 `Scope`,但是在实际项目中需要灵活一些,你的 `auth`可能有很多权限那么在获取身份权限的时候就必须根据数字或者其他的标识来返回对应的 Scope。在 ginger/app/models/user.py 中:
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyy0onqhc2j31ra0c6q62.jpg)
2. 将身份信息传入序列化器,序列化生成 `token`,在 ginger/app/api/v1/token.py 中:
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyy0qy9fo8j31um0r8tey.jpg)
3. 在读取 `token`后将身份信息返回并保存在 `g`变量中,以便使用的时候方便读取,ginger/app/libs/token\_auth.py 内:(`@auth.verify_password`是在`@auth.login_required`内起作用的,所以全局搜索不到`verify_password`函数的调用情况) **注意:**
* 调用 `is_in_scope`函数判断该用户所属权限组能否访问该接口,返回的是布尔值,在判断布尔值即可
* 调用`request.endpoint`可以获取该请求访问的 `api`
![](https://ws3.sinaimg.cn/large/006tNc79gy1fyy09tprrfj313r0u0tig.jpg)
4. 编写 ginger/app/libs/scope.py 文件:
* 编写相关的 `scope`类将对应权限下允许访问的接口放到**类属性**`allow_api`中
* 编写`is_in_scope`函数判断该用户是否具有访问该接口的权限
![](https://ws3.sinaimg.cn/large/006tNc79gy1fyy0eeufghj31zs0mk40x.jpg)
到这里基本的思路已经写完了,这样写有问题吗?我们使用 postman 测试一下。本节就到这里,这个问题下一节解决。
![](https://ws1.sinaimg.cn/large/006tNc79gy1fyy0veze65j31od0u0150.jpg)
### 8-7 globals()实现反射
实际上经过调试很容易发现我们在 ginger/app/libs/token\_auth.py 传入 `is_in_scope(scope, request.endpoint)`的 `scope`参数其实是字符串
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyy15ft9g7j31o10c3tba.jpg)
所以我们在 ginger/app/libs/scope.py 中调用`scope.allow_api`会报错。那怎么解决这个问题呢?因为这个字符串的名字就是跟我们定义的具体的权限组的名称是一样的,也就是说我们如何通过一个类的名字获得这个类对象呢?
使用 `globals()`函数,我们实例化一个`globals()`函数看看它到底是什么? 看下图调试结果可以得到,`globals()`函数可以将当前模块下所有函数、类、变量的名字都提取出来作为键,名称所对应的对象作为值整理成一个字典返回。
![](https://ws2.sinaimg.cn/large/006tNc79gy1fyy1q0shp7j31nj0u014q.jpg)
代码其实很简单,我们将 ginger/app/libs/scope.py 改成这样:
~~~
class AdminScope:
allow_api = ['super_get_user']
class UserScope:
allow_api = []
def is_in_scope(scope, endpoint):
scope = globals()[scope]
if endpoint in scope.allow_api:
return True
else:
return False
~~~
然后我们再使用 postman 测试一下,测试失败,调试结果发现原来是 `endpoint` 出问题了,传入的 `endpoint` 与我们放在权限组里的视图函数名字对不上
![](https://ws3.sinaimg.cn/large/006tNc79gy1fyy1hyd6dcj31u80oqjuy.jpg)
那么我们再思考下为什么需要加上 `v1`呢?是因为我们的视图函数并不是直接注册在 flask 核心对象 app 上的,如果是直接注册在 flask 核心对象 app 上的,那么视图函数的名称就是 `super_get_user`。但是我们的视图函数其实是注册在**蓝图**上的,**蓝图**注册在 flask 核心对象 app 上的。
### 8-8 实现Scope权限管理 二
上节问题解决方案:将 `v1`加上之后就可以成功访问 `super_get_user`接口了,完美。
这样一套权限管理解决方案是可以解决问题,虽然基本的原理和思路是正确的,但是这是一套很差的解决方案,因为我们还需要在此基础上增加更多的方法和技巧帮助我们简化配置文件的编写流程,下节课继续。
### 8-9 Scope优化一 支持权限相加
目前方案的缺陷:编写配置太麻烦
支持权限相加的优化方案如下:
~~~
class UserScope:
allow_api = []
class AdminScope:
allow_api = ['v1.super_get_user']
def __init__(self):
self.__add__(UserScope())
def __add__(self, other):
self.allow_api += other.allow_api
~~~
### 8-10 Scope优化 二 支持权限链式相加
上节中`AdminScope`中包含了`UserScope`中的视图函数,所以我们只加了`UserScope`。但是实际情况中我们可能需要叠加几个权限组的 `allow_api`。该怎么办呢?
![](https://ws2.sinaimg.cn/large/006tNc79gy1fyy3564r9jj31z60tkn30.jpg)
### 8-11 Scope优化 三 所有子类支持相加
问题:我们将`__add__`操作定义在`AdminScope`里面合理吗?如果`UserScope`也需要进行权限相加的操作呢?怎么办呢?
将加法操作写到基类 `Scope()`中:
~~~
class Scope:
allow_api = []
def add(self, other):
self.allow_api += other.allow_api
return self
class UserScope(Scope):
allow_api = []
def __init__(self):
self.add(Scope())
print('UserScope', self.allow_api)
class AdminScope(Scope):
allow_api = ['v1.super_get_user']
def __init__(self):
self.add(UserScope())
print('AdminScope', self.allow_api)
class SuperScope(Scope):
allow_api = []
def __init__(self):
self.add(UserScope()).add(AdminScope())
print('SuperScope', self.allow_api)
~~~
### 8-12 Scope优化 四 运算符重载
根据上节的代码,其实不难发现,这种链式无限`.add()`的操作在权限组多的情况下,其实是很繁琐的。有没有更简洁的办法呢?
我们可不可以直接进行权限组相加的操作呢?
~~~
Scope() + UserScope() + AdminScope() + SuperScope()
~~~
默认是不行的,进行运算符重载之后就可以了。
我们只需要将`Scope.add`方法改成`Scope.__add__`方法就可以了,因为`__add__`方法是内置的加法运算,我们相当于覆写了加法法则。代码如下:
~~~
class Scope:
allow_api = []
def __add__(self, other):
self.allow_api += other.allow_api
return self
class UserScope(Scope):
allow_api = []
def __init__(self):
self + Scope()
print('UserScope', self.allow_api)
class AdminScope(Scope):
allow_api = ['v1.super_get_user']
def __init__(self):
self + UserScope()
print('AdminScope', self.allow_api)
class SuperScope(Scope):
allow_api = []
def __init__(self):
self + AdminScope + UserScope
print('SuperScope', self.allow_api)
~~~
### 8-13 Scope 优化 探讨模块级别的Scope
#### 去重
经过前面的代码我们发现会有很多视图函数重复了,我们需要为权限组的 `allow_api`列表去重,使用最简单的方法 python 内置`set()`函数,因为重复是出现在**相加**操作之后,所以我们在`__add__`方法内的最后做去重操作就可以了,代码如下:
~~~
class Scope:
allow_api = [1]
def __add__(self, other):
self.allow_api += other.allow_api
self.allow_api = list(set(self.allow_api))
return self
~~~
> 小技巧:
>
> 有同学可能觉得先转成集合再转成列表比较繁琐,其实我们的 `allow_api`可以直接使用集合,就不会出现重复。
#### 模块级别的 Scope 构思
目前来说控制权限的粒度都是在视图函数这个级别。如果我有100个视图函数,势必要把这100个视图函数全部填到 `Scope`中来,这个写起来就比较麻烦了。有没有办法可以简化一下呢?举个例子,假如说 `SuperScope`这个超级权限组可以访问 `user.py`模块下的所有视图函数,那么我们还有没有必要将`user.py`模块下所有的视图函数全部写到 `SuperScope`下面来呢?既然它可以访问整个模块下面的视图函数,那么我们可以不可以只把`user.py`这个模块的名字填写到 `SuperScope`下面呢?如果能只写一个模块的名字,那岂不是很方便了?那我们就来增加一个属性来支持这样一个功能。
其实能不能通过我们的权限控制完全集中在 `is_in_scope`这个函数的实现的。如果在 `is_in_scope`函数内传进来的 `endpoint`里有模块的名字那就好办了,如果该模块在我们对应的权限分组里可以找到,那就说明允许访问,如果找不到就禁止访问。
那么问题来了,实际上我们传入 `is_in_scope`函数内的 `endpoint`是不包含(即将访问的视图函数所属的)模块名的。如何将模块名添加到 `endpoint`里面去呢?
### 8-14 Scope优化 实现模块级别的Scope
在 ginger/app/libs/red\_point.py 内:
![](https://ws2.sinaimg.cn/large/006tNc79gy1fyz350onxbj329y0bg77y.jpg)
这样我们就在 `endpoint`里得到了模块的名字,因为我们在写视图函数模块的时候,模块名=Redpoint。所以我们在 Redpoint 内部写 Redpoint 注册函数的时候,将 Redpoint 名字添加到 endpoint 里面去,就能在后面用的时候拿到模块名。
在 ginger/app/libs/scope.py 内:
![](https://ws4.sinaimg.cn/large/006tNc79gy1fyz3ckniwgj32420g6wi4.jpg)
完美解决问题。
### 8-15 Scope优化 七 支持排除
支持`allow_module`相加
![](https://ws3.sinaimg.cn/large/006tNc79gy1fyz3jmycq9j31mc0j441d.jpg)
#### 权限排除
添加 `forbidden_api`属性,并支持权限相加:
![](https://ws3.sinaimg.cn/large/006tNc79gy1fyz3zobv39j31ym0pwtct.jpg)
![](https://ws2.sinaimg.cn/large/006tNc79gy1fyz3y96hk3j31ue0iq77f.jpg)
> 注意:`forbidden_api`、`allow_api`、`allow_module`三个判断的顺序很重要,不能错