はじめに
本記事は Qiita AdventCalendar2022 Elixir vol4 14日目の記事です
セットアップ編でPhoenixプロジェクトを新たに作成し、それをスマホアプリ化してトップページを表示できるところまでやりました
今回はphx.gen.authを使って認証機能を追加していこうと思います
ElixirDesktopでスマホアプリ作成シリーズ
- Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編
- Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編
認証機能の追加
phx.gen.authを実行します
mix phx.gen.auth Accounts User users
bcrypt_elixirがnifの関係で使えないのでElixir Onlryのpbkdf2_elixirに変更します
defp deps do
    [
-      {:bcrypt_elixir, "~> 3.0"},
+      {:pbkdf2_elixir, "~> 2.0"},
    ]
  end
いつものコマンドを打ちます
mix deps.get
mix ecto.migrate
routing
ログインしていない場合はログインページ
している場合は設定ページに飛ばすようにする
defmodule SpottiesWeb.PageController do
  use SpottiesWeb, :controller
  alias Spotties.Accounts.User # 追加
  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end
  # 以下追加
  def index(conn, _params) do
    redirect_to(conn, conn.assigns.current_user)
  end
  def redirect_to(conn, %User{}) do
    redirect(conn, to: ~p"/users/settings")
  end
  def redirect_to(conn, nil) do
    redirect(conn, to: ~p"/users/log_in")
  end
end
アプリを開いた時に indexを開くように変更
defmodule SpottiesWeb.Router do
  ...
  scope "/", SpottiesWeb do
    pipe_through(:browser)
-    get("/", PageController, :home)
+    get("/", PageController, :index)
  end
  ...
end
次の記事で別途ナビゲーションは作成するのでheaderからリンクを削除
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" · Phoenix Framework">
      <%= assigns[:page_title] || "Spotties" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>
  <body class="bg-white antialiased">
-    <ul>
-      <%= if @current_user do %>
-        <li>
-          <%= @current_user.email %>
-        </li>
-        <li>
-          <.link href={~p"/users/settings"}>Settings</.link>
-        </li>
-        <li>
-          <.link href={~p"/users/log_out"} method="delete">Log out</.link>
-        </li>
-      <% else %>
-        <li>
-          <.link href={~p"/users/register"}>Register</.link>
-        </li>
-        <li>
-          <.link href={~p"/users/log_in"}>Log in</.link>
-        </li>
-      <% end %>
-    </ul>
    <%= @inner_content %>
  </body>
</html>
sign upのページで実際に登録してみましょう
1.7からphx.gen.authで生成されるページがLiveViewになったので、リアルタイムにバリデーションが行われたりといい感じになっています
SignUpが完了すると / にリダイレクトされるので、上記で設定したとおりにsettingsページに飛ばされます
おまけ:セッション情報の維持
このままでも十分ですが、セッション管理をETS(インメモリDB)で行っている関係上アプリを再起動するとセッション情報が消えるので、アプリを起動する度にログインを要求されます
これはスマホアプリ的に良くないUXなので、セッション情報を維持できるように細工をします
ログイン成功時にファイルにトークンを書き込む
ログイン成功時に実行される関数のmaybe_write_remember_me_cookieを関数パターンマッチでElixirDesktopで実行されている時は別の関数を実行するように細工をします
セットアップ編で指定したconfig_dir配下にトークンを書き込んだファイルを作成します
defmodule SpottiesWeb.UserAuth do
  ...
  def log_in_user(conn, user, params \\ %{}) do
    token = Accounts.generate_user_session_token(user)
    # ↓追加
    params = Map.put(params, "target", Application.fetch_env!(:spotties, :target)) 
    user_return_to = get_session(conn, :user_return_to)
    conn
    |> renew_session()
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end
  # 以下追加
  defp maybe_write_remember_me_cookie(conn, token, %{"target" => target}) when target != :web do
    File.write!(Spotties.config_dir() <> "/token", Base.encode64(token))
    conn
  end
  ...
