ElixirDesktopで作るスマホアプリ Part 2 認証機能＋ExMachinaの導入

# はじめに
この記事はElixirアドベントカレンダー2024のシリーズ２、6日目の記事です

https://qiita.com/advent-calendar/2024/elixir

本記事では、ElixirDesktopアプリに認証機能とテストデータの生成をサポートするExMachinaの導入の解説を行います

今回の作業ブランチを作成します

```
git checkout -b feature/user_auth
```

# 認証機能の実装

認証機能を実装していきます

## ジェネレーターの実行

Phoenixにはビルドインで認証機能のジェネレーターが搭載されており、以下の構文で実行できます
mix phx.gen.auth [コンテキスト名] [スキーマ名] [テーブル名]

上記のコマンドで以下の機能が構築されます
- ユーザーの新規登録 
- ユーザーのログイン、ログアウト
- メールアドレス、パスワードの変更
- パスワードリセット（メール送信も含む）
- セッション管理
- 認証・非認証時のルーティング処理

これらはすべてコードとして生成されるので、理解・改修が非常に楽です

```
mix phx.gen.auth Accounts User users
```

主に以下のファイルが生成されます
- accounts.ex -> ユーザーの取得、メール、パスワード等の更新処理
- user_notifier.ex -> 確認メール、パスワードリセット等を送信する設定・文面
- user_token.ex -> セッション等で使用される認証トークン関連の処理
- user.ex -> スキーマとバリデーション
- user_auth.ex -> 認証、セッション、ルーティング周り
- user_session_conftoller.ex -> ログイン時、新規登録時のセッション処理
- user_confirmation_instructions_live.ex ->確認メール再送画面
- user_confirmation_live.ex -> 本人確認画面
- user_forgot_password_live.ex -> パスワードリセットメール送信画面
- user_login_live.ex -> ログイン画面
- user_registration_live.ex -> 新規登録画面
- user_reset_password_live.ex -> パスワードリセット画面
- user_settings_live.ex -> パスワード、メールアドレス変更画面

これ以外はマイグレーションファイルやテストコードが生成されます

## 暗号化ライブラリの変更

ジェネレーターの実行時に依存ライブラリのリストのmix.exsの`deps/0`関数に`bcrypt_elixir`が追加されていますが、こちらはネイティブのBcryptを使用する関係上iOS、Androidでは動作しないため  `Pbkdf2`のElixir実装である`pbkdf2_elixir`に変更します

https://github.com/riverrun/pbkdf2_elixir

```diff_elixir:mix.exs:L44
  defp deps do
    [
-     {:bcrypt_elixir, "~> 3.0"},
+     {:pbkdf2_elixir, "~> 2.0"},
      ...
    ]
  end
```

変更したら以下のコマンドでライブラリを追加後DBへマイグレーションを実行します

```
mix deps.get
mix ecto.migrate
```

パスワードハッシュで使用している箇所をpbkdf2に置き換えます

```diff_elixir:lib/trarecord/accounts/user.ex:L64
  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
-     # If using Bcrypt, then further validate it is at most 72 bytes long      
+     # If using Pbkdf2, then further validate it is at most 72 bytes long
      |> validate_length(:password, max: 72, count: :bytes)
      # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
      # would keep the database transaction open longer and hurt performance.
-     |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))      
+     |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
      |> delete_change(:password)
    else
      changeset
    end
  end
```

パスワードの一致検証の箇所にあるので差し替えます

```diff_elixir:lib/trarecord/accounts/user.ex:L64
  @doc """
  Verifies the password.

  If there is no user or the user doesn't have a password, we call
- `Bcrypt.no_user_verify/0` to avoid timing attacks.
+ `Pbkdf2.no_user_verify/0` to avoid timing attacks.
  """
  def valid_password?(%Trarecord.Accounts.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
-   Bcrypt.verify_pass(password, hashed_password)
+   Pbkdf2.verify_pass(password, hashed_password)
  end

  def valid_password?(_, _) do
-   Bcrypt.no_user_verify()
+   Pbkdf2.no_user_verify()    
    false
  end
```

テスト実行時の設定もあるのでそちらも変更します

```diff_elixirconfig/test.exs
import Config

# Only in tests, remove the complexity from the password hashing algorithm
- config :bcrypt_elixir, :log_rounds, 1
+ config :pbkdf2_elixir, :log_rounds, 1
```

