LoginSignup
3
2

More than 1 year has passed since last update.

PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【登録〜認証編】①

Last updated at Posted at 2023-03-02

はじめに

この記事ではPhoenixのバージョン1.7.0を使用しています。

$ mix phx.new -v
Phoenix installer v1.7.0

またこの記事ではアカウントの登録から認証までを見ていきます。

以下はこのシリーズの目次です。

目次

プロジェクトの作成

まず、プロジェクトの作成をします。

$ mix phx.new auth_sample
$ ce auth_sample
$ mix ecto.create

phx.gen.authの実行

途中で「LiveViewベースの認証システムを作成しますか?」と聞かれるので「y」を入力します。

$ mix phx.gen.auth Accounts Account accounts
An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Yn] y
* creating priv/repo/migrations/20230301141102_create_accounts_auth_tables.exs
* creating lib/auth_sample/accounts/account_notifier.ex
* creating lib/auth_sample/accounts/account.ex
* creating lib/auth_sample/accounts/account_token.ex
* creating lib/auth_sample_web/account_auth.ex
* creating test/auth_sample_web/account_auth_test.exs
* creating lib/auth_sample_web/controllers/account_session_controller.ex
* creating test/auth_sample_web/controllers/account_session_controller_test.exs
* creating lib/auth_sample_web/live/account_registration_live.ex
* creating test/auth_sample_web/live/account_registration_live_test.exs
* creating lib/auth_sample_web/live/account_login_live.ex
* creating test/auth_sample_web/live/account_login_live_test.exs
* creating lib/auth_sample_web/live/account_reset_password_live.ex
* creating test/auth_sample_web/live/account_reset_password_live_test.exs
* creating lib/auth_sample_web/live/account_forgot_password_live.ex
* creating test/auth_sample_web/live/account_forgot_password_live_test.exs
* creating lib/auth_sample_web/live/account_settings_live.ex
* creating test/auth_sample_web/live/account_settings_live_test.exs
* creating lib/auth_sample_web/live/account_confirmation_live.ex
* creating test/auth_sample_web/live/account_confirmation_live_test.exs
* creating lib/auth_sample_web/live/account_confirmation_instructions_live.ex
* creating test/auth_sample_web/live/account_confirmation_instructions_live_test.exs
* creating lib/auth_sample/accounts.ex
* injecting lib/auth_sample/accounts.ex
* creating test/auth_sample/accounts_test.exs
* injecting test/auth_sample/accounts_test.exs
* creating test/support/fixtures/accounts_fixtures.ex
* injecting test/support/fixtures/accounts_fixtures.ex
* injecting test/support/conn_case.ex
* injecting config/test.exs
* injecting mix.exs
* injecting lib/auth_sample_web/router.ex
* injecting lib/auth_sample_web/router.ex - imports
* injecting lib/auth_sample_web/router.ex - plug
* injecting lib/auth_sample_web/components/layouts/root.html.heex

Please re-fetch your dependencies with the following command:

    $ mix deps.get

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Once you are ready, visit "/accounts/register"
to create your account and then access "/dev/mailbox" to
see the account confirmation email.

依存関係の取得をします。

$ mix deps.get

マイグレートを実行してテーブルを作成します。

$ mix ecto.migrate

生成されたファイルの確認

mix phx.gen.authで生成されたファイルをアカウントの登録、認証の順で確認します。

アカウント登録

まずアカウント登録を確認します。

http://localhost:4000/accounts/register

Screenshot from 2023-03-01 23-30-22.png

