PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【ログイン編】③

# はじめに

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

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

[前回の記事](https://qiita.com/suzuki-t0505/items/28bf11f978fcb7a85adb)に引き続き、今回はアカウントのログインを見ていきます。

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

- [PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【登録〜認証編】①](https://qiita.com/suzuki-t0505/items/5477e9b2cb6c172b778d)
- [PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【認証メール再送信編】②](https://qiita.com/suzuki-t0505/items/28bf11f978fcb7a85adb)
- PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【ログイン編】③

## 目次

- [はじめに](#はじめに)
- [生成されたファイルの確認](#生成されたファイルの確認)
    - [ログイン](#ログイン)
- [終わり](#終わり)

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

前回はアカウントの認証メールを再送信するための機能を確認しました。
今回はログインをするまでの生成されたファイルを確認します。

### ログイン

ログインをするためのurlは以下です。
[http://localhost:4000/accounts/log_in](http://localhost:4000/accounts/log_in)

ログインに該当するソースコードは以下です。

```elixir:auth_sample/lib/auth_sample_web/live/account_login_live.ex
defmodule AuthSampleWeb.AccountLoginLive do
  use AuthSampleWeb, :live_view

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

      <.simple_form for={@form} id="login_form" action={~p"/accounts/log_in"} phx-update="ignore">
        <.input field={@form[:email]} type="email" label="Email" required />
        <.input field={@form[:password]} type="password" label="Password" required />

        <:actions>
          <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
          <.link href={~p"/accounts/reset_password"} class="text-sm font-semibold">
            Forgot your password?
          </.link>
        </:actions>
        <:actions>
          <.button phx-disable-with="Signing in..." class="w-full">
            Sign in <span aria-hidden="true">→</span>
          </.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    email = live_flash(socket.assigns.flash, :email)
    form = to_form(%{email: email}, as: "account")
    {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
  end
end
```

まず、`mount`を確認します。

```elixir:auth_sample/lib/auth_sample_web/live/account_login_live.ex
def mount(_params, _session, socket) do
  email = live_flash(socket.assigns.flash, :email)
  form = to_form(%{email: email}, as: "account")
  {:ok, assign(socket, form: form), temporary_assigns: [form: form]}
end
```

まず`live_flash/2`で`flash`にアサインされている`:email`キーの値を取得します。
続いて取得したemailを使用した`Phoenix.HTML.Form`構造体を作成します。
そして作成した`Phoenix.HTML.Form`構造体をアサインしています。
`mount`の返り値で指定しているキーワード`temporary_assigns`はレンダリングの度にその値をリセットする必要がある、アサインされているキーを指定します。値がリセットされると、明示的に割り当てられるまで再レンダリングはしません。

ソケットに`form`キーでアサインされている値は以下です。

```elixir
%Phoenix.HTML.Form{
  source: %{email: nil},
  impl: Phoenix.HTML.FormData.Map,
  id: "account",
  name: "account",
  data: %{},
  hidden: [],
  params: %{email: nil},
  errors: [],
  options: [],
  index: nil,
  action: nil
}
```

次に`render`を確認します。

```elixir:auth_sample/lib/auth_sample_web/live/account_login_live.ex
def render(assigns) do
  ~H"""
  <div class="mx-auto max-w-sm">
    <.header class="text-center">
      Sign in to account
      <:subtitle>
        Don't have an account?
        <.link navigate={~p"/accounts/register"} class="font-semibold text-brand hover:underline">
          Sign up
        </.link>
        for an account now.
      </:subtitle>
    </.header>

    <.simple_form for={@form} id="login_form" action={~p"/accounts/log_in"} phx-update="ignore">
      <.input field={@form[:email]} type="email" label="Email" required />
      <.input field={@form[:password]} type="password" label="Password" required />

      <:actions>
        <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" />
        <.link href={~p"/accounts/reset_password"} class="text-sm font-semibold">
          Forgot your password?
        </.link>
      </:actions>
      <:actions>
        <.button phx-disable-with="Signing in..." class="w-full">
          Sign in <span aria-hidden="true">→</span>
        </.button>
      </:actions>
    </.simple_form>
  </div>
  """
end
```

ここで確認すべきはフォームの送信先は`/accounts/log_in`ということです。今回は`phx-submit`が指定されていないので`AccountLoginLive`のイベントが呼ばれないということです。ではどこに送信するかというと、`AccountSessionController`の`create`アクションです。

では`AccountSessionController`の`create`アクションを確認します。

```elixir:auth_sample/lib/auth_sample_web/controllers/account_session_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

  def create(conn, %{"_action" => "password_updated"} = params) do
    conn
    |> put_session(:account_return_to, ~p"/accounts/settings")
    |> create(params, "Password updated successfully!")
  end

  def create(conn, params) do
    create(conn, params, "Welcome back!")
  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

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "Logged out successfully.")
    |> AccountAuth.log_out_account()
  end
end
```

`create`アクションの関数が3つありますが、今回呼ばれるのは以下です。

```elixir:auth_sample/lib/auth_sample_web/controllers/account_session_controller.ex
def create(conn, params) do
  create(conn, params, "Welcome back!")
end
```

では、`create/3`関数を呼び出しているのでこちらも確認します。

```elixir:auth_sample/lib/auth_sample_web/controllers/account_session_controller.ex
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
```

まず、`Accounts.get_account_by_email_and_password/2`関数にメールアドレスとパスワードを渡してアカウントを取得します。
ここで取得できなかった場合は`put_flash/3`でエラー時のメッセージとフォームから受け取ったメールアドレスを保持させます。その後`/accounts/log_in`（`AccountLoginLive`の`mount`を呼びます）にリダイレクトします。
ではアカウントを取得できた時は`put_flash/3`で引数で受け取ったメッセージを保持させます。その後、`AccountAuth.log_in_account/3`を呼びアカウントのログイン処理をします。

```elixir:auth_sample/lib/auth_sample_web/account_auth.ex
defmodule AuthSampleWeb.AccountAuth do
# ・・・（省略）

  @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"/"
end
```

まず、`Accounts.generate_account_session_token`でログイン用のトークンを作成します。
続いて、`renew_session/1`で一度セッションをクリアしています。
次に、`put_token_in_session/1`でセッションに`:account_token`で生成したトークン、`:live_socket_id`で生成したトークンをBase64でエンコードした値を保持させています。
次に`maybe_write_remeber_me_cookie/3`でログイン状態を保つかをチェックします。
ログイン状態を保つ場合は、`put_resp_cookie/4`でCookieに値を証明します。
ログイン状態を保たない場合は何もしません。
最後にリダイレクトをします。
リダイレクト先はセッションにログイン後のurlの情報があればそのurlにリダイレクトします。情報がない場合は`/`にリダイレクトします。

## 終わり

今回もここまで読んで頂きありがとうございました。

次回はパスワードリセットの部分について見ていきます。