跳到主要內容

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

taotao(后端程序员)
ArcBlockBlockAuth

作者: 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