はじめに
この記事はElixirアドベントカレンダー2025シリーズ2の7日目の記事です。
本記事はPhoenixのビルドイン機能である、認証機能ジェネレーターのphx.gen.authをベースに2FAを実装する方法を解説します
2FA機能を備えたWebアプリ作成する記事ではなく、通常の認証に加えてもう一つの認証要素を簡単に実装ますよという内容です
Authenticatorを入れて連携してーとかだと実装ハードルも使ってもらうハードルも高いですからね2FAは欲しいけど大仰にしたくないという時に最適です
2FAを実装するモチベーションとしては、モバイル上で動いているPhoenixだとメールから開いたログインURLからのリダイレクトでアプリに戻ることが難しいので、アプリ上で完結する2FAの実装が必要になります
2FAとは
2要素認証 (two-factor authentication) の略称で、メールアドレスとパスワードとは別に本人確認情報を使用することによって、パスワードが漏洩しても不正ログインされることを防ぐことができ、セキュリティ強度を上げることができます
要素としては下記のものが挙げられます
- 知識要素(知っているもの)::パスワード、PIN、秘密の質問の答え等
- 所持要素(持っているもの)::スマートフォン、認証アプリで生成されるワンタイムコード、ハードウェアキー等
- 生体要素(本人の特徴)::指紋、顔、虹彩等
今回はURLで認証後ページに飛べないモバイル内のWebViewを想定して
知的要素(登録したメールアドレス)と所持要素(メールボックスに届いたワンタイムパスワード)の組み合わせで実装していきます
Phoenixの認証について
mix phx.gen.authを実行すると
ユーザーの情報を扱う usersテーブル
汎用的なトークンを扱う user_tokensテーブル
が作成されます
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
add :authenticated_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
上記のマイグレーションが実行されて、
- 関連するuser_id
- token(文字列ならなんでもok)
- context(セッション、ログイン、メールアドレス変更、パスワード変更)
- sent_to(送信先メールアドレス)
となっているので
登録したメールアドレスと生成したワンタイムパスワード(確認コード)から紐づいたユーザーを取得することができます
userが取得できれば認証関連のモジュールのUserAuth.log_in_user/2関数でセッショントークンの作成とログイン処理を行うことができます
どうやるかは実装の方で詳しく解説します
プロジェクトの作成
mix phx.new sample_auth
cd sample_auth
mix ecto.create
mix phx.gen.auth Accounts User users
mix deps.get
mix ecto.migrate
2FAのフォーム部分の実装
Phoenix 1.8ではユーザー登録は入力したメールアドレスでユーザーを作成し、magic linkというログイン用のトークン付きURLを発行して、そのURLにアクセスすることによってログインを完了します
機構としては2FAとだいたい一緒なのでこれをベースにしていきます
確認コード入力フォーム表示制御にsentをアサイン
post users/log-inを実行するのは確認コード入力フォームなので、trigger_submitをfalseをアサイン
tenporary_assignsがあるとフォームが切り替わった時に初期化されるので削除
def mount(_params, _session, socket) do
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
- {:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
+ socket
+ |> assign_form(changeset)
+ |> assign(:sent, false)
+ |> assign(:trigger_submit, false)
+ |> then(&{:ok, &1})
end
メール送信をmagic linkから2fa codeに変更
push_navigateを消す
確認コード入力フォーム用にformデータをアサイン
確認コード入力表示フラグを立てる
確認コード入力フォームsubmit時のイベントを作成
@impl true
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
- {:ok, _} =
- Accounts.deliver_login_instructions(
- user,
- &url(~p"/users/log-in/#{&1}")
- )
+ {:ok, _} = Accounts.deliver_user_2fa_instructions(user)
{:noreply,
socket
|> put_flash(
:info,
- "An email was sent to #{user.email}, please access it to confirm your account."
+ "An email was sent to #{user.email}, please put 2fa code."
)
- |> push_navigate(to: ~p"/users/log-in")}
+ |> assign(:form, to_form(%{"email" => user.email, "code" => ""}, as: "user"))
+ |> assign(:sent, true)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
+ def handle_event("submit_code", _params, socket) do
+ {:noreply, assign(socket, :trigger_submit, true)}
+ end
end
確認コード入力フォームは以下のようになります、submitを押したらtrigger_actionがtrueになって/users/log-inにpostされます
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
</div>
+ <%= if !@sent do %>
<.form for={@form} id="registration_form" phx-submit="save" phx-change="validate">
<.input
field={@form[:email]}
type="email"
label="Email"
autocomplete="username"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
+ <% else %>
+ <.form
+ :let={f}
+ for={@form}
+ id="confirm_code"
+ action={~p"/users/log-in"}
+ phx-submit="submit_code"
+ phx-trigger-action={@trigger_submit}
+ >
+ <input name="user[email]" value={f[:email].value} type="hidden" />
+ <.input field={f[:code]} type="text" maxlength="6" />
+ <button type="submit" class="btn btn-primary">
+ ログイン
+ </button>
+ </.form>
+ <% end %>
</div>
</Layouts.app>
"""
end
メール送信部分の実装
deliver_login_instructions/2 を参考に作っていきます
build_2fa_tokenでcontextが2fa、tokenが6桁の数字のトークンを作成します
その後はdeliver_user_2fa_instructionsでユーザーに送信します
def deliver_user_2fa_instructions(%User{} = user) do
{encoded_token, user_token} = UserToken.build_2fa_token(user, user.email)
Repo.insert!(user_token)
UserNotifier.deliver_user_2fa_instructions(user, encoded_token)
end
tokenと2faからuserを取得できるようにトークンを作成します
def build_2fa_token(user, sent_to) do
token = generate_code() |> :erlang.iolist_to_binary()
dt = user.authenticated_at || DateTime.utc_now(:second)
{token,
%UserToken{
token: token,
context: "2fa",
user_id: user.id,
authenticated_at: dt,
sent_to: sent_to
}}
end
defp generate_code do
Enum.random(0..999_999)
|> Integer.to_string()
|> String.pad_leading(6, "0")
end
メールに関しては deliver_update_email_instructionsをコピーしてそれっぽく直します
def deliver_user_2fa_instructions(user, code) do
deliver(user.email, "user 2fa instructions", """
==============================
Hi #{user.email},
your 2fa code is below
#{code}
If you didn't request this email, please ignore this.
==============================
""")
end
ログイン部分の実装
Accounts.get_user_by_2fa_token(code, email)でユーザーを取得してログインを行います
# 2fa login
defp create(conn, %{"user" => %{"code" => code, "email" => email} = user_params}, info) do
if {user, _token} = Accounts.get_user_by_2fa_token(code, email) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
conn
|> put_flash(:error, "確認コードまたはメールアドレスが無効です")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/users/log-in")
end
end
def get_user_by_2fa_token(token, email) do
{:ok, query} = UserToken.verify_2fa_token_query(token, email)
Repo.one(query)
end
コンテキストが2faで確認コードと同一のトークンをもつ user_tokenにuserをjoinして
有効期限内であるか、sent_toのメールが合っているかをチェックして該当するトークンとユーザーを返すクエリを実行します
def verify_2fa_token_query(token, sent_to) do
query =
from token in by_token_and_context_query(token, "2fa"),
join: user in assoc(token, :user),
where:
token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute") and
token.sent_to == ^sent_to,
select: {user, token}
{:ok, query}
end
以上で実装完了になります
動作確認
こんな感じになります
最後に
Phoenixの認証機能はシンプルで全体のコードもそこまで多くないので割と色々手を加えやすいのが結構好きですね
みなさんもパスワードレスな認証マジックリンク並びに2FAをぜひ使ってみてください!
