Phoenixで作るGPS Logging System 5 APIの認証

# はじめに
[ひとりLiveView Advent Calendar](https://qiita.com/advent-calendar/2021/liveview_alone) の5日目の記事です

この記事は[Elixir Conf US 2021](https://www.elixirconf.com/)の[発表](https://www.youtube.com/watch?v=vHNqZmLLTj4)したシステムの構築と関連技術の解説を目的とした記事です

今回は以下の4つを実装します

- 認証トークン発行API
- APIのエラーハンドリング
- API request時のトークン認証
- token リフレッシュ

# 認証トークン発行API
day1 で phx.gen.authをで認証機能を追加しましたが、APIの認証は含まれていないのでこちらで実装していきます。
[こちら](https://qiita.com/the_haigo/items/a194afc7d859b4ef48e5)の記事ではif文を使っていましたが、今回はwithで書いていきます。

withは式の右側の結果を左側とパターンマッチさせます。
例外が起きた時はelse側に行きますが、`get_user_by_email_and_password`は見つからなかった場合はnilを返すため、常にtrue側になってしまいます。
なのでガード節でnilでないかをチェックしています。

withを全てパスした場合はtokenを返す viewを表示します。

また action_fallbackが付いて認証失敗時も errorのタプルを返しているだけですが、次の節で解説するので一旦置いておきます。

```lib/live_logger_web/controllers/user_api_session_controller.ex
defmodule LiveLoggerWeb.UserApiSessionController do
  use LiveLoggerWeb, :controller

  alias LiveLogger.Accounts
  action_fallback LiveLoggerWeb.FallbackController

  def create(conn, %{"email" => email, "password" => password}) do
    with user when is_struct(user) <- Accounts.get_user_by_email_and_password(email, password),
         token <- user |> Accounts.generate_user_session_token() |> Base.encode64() do
      render(conn, "token.json", token: token)
    else
      _error -> {:error, :unauthorized}
    end
  end
end
```


```lib/live_logger_web/views/user_api_session_view.ex
defmodule LiveLoggerWeb.UserApiSessionView do
  use LiveLoggerWeb, :view

  def render("token.json", %{token: token}) do
    %{token: token}
  end
end
```

routerに `api/signin`でendpointを追加します

```lib/live_logger_web/router.ex
defmodule LiveLoggerWeb.Router do
  use LiveLoggerWeb, :router

  # Other scopes may use custom stacks.
  scope "/api", LiveLoggerWeb do
    pipe_through :api

    resources "/maps", MapController, except: [:new, :edit]
    resources "/points", PointController, only: [:create]
    post "/signin", UserApiSessionController, :create #追加
  end
end
```

# APIのエラーハンドリング
phx.gen.jsonを実行した際に fallback_controllerというものが作成されます。
これをaction_fallbackマクロに指定することによって、エラー時にfallback_controllerに定義されたエラーをレスポンスとして返します。

action_callbackは各actionがconnを返さなかった場合にfallbackに指定したモジュールの関数にマッチしたエラーレスポンスを返します、phx.gen.jsonでは404しか定義されていなかったので、認証失敗とバリデーションエラー時を追加します

https://hexdocs.pm/phoenix/Phoenix.Controller.html#action_fallback/1

```lib/live_logger_web/controllers/fallback_controller.ex
defmodule LiveLoggerWeb.FallbackController do
  @moduledoc """
  Translates controller action results into valid `Plug.Conn` responses.

  See `Phoenix.Controller.action_fallback/1` for more details.
  """
  use LiveLoggerWeb, :controller

  # This clause is an example of how to handle resources that cannot be found.
  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(LiveLoggerWeb.ErrorView)
    |> render(:"404")
  end

  # 以下追加
  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:unauthorized)
    |> put_view(LiveLoggerWeb.ErrorView)
    |> render(:"401")
  end

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(LiveLoggerWeb.ChangesetView)
    |> render("error.json", changeset: changeset)
  end
end
```

render :401,:404と指定するだけで`Not Found`や`Unauthorized`が表示されるのですが、
それがなぜかというと、phx.gen.jsonで作成されたerror_viewで以下の関数が実行されて
[Phoenix.Controller.status_message_from_template](https://hexdocs.pm/phoenix/Phoenix.Controller.html#status_message_from_template/1)
その関数内で[Plug.Conn.Status.reason_phrase()](https://github.com/phoenixframework/phoenix/blob/a3a470e6bc4ecefbfac783f465e0fa130f672d4c/lib/phoenix/controller.ex#L1430
)が呼ばれています。
Plug.Conn.Statusをみると各http statusが列挙されていて、
[reason_phrase](https://hexdocs.pm/plug/Plug.Conn.Status.html#reason_phrase/1)に status codeを入れることでstatus名が返ってきます

```lib/live_logger_web/views/error_view.ex
defmodule LiveLoggerWeb.ErrorView do
  use LiveLoggerWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  # def render("500.html", _assigns) do
  #   "Internal Server Error"
  # end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end
```

では動作確認をしてみましょう、登録していないemailとpasswordを入力してpostすると
401 Unauthorizedが返ってきています

![スクリーンショット 2021-12-08 15.25.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/f840a9dc-1c18-f1b0-1514-1ef3712b906b.png)

バリデーションエラーを起こした場合はこちらのViewを指定しています
内容としては [Ecto.Changeset.traverse_errors](https://hexdocs.pm/ecto/Ecto.Changeset.html#traverse_errors/2)を実行していい感じのJSONにしてエラーを返しています

```lib/live_logger_web/views/changeset_view.ex
defmodule LiveLoggerWeb.ChangesetView do
  use LiveLoggerWeb, :view

  @doc """
  Traverses and translates changeset errors.

  See `Ecto.Changeset.traverse_errors/2` and
  `LiveLoggerWeb.ErrorHelpers.translate_error/1` for more details.
  """
  def translate_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
  end

  def render("error.json", %{changeset: changeset}) do
    # When encoded, the changeset returns its errors
    # as a JSON object. So we just pass it forward.
    %{errors: translate_errors(changeset)}
  end
end
```

こちらも動作確認を行いましょう。
前に作った api/pointsに関係ないデータをpostします。

![スクリーンショット 2021-12-08 15.30.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/33f15355-de6e-67b5-a5f2-5b22a8192988.png)

無事バリデーションエラーの内容が返ってくるのを確認しました

# API request時のトークン認証
認証はauthorization headerのBearer tokenで行います
authorization haderには "Bearer " + tokenで間に半角スペースがあるのでパターンマッチさせる場合には注意してください。

認証に成功した場合はuserとtokenをアサインしてAPIの関数を実行します
エラー時の処理ですが、user_authはコントローラーではないので action_fallbackは指定できないため、直接FallbackControllerの関数を呼び出しています。
最後にhalt()でコネクションを落とすのを忘れないようにしましょう

```lib/live_logger_web/controllers/user_auth.ex
defmodule LiveLoggerWeb.UserAuth do
...
  def require_authenticated_token(conn, _opts) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, binary_token} <- Base.decode64(token),
         user when is_struct(user) <- Accounts.get_user_by_session_token(binary_token) do
      conn
      |> assign(:current_user, user)
      |> assign(:token, binary_token)
    else
      _error ->
        conn
        |> LiveLoggerWeb.FallbackController.call({:error, :unauthorized})
        |> halt()
    end
  end
...
end
```
mapとpointsのAPIをトークン認証を行わないと実行できないように routerを編集します

```lib/live_logger_web/router.ex
defmodule LiveLoggerWeb.Router do
  ...
  # maps,points削除
  scope "/api", LiveLoggerWeb do
    pipe_through :api

    post "/signin", UserApiSessionController, :create
  end
  # 以下追加
  scope "/api", LiveLoggerWeb do
    pipe_through [:api, :require_authenticated_token]

    resources "/maps", MapController, except: [:new, :edit]
    resources "/points", PointController, only: [:create]
  end
  ...
end
```
それでは動作確認を行っていきましょう

トークン作成
![スクリーンショット 2021-12-08 15.58.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/00052636-bfe0-7765-1aad-ec6980c103f1.png)

トークン認証成功
![スクリーンショット 2021-12-08 15.58.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/fc96eb5b-3c2a-ed36-529d-43db08a857a5.png)

トークン認証失敗
![スクリーンショット 2021-12-08 16.04.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/1febfce9-ea49-3a61-6fdd-6c7b13054d70.png)

# token リフレッシュ
最後にトークンリフレッシュを実装します。
アプリで一度認証して同じトークンを長期間使い回すとセキュリティ上あまり良くはないので、
古いトークンを破棄して新しいトークンを作成するAPIを実装します。
関数の引数はパターンマッチマッチで展開することで特定の値を取得することができます。
これを応用して同じ関数でも引数によって挙動を変えることができます。

トークンの破棄と新規作成はphx.gen.authで作成済みなので、encodeしてトークンを返すだけです

```lib/live_logger_web/controllers/user_api_session_controller.ex
defmodule LiveLoggerWeb.UserApiSessionController do
  ...
  def refresh_token(%{assigns: %{current_user: user, token: old_token}} = conn, _params) do
    Accounts.delete_session_token(old_token)

    new_token =
      user
      |> Accounts.generate_user_session_token()
      |> Base.encode64()

    render(conn, "token.json", token: new_token)
  end
end
```
routerに追加して完了です

```lib/live_logger_web/router.ex
defmodule LiveLoggerWeb.Router do
  ...
  scope "/api", LiveLoggerWeb do
    pipe_through [:api, :require_authenticated_token]

    resources "/maps", MapController, except: [:new, :edit]
    resources "/points", PointController, only: [:create]
    post "/refresh_token", UserApiSessionController, :refresh_token # 追加
  end
  ...
end
```
動作確認をしていきましょう

![スクリーンショット 2021-12-08 16.48.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/e818c5d3-e978-2009-ba63-b8471cc7daec.png)
新しいtokenが発行されています

古いトークンで再度実行します
![スクリーンショット 2021-12-08 16.55.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/14f66364-2081-9a8c-ae54-11db36ea0fd2.png)

古いのは破棄されているので 401が返ってきます

# おまけ
tokenの文字数を増やしたり、有効期間を長くしたい場合は以下を変更することで可能です。
## tokenの文字数を増やしたい

```lib/live_logger/accounts/user_token.ex
defmodule LiveLogger.Accounts.UserToken do
  use Ecto.Schema
  import Ecto.Query

  @hash_algorithm :sha256
  @rand_size 32 # ここを増やす
...
end
```
## tokenの有効期限を長くしたい

```lib/live_logger_web/controllers/user_auth.ex
defmodule LiveLoggerWeb.UserAuth do
  import Plug.Conn
  import Phoenix.Controller

  alias LiveLogger.Accounts
  alias LiveLoggerWeb.Router.Helpers, as: Routes

  # Make the remember me cookie valid for 60 days.
  # If you want bump or reduce this value, also change
  # the token expiry itself in UserToken.
  @max_age 60 * 60 * 24 * 60 # 最後の60が日数なのでここを増やす
end
```

# 最後に
API認証もphx.gen.authとphx.gen.jsonで楽に実装できました
本記事は以上になります。

次はemailとパスワードを入力しにくいデバイス用にパスコードを使用したAPI認証を実装します

# code
https://github.com/thehaigo/live_logger/tree/day5

# 参考ページ
https://qiita.com/the_haigo/items/a194afc7d859b4ef48e5
https://hexdocs.pm/phoenix/Phoenix.Controller.html#action_fallback/1
https://hexdocs.pm/phoenix/Phoenix.Controller.html#status_message_from_template/1
https://github.com/phoenixframework/phoenix/blob/a3a470e6bc4ecefbfac783f465e0fa130f672d4c/lib/phoenix/controller.ex#L1430
https://hexdocs.pm/plug/Plug.Conn.Status.html#reason_phrase/1
https://hexdocs.pm/ecto/Ecto.Changeset.html#traverse_errors/2