メインコンテンツへスキップ

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 は検証を完了できません。ユーザー B も email_b を使って登録すると、server 側は同じように検証リンクを email_b へ送信し、その後ユーザー B は検証プロセスを続行できます。

ユーザー B が email_b で登録する際、不運にも次の状況に遭遇した場合:

  • メールアドレスが検証済みである

この場合、ユーザー B はその後の操作を続行できます。

  • メールアドレスがユーザーによって登録済みである

ユーザー B はパスワードをリセットすることで、メールアドレスの使用権を取り戻せます。

メールアドレスの検証を事前に行うことで、メールアドレスの悪意ある占有を大幅に防げます。(興味のある読者は、Github でメールアドレスを悪意に占有される厄介さを体験してみてください)

JWT

簡単に言えば、JWT は Json に基づくサーバー認証方式であり、session による保存方式とは異なります。

  • ユーザーがログインすると、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 モジュールを通じて access_key と access_secret のペアを create できます。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