実装が完了したのでここで一旦コミットしておきます

```
git add .
git commit -m 'impl user authentication'
```


# ExMachinaの導入

次にテストデータの生成を楽にするExMachinaを導入していきます

https://github.com/beam-community/ex_machina

ExMachinaとはRailsでいうFactoryBotみたいなもので、テストデータを生成が非常に楽になるライブラリです

## ライブラリの追加

depsの末尾に追加してライブラリを取得します

```diff_elixir:mix.exs:L44
  defp deps do
    [
      {:desktop_setup, github: "thehaigo/desktop_setup", only: :dev},
+     {:ex_machina, "~> 2.8.0", only: :test}
    ]
  end
```

```
mix desp.get
```

## セットアップ

テスト開始前にex_machinaのプロセスを起動するようにします

```diff_elixir:test/test_helper.exs
+ {:ok, _} = Application.ensure_all_started(:ex_machina)
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Trarecord.Repo, :manual)
```

## ファクトリ作成

Userのファクトリを作成していきます

xx_factoryという関数を作成することで 
データをインサートする`insert(:user)`
インサート前の構造体を生成する`build(:user)`
フォーム用のマップを生成する`params_for(:user_form_data)`
のように呼び出すことが可能です


factory関数の他に、`accounts_fixutres.ex`にある`enique_user_email/0`, `valid_user_password/0`, `extract_user_token/1`を移植します

```test/support/factories/user_factory.ex
defmodule Trarecord.UserFactory do
  defmacro __using__(_opts) do
    quote do
      def user_factory do
        %Trarecord.Accounts.User{
          email: sequence(:email, &"user#{&1}@example.com"),
          password: valid_user_password(),
          hashed_password: Pbkdf2.hash_pwd_salt(valid_user_password())
        }
      end

      def user_form_data_factory do
        build(:user, hashed_password: nil)
      end

      def valid_user_password, do: "hello world!"

      def unique_user_email do
        sequence(:unique_user_email, &"user#{&1}@example.com")
      end

      def extract_user_token(fun) do
        {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
        [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
        token
      end
    end
  end
end
```

移植したら`test/support/fixtures/accounts_fixtures.ex`を削除します

## ファクトリ（全体）の作成

テスト時にまとめて読み込むためのファクトリを作成します
`use ExMachina.Ecto`でEcto周りの関数をマクロを通して展開し

先ほど作成したUserFactoryの関数も展開していきます

```test/support/factory.ex
defmodule Trarecord.Factory do
  use ExMachina.Ecto, repo: Trarecord.Repo

  use Trarecord.UserFactory
end
```

## ファクトリモジュールの読み込み

ファクトリを作成したので各所
`conn_case`(controllerやlive_test)と`data_case`(コンテキスト周り)で読み込みます

```diff_elixir:test/support/conn_case.ex:L20
  using do
    quote do
      # The default endpoint for testing
      @endpoint TrarecordWeb.Endpoint

      use TrarecordWeb, :verified_routes

      # Import conveniences for testing with connections
      import Plug.Conn
      import Phoenix.ConnTest
      import TrarecordWeb.ConnCase
+     import Trarecord.Factory
    end
  end
```

```diff_elixir:test/support/data_case.ex:L19
  using do
    quote do
      alias Trarecord.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import Trarecord.DataCase
+     import Trarecord.Factory
    end
  end
```


## テストコードの修正

以下の左側のコードを右側に置き換えます
vscodeならcmd + shift + f押したあと、左端にtoggleボタンがあるのでそれをクリックすると置換モードになりますのでそこに置換する文字を入れてください

```
user_fixture() -> insert(:user)
import Trarecord.AccountsFixtures -> 空文字(削除)
```

上記で置換出来なかった箇所を修正していきます


### conn_case.ex

```diff_elixir:test/support/conn_case.ex:L48
  def register_and_log_in_user(%{conn: conn}) do
-   user = Trarecord.AccountsFixtures.user_fixture()
+   user = Trarecord.Factory.insert(:user)
    %{conn: log_in_user(conn, user), user: user}
  end
```

### accoutns_test.exs

```diff_elixir:test/trarecord/accounts_test.exs:L86
    test "registers users with a hashed password" do
      email = unique_user_email()
-     {:ok, user} = Accounts.register_user(valid_user_attributes(email: email))      
+     {:ok, user} = Accounts.register_user(params_for(:user_form_data, email: email))
      assert user.email == email
      assert is_binary(user.hashed_password)
      assert is_nil(user.confirmed_at)
      assert is_nil(user.password)
    end
```

