PhoenixのAPIサーバー向け簡易トークン認証

ここ最近、[Elixir](https://elixir-lang.org/)、[Nerves](https://www.nerves-project.org/)、[Phoenix](https://phoenixframework.org/)を使ったIoTデバイスの開発に夢中になってます。しばらく[電子工作しながら基礎を学んだ](https://qiita.com/mnishiguchi/items/5b3aadb26850a36d58f2)あと、[autoracex](https://autoracex.connpass.com/)主催者@torifukukaiouさんの[記事](https://qiita.com/torifukukaiou/items/5876bc4576e7b7991347)を参考に自宅の温度と湿度をリアルタイムで監視できるシステムを作りました。

https://qiita.com/torifukukaiou/items/5876bc4576e7b7991347

取り敢えずいい感じに動いているので、次はセキュリティ面の強化に取り組もうと思います。今日は簡易的なトークン認証を実装します。

[4/3(土) 00:00〜 4/5(月) 23:59開催のautoracex #21](https://autoracex.connpass.com/event/209286/)での成果です。
[English edition](https://dev.to/mnishiguchi/simple-token-authentication-for-phoenix-json-api-1m05)

[![hello-nerves-2](https://user-images.githubusercontent.com/7563926/113411295-6508b380-9383-11eb-81ef-942e0999d0cd.gif)](https://dev.to/mnishiguchi/iot-development-using-rapberry-pi-and-elixir-iij)

## 方針

- Plugのパイプラインに追加できるようにする。
- トークンはIExにて手動で生成する。ログインの部分は実装しない。
- APIユーザーは[Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)リクエストヘッダーにトークンを入れなければ、アクセスできない。

## Phoenix.Token

ありがたいことに、Phoenixには[`Phoenix.Token`](https://hexdocs.pm/phoenix/Phoenix.Token.html)モジュールにトークン認証に最低限必要な機能が備わっています。Nice!

## トークン認証`Plug`の実装例

`Phoenix.Token`モジュールを使用して、カスタム`Plug`を書きました。[Programming Phoenix](https://pragprog.com/titles/phoenix14/programming-phoenix-1-4/)で学んだ内容をベースにしてます。実行する内容は以下の2点です。

- `ExampleWeb.API.AuthPlug` - リクエストヘッダーのトークンを認証し、`:current_user`を `conn`に`assign`する。
- `ExampleWeb.API.AuthPlug.authenticate_api_user/2` - `:current_user`の値が存在するか確認。

```elixir
defmodule MnishiguchiWeb.API.AuthPlug do
  @moduledoc false

  import Plug.Conn, only: [get_req_header: 2, halt: 1, put_status: 2]
  import Phoenix.Controller, only: [put_view: 2, render: 2]

  @token_salt "api token"

  @doc """
  A function plug that ensures that `:current_user` value is present.

  ## Examples

      # in a router pipeline
      pipe_through [:api, :authenticate_api_user]

      # in a controller
      plug :authenticate_api_user when action in [:create]

  """
  def authenticate_api_user(conn, _opts) do
    conn
    |> get_token()
    |> verify_token()
    |> case do
      {:ok, _user_id} -> conn
      _unauthorized -> render_error(conn)
    end
  end

  defp render_error(conn) do
    conn
    |> put_status(:unauthorized)
    |> put_view(MnishiguchiWeb.ErrorView)
    |> render(:"401")
    # Stop any downstream transformations.
    |> halt()
  end

  @doc """
  Generate a new token for a user id.

  ## Examples

      iex> MnishiguchiWeb.API.AuthPlug.generate_token(1)
      "xxxxxxx"

  """
  def generate_token(user_id) do
    Phoenix.Token.sign(
      MnishiguchiWeb.Endpoint,
      @token_salt,
      user_id
    )
  end

  @doc """
  Verify a user token.

  ## Examples

      iex> MnishiguchiWeb.API.AuthPlug.verify_token("good-token")
      {:ok, 1}

      iex> MnishiguchiWeb.API.AuthPlug.verify_token("bad-token")
      {:error, :invalid}

      iex> MnishiguchiWeb.API.AuthPlug.verify_token("old-token")
      {:error, :expired}

      iex> MnishiguchiWeb.API.AuthPlug.verify_token(nil)
      {:error, :missing}

  """
  @spec verify_token(nil | binary) :: {:error, :expired | :invalid | :missing} | {:ok, any}
  def verify_token(token) do
    one_year = 365 * 24 * 60 * 60

    Phoenix.Token.verify(
      MnishiguchiWeb.Endpoint,
      @token_salt,
      token,
      max_age: one_year
    )
  end

  @spec get_token(Plug.Conn.t()) :: nil | binary
  def get_token(conn) do
    case get_req_header(conn, "authorization") do
      ["Bearer " <> token] -> token
      _ -> nil
    end
  end
end
```

## トークン認証`Plug`の使用例

関数プラグ`authenticate_api_user/2`は使用前にどこかで`import`する必要があります。2つのシナリオが考えられます。

### A: routerパイプラインで使用

このパターンはパイプラインにある全てのコントローラに対して影響を及ぼしたい場合に便利です。関数プラグを`ExampleWeb`モジュールの`router`関数の`quote`ブロック内で`import`します。

```diff
 defmodule ExampleWeb do

   ...

   def router do
     quote do
       use Phoenix.Router

       import Plug.Conn
       import Phoenix.Controller
       import Phoenix.LiveView.Router
+      import ExampleWeb.API.AuthPlug, only: [authenticate_api_user: 2]
     end
   end
```

`ExampleWeb.Router`でプラグをパイプラインに追加します。

```diff
 defmodule ExampleWeb.Router do
   use ExampleWeb, :router

   ...

   scope "/api", ExampleWeb do
-    pipe_through [:api]
+    pipe_through [:api, :authenticate_api_user]

     resources "/measurements", API.Environment.MeasurementController, only: [:index,  :show, :create]
   end
```

### B: 特定のコントローラアクションで使用

このパターンはある特定のコントローラアクションに対して影響を及ぼしたい場合に便利です。関数プラグを`ExampleWeb`モジュールの`controller`関数の`quote`ブロック内で`import`するか、もしくは各コントローラファイルにて明示的に`import`することもできます。

```diff
 defmodule ExampleWeb do

   ...

   def controller do
     quote do
       use Phoenix.Controller, namespace: ExampleWeb

       import Plug.Conn
       import ExampleWeb.Gettext
+      # パイプライン上流でにimportする場合
+      import ExampleWeb.API.AuthPlug, only: [authenticate_api_user: 2]
       alias ExampleWeb.Router.Helpers, as: Routes
     end
   end
```

コントローラ内で特定のアクションに対して使用します。

```diff
 defmodule ExampleWeb.API.MeasurementController do
   use ExampleWeb, :controller
+  # コントローラファイルにて明示的にimportする場合
+  import ExampleWeb.API.AuthPlug, only: [authenticate_api_user: 2]

   alias Example.Measurement

   action_fallback ExampleWeb.API.FallbackController
+
+  plug :authenticate_api_user when action in [:create]
```

## クイックテスト

IExを開きトークンを生成。

```elixir
iex> ExampleWeb.API.AuthPlug.generate_token(1)
"SFMyNTY.g2gDbQAAAAVoZWxsb24GABaFup54AWIAAVGA.R3AjaixW4edvVLSQjQqr9LcTieqSV1ivfBltWBZt0x0"
```

そのトークンをヘッダーに含めて、APIにアクセスしてみる。

```
❯ curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer SFMyNTY.g2gDbQAAAAVoZWxsb24GABaFup54AWIAAVGA.R3AjaixW4edvVLSQjQqr9LcTieqSV1ivfBltWBZt0x0" \
  -d '{"measurement": {"temperature_c": "23.5"}}' \
  http://localhost:4000/api/measurements
{"data":{"id":37,"temperature_c":23.5}}

❯ curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"measurement": {"temperature_c": "23.5" }}' \
  http://localhost:4000/api/measurements
"Unauthorized"
```

## 注意点

[公式ドキュメント](https://hexdocs.pm/phoenix/Phoenix.Token.html)によると、[`SECRET_KEY_BASE`](https://hexdocs.pm/phoenix/deployment.html#handling-of-your-application-secrets)がトークン生成に使用されているとのことですので、production用のトークンはproductionサーバーのIexで生成する必要があります。

もっとちゃんとしたトークン認証を実装するには、[JWT](https://jwt.io/)を使うのが一般的のようです。

ウエブフックにはそれ用のルールがあり`x-Hub-Signature-256`または`x-hub-signature`ヘッダーが使用されるとのことです。

https://jwt.io/

https://www.okta.com/identity-101/what-is-token-based-authentication/

https://phoenixbyexample.com/posts/securing-webhook-payload-delivery-in-phoenix/

https://docs.github.com/ja/developers/webhooks-and-events/securing-your-webhooks

以上