3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Phoenix1.8の既存の認証機能に乗せる2FA実装

Last updated at Posted at 2025-12-01

はじめに

この記事は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があるとフォームが切り替わった時に初期化されるので削除

lib/sample_auth_web/live/user_live/registration.ex
  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時のイベントを作成

lib/sample_auth_web/live/user_live/registration.ex
  @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されます

lib/sample_auth_web/live/user_live/registration.ex
  @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でユーザーに送信します

lib/sample_auth/accounts.ex
  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を取得できるようにトークンを作成します

lib/sample_auth/accounts/user_token.ex
  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をコピーしてそれっぽく直します

lib/sample_auth/accounts/user_notifier.ex
  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)でユーザーを取得してログインを行います

lib/sample_auth_web/controllers/user_session_controller.ex
  # 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
lib/sample_auth/accounts.ex
  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のメールが合っているかをチェックして該当するトークンとユーザーを返すクエリを実行します

lib/sample_auth/accounts/user_token.ex
  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

以上で実装完了になります

動作確認

こんな感じになります

47a39cebaf685a33a6b95eeeb48f0a55.gif

最後に

Phoenixの認証機能はシンプルで全体のコードもそこまで多くないので割と色々手を加えやすいのが結構好きですね
みなさんもパスワードレスな認証マジックリンク並びに2FAをぜひ使ってみてください!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?