8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

本記事では、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に変更します

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

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

mix deps.get
mix ecto.migrate

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

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

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

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を導入していきます

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

ライブラリの追加

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

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のプロセスを起動するようにします

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(コンテキスト周り)で読み込みます

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
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

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

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
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で初期化しておきます

test/trarecord/accounts_test.exs:L256
  describe "update_user_password/3" do
    setup do
-     %{user: user_fixture()}
+     %{user: insert(:user, password: nil)}
    end
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時にパスワードのハッシュ化はしてくれないので明示的に行うようにします

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

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

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
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

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

最後に

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

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

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?