BlockAuth 基本設計以及在實現中的一些思考

作者: taotao(後端程式設計師)
BlockAuth 模組幾乎是所有系統中必不可少的一個環節,承載使用者註冊、登入、授權等各種常規操作,為整個系統中的其他邏輯提供支援。在當前階段,BlockAuth 模組主要為 OCAP service 提供支援,透過對使用者區分不同的 role,來分配不同的 service quota,以此提升 OCAP service 使用體驗。現階段而言,BlockAuth 模組採用相對於分散式 ID 方案(DID)更加中心化的實現,但在 DID 技術成熟後可以平滑過渡到去中心的實現。
本文試圖從 BlockAuth 模組常見的元件入手,闡述 BlockAuth 的基本設計以及在實現過程中遇到的一些有趣的想法和思考。其中,常見元件主要會介紹:
- User Register
- JWT (Json Web Token)
- MFA
- HMAC
- User Role
而一些有趣的想法和思考,主要會包括:
- 使用 absinthe middleware 抽象元件邏輯
- 使用 pipeline 來封裝多重邏輯
- 程式碼層面的讀寫分離
- 併發控制以及異常控制
User Register
對於使用者註冊而言,在 BlockAuth 模組中,會將 email 作為一個非常重要的引數,為了防止 email 被佔用帶給後續註冊的各種麻煩,會將驗證 email 作為前置的操作。換言之,只有在使用者的 email 被確認之後,才能進行後續的各種操作。這樣的話,可以將 email 被佔用的風險降低到最小。
BlockAuth 模組,採用的是常見 register email 流程:
- 使用者填寫郵箱
- server 端傳送驗證連結到填寫的郵箱
- 使用者點選驗證連結完成郵箱驗證
在這個過程中,email 被佔用是最常見的一種異常情形。
假如使用者 A 使用email_b 來註冊,但使用者 A 並不擁有該郵箱,正常而言,使用者 A 也就無法完整驗證,當使用者 B 也使用 email_b 來註冊時,server 端同樣會傳送驗證連結到 email_b,之後使用者 B 可以繼續完成驗證流程。
如果使用者 B 在使用email_b 註冊時,非常不幸的發現:
- 郵箱已被驗證
此時使用者 B 可以繼續完成後續的操作
- 郵箱已被註冊使用者
使用者 B 可以透過重置密碼,拿回郵箱的使用權
透過前置郵箱驗證的方式,極大程度的可以保證郵箱不會惡意佔用。(感興趣的讀者可以嘗試在 GitHub 體驗郵箱被惡意佔用的困擾)
JWT
簡言之,JWT 是一種基於 Json 的伺服器認證方案,不同於 session 儲存方式:
- JWT 是在使用者登入之後,server 端透過簽名的方式生成 JWT 資料(簡稱為 JWT Token)並返回給 client 端
- client 端需要儲存 JWT,並隨每次請求,將 JWT 置於 http request headers 中傳送給 server 端
- server 端接收到 JWT 後,解析並驗證是否被篡改
基於此,server 端將不再需要儲存 session 資料,對於 client 的請求驗證將變為 stateless 的方式,進而可以提高 server 端的擴充套件性。
但是在 JWT 的使用中,同樣存在著一定風險,假如 JWT Token 被非法截獲,JWT Token 就會被冒用,導致不可完全預料的隱患。因此,JWT Token 在生成之初,就包括了過期時間(expiration)以及重新整理 Token (refresh token)。
基本的流程:
- 過期後,access token 將無法使用
- client 可以使用 refresh token 獲取新的 access token
- 如果 refresh token 同樣過期,使用者重新登入
也就是說,如果過期時間過大,可能會導致安全性降低,過期時間多小,使用者就需要頻繁的重新登入。在 BlockAuth 模組中,會針對不同的應用,設定不同的過期時間,兼顧安全性以及使用者體驗。
MFA
在之前的分享中,小山同學已經詳細介紹過 MFA 的原理以及應用,在此就不再贅述。
HMAC
HMAC 是一種訊息校驗方式,在面向 developer 的場景是發揮作用,應用於服務端驗證 client 端的請求是否合法而未被偽造。常見的使用方式:
- client 端發起 request
- client 端計算 HMAC signature
- client 端將 request 以及 HMAC signature 傳送給 server 端
server 端接收到 client 端的請求以及 HMAC signature 之後,會採用與 client 端相同的方式產生 HMAC signature,如果與 client 端傳送的相同,則證明 client 端傳送的請求是合法而未被偽造的。
為了計算 HMAC signature,就需要構造進行簽名計算的字串,以及進行簽名的金鑰。
在 BlockAuth 模組中,基於使用 GraphQL 的前提,進行簽名的字串是由 GraphQL 的 query 請求構成:
{"query":"{\n\trichestAccounts {\n data {\n address\n }\n }\n}\n","variables":null}為了計算 signature 就需要簽名的金鑰,在 BlockAuth 模組中,使用了access_key 和 access_secret 的方式來管理金鑰。
client 端可以透過 BlockAuth 模組 create access_key access_secret 對,在計算 HMAC signature 時:
- client 端使用
access_secret作為金鑰 - 並將與之相對的
access_key傳送給 server 端 - server 端根據 client 傳送的
access_key獲得對應的access_secret - 計算 HMAC signature 簽名
此外,為了儘可能防止合法簽名的請求被冒用,client 端構造簽名以及傳送請求時,時間戳都是其中重要的一部分,server 端會校驗時間戳,如果時間戳超過一定範圍,該請求會被認為已經失效。
User Role
對於使用者許可權管理,BlockAuth 採用了 role-based access control (RBAC)的方式;對於使用者操作而言,採用的是策略控制訪問,對於不同的 Resource,可以定義 allow 的 action 列表,如:
[
{
"arn": "ocap",
"action": ["read"],
"resource": ["btc", "eth"],
"quota": {
"qps": "10/1",
"cursor_limit": 100
}
},
{
"arn": "BlockAuth",
"action": [
"get_user_by_id",
"get_user_by_email",
"mutation_register_cellphone",
"mutation_unregister_cellphone"
],
"resource": "*",
"quota": {
"query_qps": "10/1",
"mutation_qps": "1/1"
}
}
]而控制策略的基本原則是,只有顯式 allow 的 action 才能允許被執行。
結合 RBAC,為不同的 user 分配不同的 role,不同的 role 設定不同的訪問控制策略,就能到達到管理使用者許可權的效果。
在 BlockAuth 實際實現中,根據使用者檢查使用者當前操作是否被 allow 的基本流程:
- get user privilege based on user role
- check the action if allowed
- check action if exceed the quota limit
對於 action 判斷是否 allowed 的虛擬碼大致為:
case Map.get(specific_privilege, "action") do
"*" ->
{:continue, ...}
action_list when is_list(action_list) ->
if Enum.member?(action_list, action) do
{:continue, ...}
else
@forbidden
end
_ ->
@forbidden
end而對於 action 是否超出 quota limit,在 BlockAuth 模組中,採用的是Token Bucket 演算法。
在實現中的有趣的想法和思考
在整個 BlockAuth 模組的實現過程中,經過各種設計、權衡、編碼、程式碼測試,再三反覆,著實遇到一些有缺的想法和思考。至於一些基本的就不再贅述,例如單元測試的重要性,程式碼結構的設計。接下來,會從一些落腳點出發,拋磚引玉的討論幾個有趣的想法和思考。
使用 absinthe middleware 抽象元件邏輯
熟悉 ArcBlock 的同學,應該對 ArcBlock 採用的技術有一些基本的瞭解,在構建 service 時,主要採用了 GraphQL 協議以及 Elixir 程式語言,而 Absinthe 是使用 Elixir 實現的 GraphQL 框架。
基於此大背景,在邏輯實現時,針對不同的 action 邏輯,需要前置 Authenticate 操作,大致的虛擬碼:
def mutation_create(parent, args, info) do
info
|> get_jwt_token()
|> BlockAuthenticate_action("mutation_create")
|> case do
{:ok, BlockAuthenticate} -> continue_logic()
{:error, _} = error -> error
end
end換言之,對於所有的介面邏輯,都需要前置這樣的 BlockAuthenticate 操作,就可能帶來一些問題:
- 程式碼冗餘 [顯而易見]
- 單元測試冗餘 [需要為每個介面的 error case 覆蓋]
- 難以維護
所幸,GraphQL 協議中,提供了 middleware,可以前置或者後置一些操作。在 Absinthe 的實現中,定義 middleware 可採用如下方式:
@desc "Create one user access key"
field(:create_user_access_key, :user_access_key) do
middleware(ArcBlockAuthService.GQL.BlockAuth.Middleware.BlockAuthenticateAction,
action: :mutation_create_user_access_key,
action_type: :mutation
)
resolve(fn parent, args, resolution ->
Logger.metadata(mutation: :mutation_create_user_access_key)
apply(Resolver, :mutation_create_user_access_key, [parent, args, resolution])
end)
end也就是,對於實際的介面而言,在執行 resolve 函式之前,先進行 BlockAuthenticate 操作。對於感興趣的讀者朋友,繼續深入瞭解,可參見:
使用 pipeline 來封裝多重邏輯
在邏輯實現中,經常存在這樣的場景:一個複雜的邏輯操作,會由多重邏輯組成,前一重邏輯的輸出是後一重邏輯的輸入,如果某一重邏輯失敗,中斷後續的邏輯。
最容易想到的方式,大概是:
def logic() do
case fn_1() do
{:ok, _} ->
case fn_2() do
{:ok, _} ->
case fn_3() do
{:ok, _} ->
:ok
{:error_} ->
:error
end
_ ->
:error
end
_ ->
:error
end
end但是這種方式的問題也顯而易見,隨著多重邏輯的增加,最先爆炸的是程式碼的縮排,可維護性也大大降級。為了解決這類問題,在編碼時嘗試了三種方案:
1,使用 try catch 捕獲 throw
def logic() do
res_1 =
case fn_1() do
{:ok, _} = return -> return
{:error, _} = error -> throw(error)
end
res_2 =
case fn_2(res_1) do
{:ok, _} = return -> return
{:error, _} = error -> throw(error)
end
case fn_3(res_2) do
{:ok, _} = return -> return
{:error, _} = error -> throw(error)
end
catch
error ->
error
end這種方式,能夠極大的避免程式碼縮排的問題,相比較最初的方式,是提升了程式碼的維護性,但是從流線型的角度出發,還是不夠流暢。
2,使用|> 串聯多重邏輯
def logic() do
fn_1()
|> fn_2()
|> fn_3()
end
defp fn_1(), do: {:ok, nil}
defp fn_2({:error, _} = error), do: error
defp fn_2({:ok, res_1}), do: {:ok, handle_res_1(res_1)}
defp fn_3({:error, _} = error), do: error
defp fn_3({:ok, res_2}), do: {:ok, handle_res_2(res_2)}透過這種流線型 pipeline 式的方式,可以很輕鬆方便的串聯多重邏輯。
3,使用 with
使用 |> 串聯的方式仍舊有個問題,每一重邏輯函式,都需要處理 error 的 case,如果使用 with:
def logic do
with {:ok, res_1} <- fn_1(),
{:ok, res_2} <- fn_2(res_1),
{:ok, res_3} <- fn_3(res_2) do
res_3
else
err -> err
end
end
defp fn_1(), do: {:ok, "ok"}
defp fn_2(res_1), do: {:err, res_1}
defp fn_3(res_2), do: {:ok, res_2}相比較第二種方式,使用 with 的好處就是不需要在每一重邏輯函式內考慮非正常的 case。
程式碼層面的讀寫分離
從介面角度分類,可以將介面大致分為讀操作和寫操作。常見的寫操作:
- 建立使用者
- 修改使用者角色
而常見的讀操作:
- 查詢使用者資訊
- 獲取使用者許可權
對於讀操作和寫操作來說,對資料一致性和介面效能的要求,都存在一定的差異。對於寫操作,資料一致性要求就會高一些,對於介面效能的容忍度就大一些,可以接受略微的響應延遲。然而,對於讀操作,對介面效能的要求就會比較高,但是對資料一致性的要求就略微降低。
而快取是一種提升介面效能的常規手段,對於讀操作而言,可以在 database 前增加一層快取用來加速讀操作。所以在程式碼結構組織上,BlockAuth 就儘可能將讀寫操作分開,便於各自 model 層外做適當的快取用以提升介面效能:
~~~~> $>> tree
.
├── mutations
│ ├── model
│ │ ├── cellphone_state.ex
│ │ ├── email_state.ex
│ │ ├── role.ex
│ └── resolver
│ ├── cellphone.ex
│ ├── email.ex
│ ├── login.ex
│ ├── role.ex
└── queries
├── cache
│ └── user.ex
├── model
│ ├── email.ex
│ ├── roles.ex
└── resolver
├── email.ex
├── roles.ex而為了後續的擴充套件性以及給架構演進留有餘地,讀寫操作的程式碼儘可能相互不交叉,讀部分的邏輯只依賴讀部分的 model,寫部分亦然。
總結
關於 BlockAuth 模組的方方面面邊邊角角比較多,本文選出若干部分以及幾個有趣的點拿出來和大家分享。一是對內的總結,二也是希望能和大家共同交流進步。ArcBlock 是一家快速成長的公司,招小夥伴的程序一直沒有 crash,歡迎簡歷。OPEN POSITIONS