end
これでElixirDesktopの時(MIX_TARGETが:host, :android, :ios)の場合にログインが成功したらトークンが各OSのconfigフォルダのファイルに書き込まれます
MIX_TARGETの取得する際にApplication.fetch_env/2を使用しているのは、Config.config_target/0がconfigファイル以外で使うとprodでビルドしているiOSとAndroidではエラーになるので
config.exで環境変数に設定するようにします
import Config
config :spotties, target: config_target()
...
アプリ起動時にトークンを読み込んで、ログインする
トークンを保存するようになったら、起動時に読み込みを行います
アプリを起動して /settingにアクセスしようとすると
以下の流れでログイン中かをチェックされます
defmodule SpottiesWeb.Router do
  use SpottiesWeb, :router
  # 2 1の1つめの処理browser、レイアウトやセキュリティ周りの処理をまとめて行う
  pipeline :browser do
    ...
    plug(:fetch_current_user) # 3 phx.gen.authで追加された
  end
  ...
  scope "/", SpottiesWeb do
    # 1 /users/settings アクセス前に行う処理がまとめられている
    pipe_through([:browser, :require_authenticated_user]) 
    live_session :require_authenticated_user,
      on_mount: [{SpottiesWeb.UserAuth, :ensure_authenticated}] do
      live("/users/settings", UserSettingsLive, :edit)
      live("/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email)
    end
  end
end
# 4 connからトークンを取得しユーザーデータを取得する処理
def fetch_current_user(conn, _opts) do
  {user_token, conn} = ensure_user_token(conn)
  user = user_token && Accounts.get_user_by_session_token(user_token)
  assign(conn, :current_user, user)
end
# 5 トークンを取得する処理、セッションにあるか、クッキーにあるか,ないならnilを返す
defp ensure_user_token(conn) do
  if token = get_session(conn, :user_token) do
    {token, conn}
  else
    conn = fetch_cookies(conn, signed: [@remember_me_cookie])
    
    if token = conn.cookies[@remember_me_cookie] do
      {token, put_token_in_session(conn, token)}
    else
      {nil, conn}
    end  
  end
end
- pipe_through: ページを開く前に以下の処理を行う
- :browser: いろいろやる
- :fetch_current_user(:browserの最後): 現在のログイン中のユーザーを取得
- UserAuth.fetch_current_user: connからトークンを取得しユーザーデータを取得
- UserAuth.ensure_token: connからトークンを取得
- 
- else側(sessionにトークンなかった場合): cookieから取得
 
- 
- cookieにあったらput_token_in_sessionでsessionにトークンを突っ込む
 
ElixirDesktopではcookieは使わないので、ここの6の処理をMIX_TARGETによって分けて、ファイルからトークンを読み込みましょう
最終的にこうなります
元の処理をrecover_session関数に切り出して、関数パターンマッチで実行する関数を切り替えます
ElixirDesktop側はファイルからトークンを読み込めれば、そのトークンでセッションを新しく貼ります
ない場合は {nil, conn}を返すのはcookieの方とと同じです
  defp ensure_user_token(conn) do
    if token = get_session(conn, :user_token) do
      {token, conn}
    else
      # recover_sessionに切り出し
      recover_session(conn, Application.fetch_env!(:spotties, :target))
    end
  end
  # 以下追加
  defp recover_session(conn, :web) do
    conn = fetch_cookies(conn, signed: [@remember_me_cookie])
    if token = conn.cookies[@remember_me_cookie] do
      {token, put_token_in_session(conn, token)}
    else
      {nil, conn}
    end
  end
  defp recover_session(conn, _) do
    case File.read(Spotties.config_dir() <> "/token") do
      {:ok, token} ->
        {token, put_token_in_session(conn, Base.decode64!(token))}
      {:error, :enoent} ->
        {nil, conn}
    end
  end
  ...
end
これで起動時にセッションを復活させることができました
ログアウト時にトークンを削除する
最後にログアウトした際にトークンファイルを削除し、ログアウトしたはずなのにログインした状態になることを防ぎます
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)
    File.rm(Spotties.config_dir() <> "/token") # 追加
    if live_socket_id = get_session(conn, :live_socket_id) do
      SpottiesWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end
    conn
    |> renew_session()
    |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: "/")
  end
  ...
end
以上で完了になります
動作確認
無事セッションの維持、破棄ができるようになりました
最後に
ElixirDesktopのアプリにphx.gen.authで認証機能を追加できました
次はナビゲーション周りとphx.gen.liveで CRUD機能を追加していこうと思います
本記事は以上になりますありがとうございました