アカウント登録に該当するソースコードは以下です。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
defmodule AuthSampleWeb.AccountRegistrationLive do
  use AuthSampleWeb, :live_view

  alias AuthSample.Accounts
  alias AuthSample.Accounts.Account

  def render(assigns) do
    ~H"""
    <div class="mx-auto max-w-sm">
      <.header class="text-center">
        Register for an account
        <:subtitle>
          Already registered?
          <.link navigate={~p"/accounts/log_in"} class="font-semibold text-brand hover:underline">
            Sign in
          </.link>
          to your account now.
        </:subtitle>
      </.header>

      <.simple_form
        for={@form}
        id="registration_form"
        phx-submit="save"
        phx-change="validate"
        phx-trigger-action={@trigger_submit}
        action={~p"/accounts/log_in?_action=registered"}
        method="post"
      >
        <.error :if={@check_errors}>
          Oops, something went wrong! Please check the errors below.
        </.error>

        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />

        <:actions>
          <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    changeset = Accounts.change_account_registration(%Account{})

    socket =
      socket
      |> assign(trigger_submit: false, check_errors: false)
      |> assign_form(changeset)

    {:ok, socket, temporary_assigns: [form: nil]}
  end

  def handle_event("save", %{"account" => account_params}, socket) do
    case Accounts.register_account(account_params) do
      {:ok, account} ->
        {:ok, _} =
          Accounts.deliver_account_confirmation_instructions(
            account,
            &url(~p"/accounts/confirm/#{&1}")
          )

        changeset = Accounts.change_account_registration(account)
        {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
    end
  end

  def handle_event("validate", %{"account" => account_params}, socket) do
    changeset = Accounts.change_account_registration(%Account{}, account_params)
    {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
  end

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    form = to_form(changeset, as: "account")

    if changeset.valid? do
      assign(socket, form: form, check_errors: false)
    else
      assign(socket, form: form)
    end
  end
end

上から順に見ていきます。

まずはmount

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
def mount(_params, _session, socket) do
  changeset = Accounts.change_account_registration(%Account{})

  socket =
    socket
    |> assign(trigger_submit: false, check_errors: false)
    |> assign_form(changeset)

  {:ok, socket, temporary_assigns: [form: nil]}
end
# ・・・(省略)
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
  form = to_form(changeset, as: "account")

  if changeset.valid? do
    assign(socket, form: form, check_errors: false)
  else
    assign(socket, form: form)
  end
end
auth_sample/lib/auth_sample_web/live/account_registration_live.ex
changeset = Accounts.change_account_registration(%Account{})

上記はAccounts.Accountのチェンジセットを作成する関数です。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
form = to_form(changeset, as: "account")

上記はPhoenix.HTML.Form構造体に引数で渡されたデータを変換する関数です。to_form/2は、マップまたはEctoチェンジセットをコンポーネントに渡すために、フォームに変換します。
第2引数で渡してるas: "account"asオプションはフォーム入力で使用されるプレフィックスです。

Phoenix.HTML.Form構造体に変換されたデータは以下です。

%Phoenix.HTML.Form{
  source: #Ecto.Changeset<
    action: nil,
    changes: %{},
    errors: [
      password: {"can't be blank", [validation: :required]},
      email: {"can't be blank", [validation: :required]}
    ],
    data: #AuthSample.Accounts.Account<>,
    valid?: false
  >,
  impl: Phoenix.HTML.FormData.Ecto.Changeset,
  id: "account",
  name: "account",
  data: #AuthSample.Accounts.Account<
    __meta__: #Ecto.Schema.Metadata<:built, "accounts">,
    id: nil,
    email: nil,
    confirmed_at: nil,
    inserted_at: nil,
    updated_at: nil,
    ...
  >,
  hidden: [],
  params: %{},
  errors: [],
  options: [method: "post"],
  index: nil,
  action: nil
}

少しわかりやすくします。これはあくまでも見やすくしている記述しているため、実際には変更はしていません。

def mount(_params, _session, socket) do
  form = 
    %Account{}
    |> Accounts.change_account_registration()
    |> to_form(as: "account")

  socket =
    socket
    |> assign(trigger_submit: false, check_errors: false)
    |> assign(form: form, check_errors: false)

  {:ok, socket, temporary_assigns: [form: nil]}
end

ここまでで、どのような値がアサインされているかわかりました。

では次にrenderの部分を見ていきます。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
def render(assigns) do
  ~H"""
  <div class="mx-auto max-w-sm">
    <.header class="text-center">
      Register for an account
      <:subtitle>
        Already registered?
        <.link navigate={~p"/accounts/log_in"} class="font-semibold text-brand hover:underline">
          Sign in
        </.link>
        to your account now.
      </:subtitle>
    </.header>

    <.simple_form
      for={@form}
      id="registration_form"
      phx-submit="save"
      phx-change="validate"
      phx-trigger-action={@trigger_submit}
      action={~p"/accounts/log_in?_action=registered"}
      method="post"
    >
      <.error :if={@check_errors}>
        Oops, something went wrong! Please check the errors below.
      </.error>

      <.input field={@form[:email]} type="email" label="Email" required />
      <.input field={@form[:password]} type="password" label="Password" required />

      <:actions>
        <.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
      </:actions>
    </.simple_form>
  </div>
  """
end

ここではコンポーネントについては省略します。

次にフォームに入力している時に呼ぶイベント"validate"を確認します。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
def handle_event("validate", %{"account" => account_params}, socket) do
  changeset = Accounts.change_account_registration(%Account{}, account_params)
  {:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end

特に複雑な処理は無く、フォームに入力があったらこのイベントが呼ばれ、チェンジセットを入力した値に更新し続ける(フォーム入力中だけ)処理です。

次にフォームに値を入力して「Create an account」ボタンを押した際に呼ぶイベント"save"を確認します。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
def handle_event("save", %{"account" => account_params}, socket) do
  case Accounts.register_account(account_params) do
    {:ok, account} ->
      {:ok, _} =
        Accounts.deliver_account_confirmation_instructions(
          account,
          &url(~p"/accounts/confirm/#{&1}")
        )

      changeset = Accounts.change_account_registration(account)
      {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
  end
end
auth_sample/lib/auth_sample_web/live/account_registration_live.ex
Accounts.register_account(account_params)

上記はフォームで入力された値を検証(チェンジセットを作成)しaccountsテーブルにデータを挿入している関数です。
データの挿入に失敗した際はチェンジセットを受け取り、フォームにエラーを表示させます。
挿入に成功した場合は次にメール送信のステップに移行します。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
Accounts.deliver_account_confirmation_instructions(account, &url(~p"/accounts/confirm/#{&1}"))

上記の関数はトークンを生成し、メールを送信する関数です。中の処理を確認します。

auth_sample/lib/auth_sample/accounts.ex
def deliver_account_confirmation_instructions(%Account{} = account, confirmation_url_fun)
    when is_function(confirmation_url_fun, 1) do
  if account.confirmed_at do
    {:error, :already_confirmed}
  else
    {encoded_token, account_token} = AccountToken.build_email_token(account, "confirm")
    Repo.insert!(account_token)
    AccountNotifier.deliver_confirmation_instructions(account, confirmation_url_fun.(encoded_token))
  end
end

上記の関数が呼ばれます。
まずifマクロでaccount.confirmed_atがtrueかfalseかを確認しています。
※elixirでのfalseはfalseまたはnilです。それ以外はすべてtrueとして扱われます。
ここでは、このアカウントが2要素認証がすでに済んでいる場合は`{:error, :already_confirmed}が返され、メールは送信されません。

auth_sample/lib/auth_sample/accounts.ex
{encoded_token, account_token} = AccountToken.build_email_token(account, "confirm")

2要素認証が済んでいない場合はまずアカウント登録用のトークンを生成するためのクエリとエンコードされたトークンを作成します。
こちらの中も確認します。

auth_sample/lib/auth_sample/accounts/account_token.ex
@hash_algorithm :sha256
@rand_size 32
def build_email_token(account, context) do
  build_hashed_token(account, context, account.email)
end

defp build_hashed_token(account, context, sent_to) do
  token = :crypto.strong_rand_bytes(@rand_size)
  hashed_token = :crypto.hash(@hash_algorithm, token)

  {Base.url_encode64(token, padding: false),
   %AccountToken{
    token: hashed_token,
    context: context,
    sent_to: sent_to,
    account_id: account.id
  }}
end

まず、バイトサイズが32のバイナリーを作成します。そのバイナリーをsha256でハッシュ化します。
次にtoken変数のバイナリーをBase64でエンコードした文字列を作成します。
AccountToken構造体にはそれぞれ値を割り当てます。
:tokensha256でハッシュ化したトークン
:contextは引数で渡された"confirm"
:sent_toはアカウントのメールアドレス
:account_idはアカウントのid

エンコードされた値とAccountToken構造体をタプルの形にして返却しています。

その次に

auth_sample/lib/auth_sample/accounts.ex
Repo.insert!(account_token)
AccountNotifier.deliver_confirmation_instructions(account, confirmation_url_fun.(encoded_token))

受け取ったAccountToken構造体をaccount_tokenテーブルに挿入します。
その次にメール送信を行います。
その前にAccountNotifier.deliver_confirmation_instructions/2関数の第2引数は無名関数で作成したurlです。
処理をわかりやすくするしたものは以下です。

auth_sample/lib/auth_sample/accounts.ex
def deliver_account_confirmation_instructions(%Account{} = account, confirmation_url_fun)
    when is_function(confirmation_url_fun, 1) do
  if account.confirmed_at do
    {:error, :already_confirmed}
  else
    {encoded_token, account_token} = AccountToken.build_email_token(account, "confirm")
    Repo.insert!(account_token)
    # url = url(~p"/accounts/confirm/#{encoded_token}") # 下記の実際の処理
    url = confirmation_url_fun.(encoded_token) # http://localhost:4000/accounts/confirm/xxxxxxxx xのところにエンコードされたトークンの文字列が入ります。
    AccountNotifier.deliver_confirmation_instructions(account, url)
  end
end

ここまでの処理で登録されたアカウントのメールアドレス宛に2要素認証のurlを含めたメールが送信されます。

ではliveに戻って次の処理を確認しましょう。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
changeset = Accounts.change_account_registration(account)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

もう一度アカウントのチェンジセットを作成しています。
ここで注目するのはassign(trigger_submit: true)です。
trigger_submitはLiveViewのフォームバインディングです。
どのような属性かというと、フォームのaction属性で指定されたURLへフォームを送信することができます。
どういうことかというとtrigger_submittrueの時は、phx-submitで指定したアクションを呼ばずにLiveViewを切断してaction属性で指定したURLへフォームを送信します。trigger_submitfalseの時はphx-submitで指定したアクションを呼びます。
では実際にどのように使用するかはもう一度"save"アクションの関数を確認します。

auth_sample/lib/auth_sample_web/live/account_registration_live.ex
def handle_event("save", %{"account" => account_params}, socket) do
  case Accounts.register_account(account_params) do
    {:ok, account} ->
      {:ok, _} =
        Accounts.deliver_account_confirmation_instructions(
          account,
          &url(~p"/accounts/confirm/#{&1}")
        )

      changeset = Accounts.change_account_registration(account)
      {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
  end
end

アカウントの挿入が正常に行われた場合はtrigger_submittrueにしています。挿入に問題があった場合はtrigger_submitfalseのままでsocketを返しています。
このようにtrigger_submitはLiveViewフォームの送信前の検証を実行する際に使います。

LiveViewはsocket通信を使用するのでセッションに新しく追加するなどの操作ができないので、フォームをコントローラーのアクションに渡してセッションを追加するためにtrigger_submitを使用しているんだと思います。

trigger_submittrueになった状態でフォームの送信を受け取るコントローラーのアクションは以下です。

auth_sample/lib/auth_sample_web/controllers/account_settion_controller.ex
defmodule AuthSampleWeb.AccountSessionController do
  use AuthSampleWeb, :controller

  alias AuthSample.Accounts
  alias AuthSampleWeb.AccountAuth

  def create(conn, %{"_action" => "registered"} = params) do
    create(conn, params, "Account created successfully!")
  end

# ・・・(省略)

  defp create(conn, %{"account" => account_params}, info) do
    %{"email" => email, "password" => password} = account_params

    if account = Accounts.get_account_by_email_and_password(email, password) do
      conn
      |> put_flash(:info, info)
      |> AccountAuth.log_in_account(account, account_params)
    else
      # In order to prevent user enumeration attacks, don't disclose whether the email is registered.
      conn
      |> put_flash(:error, "Invalid email or password")
      |> put_flash(:email, String.slice(email, 0, 160))
      |> redirect(to: ~p"/accounts/log_in")
    end
  end
# ・・・(省略)
end

こちらは以前のgen_authとほぼ同じです。

受け取ったメールアドレスとパスワードからアカウントを取得できればログイン、できなければ、ログイン画面にリダイレクトするようになっています。

AccountAuth.log_in_account(account, account_params)の関数を見ていきましょう。

auth_sample/lib/auth_sample_web/account_auth.ex
@max_age 60 * 60 * 24 * 60
@remember_me_cookie "_auth_sample_web_account_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]

def log_in_account(conn, account, params \\ %{}) do
  token = Accounts.generate_account_session_token(account)
  account_return_to = get_session(conn, :account_return_to)

  conn
  |> renew_session()
  |> put_token_in_session(token)
  |> maybe_write_remember_me_cookie(token, params)
  |> redirect(to: account_return_to || signed_in_path(conn))
end

defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
  put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
end

defp maybe_write_remember_me_cookie(conn, _token, _params) do
  conn
end

# ・・・(省略)

defp renew_session(conn) do
  conn
  |> configure_session(renew: true)
  |> clear_session()
end

# ・・・(省略)

defp put_token_in_session(conn, token) do
  conn
  |> put_session(:account_token, token)
  |> put_session(:live_socket_id, "accounts_sessions:#{Base.url_encode64(token)}")
end

# ・・・(省略)

defp signed_in_path(_conn), do: ~p"/"

ここで行っている処理は、まずアカウントのログイン用トークンを生成します。
次にsessionにトークを追加します。
最後にsessionにはaccount_return_toは追加されていないのでhttp://localhost:4000にリダイレクトします。

ではフォームにメールアドレスとパスワードを入力してフォームを送信します。
送信して正常にアカウントが作成されるとホームにリダイレクトします。
Screenshot from 2023-03-02 01-45-06.png

2要素認証

続いて2要素認証について確認します。

まず以下のurlにアクセスして先程送信されたメールを確認します。

http://localhost:4000/dev/mailbox

アクセスして一番上にあるメールを開くと2要素認証のurlが確認できます。

Screenshot from 2023-03-02 01-47-41.png

ここに記載されているhttp://localhost:4000/accounts/confirm/xxxxxxxxにアクセスします。

Screenshot from 2023-03-02 16-29-19.png

2要素認証のソースコードは以下です。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
defmodule AuthSampleWeb.AccountConfirmationLive do
  use AuthSampleWeb, :live_view

  alias AuthSample.Accounts

  def render(%{live_action: :edit} = assigns) do
    ~H"""
    <div class="mx-auto max-w-sm">
      <.header class="text-center">Confirm Account</.header>

      <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
        <.input field={@form[:token]} type="hidden" />
        <:actions>
          <.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
        </:actions>
      </.simple_form>

      <p class="text-center mt-4">
        <.link href={~p"/accounts/register"}>Register</.link>
        |
        <.link href={~p"/accounts/log_in"}>Log in</.link>
      </p>
    </div>
    """
  end

  def mount(%{"token" => token}, _session, socket) do
    form = to_form(%{"token" => token}, as: "account")
    {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
  end

  # Do not log in the account after confirmation to avoid a
  # leaked token giving the account access to the account.
  def handle_event("confirm_account", %{"account" => %{"token" => token}}, socket) do
    case Accounts.confirm_account(token) do
      {:ok, _} ->
        {:noreply,
         socket
         |> put_flash(:info, "Account confirmed successfully.")
         |> redirect(to: ~p"/")}

      :error ->
        # If there is a current account and the account was already confirmed,
        # then odds are that the confirmation link was already visited, either
        # by some automation or by the account themselves, so we redirect without
        # a warning message.
        case socket.assigns do
          %{current_account: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
            {:noreply, redirect(socket, to: ~p"/")}

          %{} ->
            {:noreply,
             socket
             |> put_flash(:error, "Account confirmation link is invalid or it has expired.")
             |> redirect(to: ~p"/")}
        end
    end
  end
end

まず、mountから確認します。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
def mount(%{"token" => token}, _session, socket) do
  form = to_form(%{"token" => token}, as: "account")
  {:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
end

mountではurlのパラメータからtokenの値を取得して,その値をPhoenix.HTML.Form構造体に変換しています。

Phoenix.HTML.Form構造体は以下です。

%Phoenix.HTML.Form{
  source: %{"token" => "y-J6QuGXGR9sUlUMqJPYvgRojMxdLL77pvTRWaqdOsw"},
  impl: Phoenix.HTML.FormData.Map,
  id: "account",
  name: "account",
  data: %{},
  hidden: [],
  params: %{"token" => "y-J6QuGXGR9sUlUMqJPYvgRojMxdLL77pvTRWaqdOsw"},
  errors: [],
  options: [],
  index: nil,
  action: nil
}

Phoenix.HTML.Form構造体をsocketにアサインしてレンダリングしています。

続いてrenderを確認します。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
def render(%{live_action: :edit} = assigns) do
  ~H"""
  <div class="mx-auto max-w-sm">
    <.header class="text-center">Confirm Account</.header>

    <.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
      <.input field={@form[:token]} type="hidden" />
      <:actions>
        <.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
      </:actions>
    </.simple_form>

    <p class="text-center mt-4">
      <.link href={~p"/accounts/register"}>Register</.link>
      |
      <.link href={~p"/accounts/log_in"}>Log in</.link>
    </p>
  </div>
  """
end

フォームの送信先は"confirm_account"のイベントに送信するみたいです。

では"confirm_account"のインベントを確認します。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
def handle_event("confirm_account", %{"account" => %{"token" => token}}, socket) do
  case Accounts.confirm_account(token) do
    {:ok, _} ->
      {:noreply,
        socket
        |> put_flash(:info, "Account confirmed successfully.")
        |> redirect(to: ~p"/")}

    :error ->
      case socket.assigns do
        %{current_account: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
          {:noreply, redirect(socket, to: ~p"/")}

        %{} ->
          {:noreply,
            socket
            |> put_flash(:error, "Account confirmation link is invalid or it has expired.")
            |> redirect(to: ~p"/")}
      end
  end
end

まず、フォームのパラメータから受け取ったtokenを使用してアカウントを取得します。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
Accounts.confirm_account(token)

Accounts.confirm_account/1関数を確認します。

auth_sample/lib/auth_sample/accounts.ex
def confirm_account(token) do
  with  {:ok, query} <- AccountToken.verify_email_token_query(token, "confirm"),
        %Account{} = account <- Repo.one(query),
        {:ok, %{account: account}} <- Repo.transaction(confirm_account_multi(account)) do
    {:ok, account}
  else
    _ -> :error
  end
end

defp confirm_account_multi(account) do
  Ecto.Multi.new()
  |> Ecto.Multi.update(:account, Account.confirm_changeset(account))
  |> Ecto.Multi.delete_all(:tokens, AccountToken.account_and_contexts_query(account, ["confirm"]))
end

引数で受け取ったtokenとcontextの"confirm"を次の関数に渡しています。

auth_sample/lib/auth_sample/accounts.ex
AccountToken.verify_email_token_query(token, "confirm")

AccountToken.verify_email_token_query/2を確認します。

auth_sample/lib/auth_sample/accounts/account_token.ex
@confirm_validity_in_days 7
# ・・・(省略)
def verify_email_token_query(token, context) do
  case Base.url_decode64(token, padding: false) do
    {:ok, decoded_token} ->
      hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
      days = days_for_context(context)

      query =
        from token in token_and_context_query(hashed_token, context),
          join: account in assoc(token, :account),
          where: token.inserted_at > ago(^days, "day") and token.sent_to == account.email,
          select: account

      {:ok, query}

    :error ->
      :error
  end
end

defp days_for_context("confirm"), do: @confirm_validity_in_days

# ・・・(省略)
def token_and_context_query(token, context) do
  from AccountToken, where: [token: ^token, context: ^context]
end

まず、引数で受け取ったtokenをデコードします。

auth_sample/lib/auth_sample/accounts/account_token.ex
Base.url_decode64(token, padding: false)

続いてデコードしたトークンをハッシュ化します。

auth_sample/lib/auth_sample/accounts/account_token.ex
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)

days変数に整数の7を束縛しています。

auth_sample/lib/auth_sample/accounts/account_token.ex
days = days_for_context(context)

クエリではトークンが挿入された日が7日以内で、:sent_toのメールアドレスとアカウントのメールアドレスが一致していればアカウントを返すものです。

auth_sample/lib/auth_sample/accounts/account_token.ex
query =
  from token in token_and_context_query(hashed_token, context),
    join: account in assoc(token, :account),
    where: token.inserted_at > ago(^days, "day") and token.sent_to == account.email,
    select: account

最終的にタプルで{:ok, query}を返却します。

ではAccounts.confirm_account/1関数に戻ります。

auth_sample/lib/auth_sample/accounts/account_token.ex
%Account{} = account <- Repo.one(query),

受け取ったクエリでテーブルからアカウントを取得します。

auth_sample/lib/auth_sample/accounts/account_token.ex
{:ok, %{account: account}} <- Repo.transaction(confirm_account_multi(account))

# ・・・(省略)

defp confirm_account_multi(account) do
  Ecto.Multi.new()
  |> Ecto.Multi.update(:account, Account.confirm_changeset(account))
  |> Ecto.Multi.delete_all(:tokens, AccountToken.account_and_contexts_query(account, ["confirm"]))
end

トランザクションでAccountスキーマのconfirmed_atに現在時刻を割り当てチェンジセットを作成し、更新をします。

auth_sample/lib/auth_sample/accounts/account.ex
def confirm_changeset(account) do
  now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
  change(account, confirmed_at: now)
end

また、トランザクション内でcontextの値が"confirm"でアカウントのidが一致しているAccountTokenのクエリを発行して該当する値をすべて削除します。

auth_sample/lib/auth_sample/accounts/account.ex
def account_and_contexts_query(account, [_ | _] = contexts) do
  from t in AccountToken, where: t.account_id == ^account.id and t.context in ^contexts
end

ではliveに戻り次を確認します。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
def handle_event("confirm_account", %{"account" => %{"token" => token}}, socket) do
  case Accounts.confirm_account(token) do
    {:ok, _} ->
      {:noreply,
        socket
        |> put_flash(:info, "Account confirmed successfully.")
        |> redirect(to: ~p"/")}

    :error ->
      case socket.assigns do
        %{current_account: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
          {:noreply, redirect(socket, to: ~p"/")}

        %{} ->
          {:noreply,
            socket
            |> put_flash(:error, "Account confirmation link is invalid or it has expired.")
            |> redirect(to: ~p"/")}
      end
  end
end

Accounts.confirm_account/1関数でconfirm_atに現在時刻を割り当てた状態で更新をし、"confirm"の値を持つAcccountTokenを削除しました。

正常に処理が終了した場合はhome(http://localhost:4000/)にリダイレクトします。
処理が正常に終了しなかった場合は次のどちらかが処理されます。

auth_sample/lib/auth_sample_web/live/account_confirmation_live.ex
case socket.assigns do
  %{current_account: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
    {:noreply, redirect(socket, to: ~p"/")}

  %{} ->
    {:noreply,
      socket
      |> put_flash(:error, "Account confirmation link is invalid or it has expired.")
      |> redirect(to: ~p"/")}
end

現在のアカウントがすでに2要素認証が済んでいる場合は何もせずにhome(http://localhost:4000/)にリダイレクトします。
何らかの理由でアカウントの2要素認証ができなかった場合はhome(http://localhost:4000/)にリダイレクトし、警告メッセージを表示させます。

それでは「Confirm my account」ボタンを押して2要素認証をします。

Screenshot from 2023-03-02 17-48-10.png

終わり

mix phx.gen.authで生成されるコードはPhoenix1.7.0から大幅に変更され、LiveViewでも扱えるようになりました。
LiveViewではセッションに新しく値を登録できないので一度LiveViewを切断してからセッションに値を入れるところに自分も納得したなと感じでした。(今ままでLiveViewでやるにはどうすればいいのかな?と考えていましたがw)

読みづらい文章でしたが、ここまで読んで頂きありがとうございました。

次回は認証メール再送信の部分について見ていきます。

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