```diff_elixir:test/trarecord/accounts_test.exs:L102
    test "allows fields to be set" do
      email = unique_user_email()
      password = valid_user_password()

      changeset =
        Accounts.change_user_registration(
          %User{},
-         valid_user_attributes(email: email, password: password)
+         params_for(:user_form_data, email: email, password: password)   
        )

      assert changeset.valid?
      assert get_change(changeset, :email) == email
      assert get_change(changeset, :password) == password
      assert is_nil(get_change(changeset, :hashed_password))
    end
```

通常は保存時にDBに保存されない`virtual`カラムのパスワードは初期化されるのですが、`insert`だと残ってしまってテストがコケるのでsetupで初期化しておきます

```diff_elixir:test/trarecord/accounts_test.exs:L256
  describe "update_user_password/3" do
    setup do
-     %{user: user_fixture()}
+     %{user: insert(:user, password: nil)}
    end
```

```diff_elixir:test/trarecord/accounts_test.exs:L465
 describe "reset_user_password/2" do
    setup do
-     %{user: user_fixture()}    
+     %{user: insert(:user, password: nil)}
    end
```

### user_login_test.exs.exs

insert時にパスワードのハッシュ化はしてくれないので明示的に行うようにします

```diff_elixir:test/trarecord_web/live/user_login_live_test.exs:L27
    test "redirects if user login with valid credentials", %{conn: conn} do
      password = "123456789abcd"
-     user = user_fixture(%{password: password})     
+     user = insert(:user, hashed_password: Pbkdf2.hash_pwd_salt(password))

      {:ok, lv, _html} = live(conn, ~p"/users/log_in")

      form =
        form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true})

      conn = submit_form(form, conn)

      assert redirected_to(conn) == ~p"/"
    end
```

### user_registration_live_test

```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs:38
  describe "register user" do
    test "creates account and logs the user in", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")
      
      email = unique_user_email()
-     form = form(lv, "#registration_form", user: valid_user_attributes(email: email))
+     form = form(lv, "#registration_form", user: params_for(:user_form_data, email: email))
      render_submit(form)
      conn = follow_trigger_action(form, conn)

      assert redirected_to(conn) == ~p"/"

      # Now do a logged in request and assert on the menu
      conn = get(conn, "/")
      response = html_response(conn, 200)
      assert response =~ email
      assert response =~ "Settings"
      assert response =~ "Log out"
    end

    test "renders errors for duplicated email", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")

-     user = user_fixture(%{email: "test@email.com"})
+     user = insert(:user, email: "test@email.com")

      result =
        lv
        |> form("#registration_form",
          user: %{"email" => user.email, "password" => "valid_password"}
        )
        |> render_submit()

      assert result =~ "has already been taken"
    end
  end
```

### user_settings_live_test

```diff_elixir:test/trarecord_web/live/user_settings_live_test.exs:L27
  describe "update email form" do
    setup %{conn: conn} do
      password = valid_user_password()
-      user = user_fixture(%{password: password})
+      user = insert(:user, hashed_password: Pbkdf2.hash_pwd_salt(password))

      %{conn: log_in_user(conn, user), user: user, password: password}
    end
    ...
  end
```

```diff_elixir:test/trarecord_web/live/user_settings_live_test.exs:L84
  describe "update password form" do
    setup %{conn: conn} do
      password = valid_user_password()
-     user = user_fixture(%{password: password})
+     user = insert(:user, hashed_password: Pbkdf2.hash_pwd_salt(password))
      %{conn: log_in_user(conn, user), user: user, password: password}
    end
    ...
  end
```

テストの修正が完了したのでコミットしておきます

```
git add .
git commit -m 'add ex_machina and fix test'
```

# CI通過 & Merge

pushしてPRを作成します

```
git push origin feature/user_auth
```

![スクリーンショット 2024-12-01 23.39.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/dd7011e5-e25a-8c3c-8cad-458777086777.png)

CIが無事通ったのでマージして完了です

# 最後に

認証機能を実装し、テストのデータの生成をfixutesからex_machinaに変更できました
次は認証機能のセッション管理を変更していきます。

本記事は以上になりますありがとうございました