はじめに
この記事は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
に変更します
defp deps do
[
- {:bcrypt_elixir, "~> 3.0"},
+ {:pbkdf2_elixir, "~> 2.0"},
...
]
end
変更したら以下のコマンドでライブラリを追加後DBへマイグレーションを実行します
mix deps.get
mix ecto.migrate
パスワードハッシュで使用している箇所をpbkdf2に置き換えます
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
パスワードの一致検証の箇所にあるので差し替えます
@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
テスト実行時の設定もあるのでそちらも変更します
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の末尾に追加してライブラリを取得します
defp deps do
[
{:desktop_setup, github: "thehaigo/desktop_setup", only: :dev},
+ {:ex_machina, "~> 2.8.0", only: :test}
]
end
mix desp.get
セットアップ
テスト開始前にex_machinaのプロセスを起動するようにします
+ {: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
を移植します
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の関数も展開していきます
defmodule Trarecord.Factory do
use ExMachina.Ecto, repo: Trarecord.Repo
use Trarecord.UserFactory
end
ファクトリモジュールの読み込み
ファクトリを作成したので各所
conn_case
(controllerやlive_test)とdata_case
(コンテキスト周り)で読み込みます
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
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
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 "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 "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で初期化しておきます
describe "update_user_password/3" do
setup do
- %{user: user_fixture()}
+ %{user: insert(:user, password: nil)}
end
describe "reset_user_password/2" do
setup do
- %{user: user_fixture()}
+ %{user: insert(:user, password: nil)}
end
user_login_test.exs.exs
insert時にパスワードのハッシュ化はしてくれないので明示的に行うようにします
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
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
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
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
CIが無事通ったのでマージして完了です
最後に
認証機能を実装し、テストのデータの生成をfixutesからex_machinaに変更できました
次は認証機能のセッション管理を変更していきます。
本記事は以上になりますありがとうございました