はじめに
この記事ではPhoenixのバージョン1.7.0
を使用しています。
$ mix phx.new -v
Phoenix installer v1.7.0
またこの記事ではアカウントの登録から認証までを見ていきます。
以下はこのシリーズの目次です。
- PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【登録〜認証編】①
- PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【認証メール再送信編】②
- PhoenixLiveViewを使用したmix phx.gen_authを詳しく見てみた【ログイン編】③
目次
プロジェクトの作成
まず、プロジェクトの作成をします。
$ 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
アカウント登録に該当するソースコードは以下です。
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
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
changeset = Accounts.change_account_registration(%Account{})
上記はAccounts.Account
のチェンジセットを作成する関数です。
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
の部分を見ていきます。
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"
を確認します。
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"
を確認します。
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
Accounts.register_account(account_params)
上記はフォームで入力された値を検証(チェンジセットを作成)しaccounts
テーブルにデータを挿入している関数です。
データの挿入に失敗した際はチェンジセットを受け取り、フォームにエラーを表示させます。
挿入に成功した場合は次にメール送信のステップに移行します。
Accounts.deliver_account_confirmation_instructions(account, &url(~p"/accounts/confirm/#{&1}"))
上記の関数はトークンを生成し、メールを送信する関数です。中の処理を確認します。
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}が返され、メールは送信されません。
{encoded_token, account_token} = AccountToken.build_email_token(account, "confirm")
2要素認証が済んでいない場合はまずアカウント登録用のトークンを生成するためのクエリとエンコードされたトークンを作成します。
こちらの中も確認します。
@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
構造体にはそれぞれ値を割り当てます。
:token
はsha256
でハッシュ化したトークン
:context
は引数で渡された"confirm"
:sent_to
はアカウントのメールアドレス
:account_id
はアカウントのid
エンコードされた値とAccountToken
構造体をタプルの形にして返却しています。
その次に
Repo.insert!(account_token)
AccountNotifier.deliver_confirmation_instructions(account, confirmation_url_fun.(encoded_token))
受け取ったAccountToken
構造体をaccount_token
テーブルに挿入します。
その次にメール送信を行います。
その前にAccountNotifier.deliver_confirmation_instructions/2
関数の第2引数は無名関数で作成したurlです。
処理をわかりやすくするしたものは以下です。
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に戻って次の処理を確認しましょう。
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_submit
がtrue
の時は、phx-submit
で指定したアクションを呼ばずにLiveViewを切断してaction
属性で指定したURLへフォームを送信します。trigger_submit
がfalse
の時はphx-submit
で指定したアクションを呼びます。
では実際にどのように使用するかはもう一度"save"
アクションの関数を確認します。
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_submit
をtrue
にしています。挿入に問題があった場合はtrigger_submit
はfalse
のままでsocketを返しています。
このようにtrigger_submit
はLiveViewフォームの送信前の検証を実行する際に使います。
LiveViewはsocket通信を使用するのでセッションに新しく追加するなどの操作ができないので、フォームをコントローラーのアクションに渡してセッションを追加するためにtrigger_submit
を使用しているんだと思います。
trigger_submit
がtrue
になった状態でフォームの送信を受け取るコントローラーのアクションは以下です。
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)
の関数を見ていきましょう。
@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
にリダイレクトします。
ではフォームにメールアドレスとパスワードを入力してフォームを送信します。
送信して正常にアカウントが作成されるとホームにリダイレクトします。
2要素認証
続いて2要素認証について確認します。
まず以下のurlにアクセスして先程送信されたメールを確認します。
http://localhost:4000/dev/mailbox
アクセスして一番上にあるメールを開くと2要素認証のurlが確認できます。
ここに記載されているhttp://localhost:4000/accounts/confirm/xxxxxxxx
にアクセスします。
2要素認証のソースコードは以下です。
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
から確認します。
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
を確認します。
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"
のインベントを確認します。
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を使用してアカウントを取得します。
Accounts.confirm_account(token)
Accounts.confirm_account/1
関数を確認します。
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"
を次の関数に渡しています。
AccountToken.verify_email_token_query(token, "confirm")
AccountToken.verify_email_token_query/2
を確認します。
@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をデコードします。
Base.url_decode64(token, padding: false)
続いてデコードしたトークンをハッシュ化します。
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
days変数に整数の7を束縛しています。
days = days_for_context(context)
クエリではトークンが挿入された日が7日以内で、:sent_to
のメールアドレスとアカウントのメールアドレスが一致していればアカウントを返すものです。
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
関数に戻ります。
%Account{} = account <- Repo.one(query),
受け取ったクエリでテーブルからアカウントを取得します。
{: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
に現在時刻を割り当てチェンジセットを作成し、更新をします。
def confirm_changeset(account) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
change(account, confirmed_at: now)
end
また、トランザクション内でcontext
の値が"confirm"
でアカウントのidが一致しているAccountToken
のクエリを発行して該当する値をすべて削除します。
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
に戻り次を確認します。
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/
)にリダイレクトします。
処理が正常に終了しなかった場合は次のどちらかが処理されます。
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要素認証をします。
終わり
mix phx.gen.auth
で生成されるコードはPhoenix1.7.0から大幅に変更され、LiveViewでも扱えるようになりました。
LiveViewではセッションに新しく値を登録できないので一度LiveViewを切断してからセッションに値を入れるところに自分も納得したなと感じでした。(今ままでLiveViewでやるにはどうすればいいのかな?と考えていましたがw)
読みづらい文章でしたが、ここまで読んで頂きありがとうございました。
次回は認証メール再送信の部分について見ていきます。