[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()`

`get_user`是普通用戶訪問的接口,所以還需要建一個只有管理員才能訪問的接口`super_get_user`,同時`super_get_user`接口還需要打上裝飾器`@auth.login_required`。

那么加上`@auth.login_required`可以實現我們的目的嗎?不能實現。因為`@auth.login_required`只會驗證用戶是否攜帶了令牌、以及這個令牌是否合法,它無法分辨一個用戶是不是管理員。我們就可以回想一下在生成令牌的時候`generate_auth_token`函數內部只是將 `uid`、`type`添加到令牌當中去,這兩個信息無法表明該用戶是管理員還是普通用戶。
只有我們在生成令牌的時候在令牌內記錄用戶的身份,然后當用戶攜帶令牌訪問的接口的時候我們才能從令牌里讀取這個用戶的身份。
如果我們可以從令牌里讀取用戶的身份的話,那么我們就可以對用戶的操作禁止或者放行。代碼編寫思路:
1. 在驗證用戶登陸后將用戶的 `auth`記錄并返回, ginger/app/models/user.py 內:

2. 將用戶的身份信息傳入序列化器,生成 `token`

3. 在讀取 `token`后將身份信息返回并保存在 `g`變量中,以便使用的時候方便讀取,ginger/app/libs/token\_auth.py 內:(`@auth.verify_password`是在`@auth.login_required`內起作用的,所以全局搜索不到`verify_password`函數的調用情況) 
4. 編寫接口的時候取出保存在 `g`變量中的用戶信息,并判斷是否允許用戶調用接口,ginger/app/v1/user.py 內: 
現在看似我們已經很完美的解決了這個問題,但是這種寫法非常差:
1. 太啰嗦、不夠優雅,理論上我們需要在對普通用戶有限制的接口里都需要判斷管理員身份,接口一多的話寫起來就很煩
2. 我們把權限想的太簡單了,我們在這里做了一個很簡單的普通用戶和管理員的區別。但是在一個真正的復雜的項目里面,這種權限的分組可能只有普通用戶和管理員兩個嗎?可不可能除了這兩個之外還有超級管理員呢?可不可能還有其他的級別呢?這種寫法可以應用到你的簡單項目里面。但是做項目和做框架差別是很大的,做框架的話我們一定要考慮到普適性。
### 8-5 比較好的權限管理方案
上節我們只是考慮到了最簡單的情況,下面看一下復雜一點你的情況:

好一點的解決方案:
假如我們在代碼里做三張表,每一個表里都記錄著某種權限,現在假如有一個請求進來了,之前的代碼大家都知道如果一個請求訪問帶有`@auth.login_required`的接口的話,它必須是攜帶有這個令牌的,而且從前面幾節課里面我們也知道從 `token`里我們可以知道當前的請求它的權限類型(用戶、管理員、超級管理員)并且我們也能夠從請求中獲取需要訪問的接口,那么我們就可以帶著**權限類型**、**接口**去對應權限的表里查詢,如果能查詢得到則表示允許訪問,查詢不到則表示禁止訪問。
之前的解決方案有一點不好就是我們是進入到`super_get_user`接口之后,在`super_get_user`接口中判斷是否能夠訪問該接口,這個不太好。現在這個解決方案的好處就是我們在進入接口之前就能判斷用戶是否具有訪問某個接口的權限,如果沒有的話就根本不會讓用戶進入接口。

### 8-6 實現Scope權限管理 一
首先我們需要為上節中的表起個名字,對于每一個具體的表來說的話,他們需要一個具體的名字。

實現步驟:
1. 我們假設項目里只有1和2,也就是只有管理員和普通用戶兩種 `Scope`,但是在實際項目中需要靈活一些,你的 `auth`可能有很多權限那么在獲取身份權限的時候就必須根據數字或者其他的標識來返回對應的 Scope。在 ginger/app/models/user.py 中:

2. 將身份信息傳入序列化器,序列化生成 `token`,在 ginger/app/api/v1/token.py 中:

3. 在讀取 `token`后將身份信息返回并保存在 `g`變量中,以便使用的時候方便讀取,ginger/app/libs/token\_auth.py 內:(`@auth.verify_password`是在`@auth.login_required`內起作用的,所以全局搜索不到`verify_password`函數的調用情況) **注意:**
* 調用 `is_in_scope`函數判斷該用戶所屬權限組能否訪問該接口,返回的是布爾值,在判斷布爾值即可
* 調用`request.endpoint`可以獲取該請求訪問的 `api`

4. 編寫 ginger/app/libs/scope.py 文件:
* 編寫相關的 `scope`類將對應權限下允許訪問的接口放到**類屬性**`allow_api`中
* 編寫`is_in_scope`函數判斷該用戶是否具有訪問該接口的權限

到這里基本的思路已經寫完了,這樣寫有問題嗎?我們使用 postman 測試一下。本節就到這里,這個問題下一節解決。

### 8-7 globals()實現反射
實際上經過調試很容易發現我們在 ginger/app/libs/token\_auth.py 傳入 `is_in_scope(scope, request.endpoint)`的 `scope`參數其實是字符串

所以我們在 ginger/app/libs/scope.py 中調用`scope.allow_api`會報錯。那怎么解決這個問題呢?因為這個字符串的名字就是跟我們定義的具體的權限組的名稱是一樣的,也就是說我們如何通過一個類的名字獲得這個類對象呢?
使用 `globals()`函數,我們實例化一個`globals()`函數看看它到底是什么? 看下圖調試結果可以得到,`globals()`函數可以將當前模塊下所有函數、類、變量的名字都提取出來作為鍵,名稱所對應的對象作為值整理成一個字典返回。

代碼其實很簡單,我們將 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` 與我們放在權限組里的視圖函數名字對不上

那么我們再思考下為什么需要加上 `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`。該怎么辦呢?

### 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 內:

這樣我們就在 `endpoint`里得到了模塊的名字,因為我們在寫視圖函數模塊的時候,模塊名=Redpoint。所以我們在 Redpoint 內部寫 Redpoint 注冊函數的時候,將 Redpoint 名字添加到 endpoint 里面去,就能在后面用的時候拿到模塊名。
在 ginger/app/libs/scope.py 內:

完美解決問題。
### 8-15 Scope優化 七 支持排除
支持`allow_module`相加

#### 權限排除
添加 `forbidden_api`屬性,并支持權限相加:


> 注意:`forbidden_api`、`allow_api`、`allow_module`三個判斷的順序很重要,不能錯