Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編

# はじめに

本記事は [Qiita AdventCalendar2022 Elixir](https://qiita.com/advent-calendar/2022/elixir) vol4 14日目の記事です

セットアップ編でPhoenixプロジェクトを新たに作成し、それをスマホアプリ化してトップページを表示できるところまでやりました
今回はphx.gen.authを使って認証機能を追加していこうと思います

ElixirDesktopでスマホアプリ作成シリーズ
1. [Phoenix1.7とElixirDesktopでスマホアプリ開発 セットアップ編](https://qiita.com/the_haigo/items/fa3906e45cf805ca38fa)
2. [Phoenix1.7とElixirDesktopでスマホアプリ開発 認証機能編](https://qiita.com/items/c831ff92b06b207d0dbf)
3. [Phoenix1.7とElixirDesktopでスマホアプリ開発 CRUD編](https://qiita.com/the_haigo/items/7472274168939bccbe91)
4. [Phoenix1.7とElixirDesktopでスマホアプリ開発 GPSと地図アプリ編](https://qiita.com/the_haigo/items/7d0e3e24ef215fba13c1)

# 認証機能の追加
phx.gen.authを実行します

```
mix phx.gen.auth Accounts User users
```

`bcrypt_elixir`がnifの関係で使えないのでElixir Onlryの`pbkdf2_elixir`に変更します


```diff_elixir:mix.exs
defp deps do
    [
-      {:bcrypt_elixir, "~> 3.0"},
+      {:pbkdf2_elixir, "~> 2.0"},
    ]
  end
```
いつものコマンドを打ちます
```
mix deps.get
mix ecto.migrate
```

# routing
ログインしていない場合はログインページ
している場合は設定ページに飛ばすようにする

```phoenix/lib/spotties_web/controllers/page_controller.ex
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を開くように変更

```diff_elixir:phoenix/lib/spotties_web/router.ex
defmodule SpottiesWeb.Router do
  ...
  scope "/", SpottiesWeb do
    pipe_through(:browser)
-    get("/", PageController, :home)
+    get("/", PageController, :index)
  end
  ...
end
```

次の記事で別途ナビゲーションは作成するのでheaderからリンクを削除

```diff_elixir:phoenix/lib/spotties_web/components/layouts/root.html.heex
<!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 In のページが開きます
![スクリーンショット 2022-12-08 16.16.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/db2a9fa5-fd8e-aaa4-2bc6-c4603813c690.png)


sign upのページで実際に登録してみましょう

![a99ad0b7816d8c8c328fe5196ec2c911.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/9feebab1-de1c-51f2-f4cf-25d0901c27ee.gif)

1.7からphx.gen.authで生成されるページがLiveViewになったので、リアルタイムにバリデーションが行われたりといい感じになっています

SignUpが完了すると `/` にリダイレクトされるので、上記で設定したとおりにsettingsページに飛ばされます

# おまけ：セッション情報の維持
このままでも十分ですが、セッション管理をETS(インメモリDB)で行っている関係上アプリを再起動するとセッション情報が消えるので、アプリを起動する度にログインを要求されます

これはスマホアプリ的に良くないUXなので、セッション情報を維持できるように細工をします

## ログイン成功時にファイルにトークンを書き込む
ログイン成功時に実行される関数のmaybe_write_remember_me_cookieを関数パターンマッチでElixirDesktopで実行されている時は別の関数を実行するように細工をします

セットアップ編で指定したconfig_dir配下にトークンを書き込んだファイルを作成します

```phoenix/lib/spotties_web/user_auth.ex
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で環境変数に設定するようにします

```phoenix/config/config.exs
import Config
config :spotties, target: config_target()
...
```

## アプリ起動時にトークンを読み込んで、ログインする
トークンを保存するようになったら、起動時に読み込みを行います
アプリを起動して /settingにアクセスしようとすると
以下の流れでログイン中かをチェックされます

```phoenix/lib/spotties_web/router.ex
defmodule SpottiesWeb.Router do
  use SpottiesWeb, :router

  # 2 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
```

```phoenix/lib/spotties_web/user_auth.ex
# 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
```

1. pipe_through: ページを開く前に以下の処理を行う 
2. :browser: いろいろやる
3. :fetch_current_user(:browserの最後): 現在のログイン中のユーザーを取得
4. UserAuth.fetch_current_user: connからトークンを取得しユーザーデータを取得
5. UserAuth.ensure_token: connからトークンを取得
6. - else側(sessionにトークンなかった場合): cookieから取得
7. - cookieにあったらput_token_in_sessionでsessionにトークンを突っ込む

ElixirDesktopではcookieは使わないので、ここの6の処理をMIX_TARGETによって分けて、ファイルからトークンを読み込みましょう

最終的にこうなります
元の処理をrecover_session関数に切り出して、関数パターンマッチで実行する関数を切り替えます
ElixirDesktop側はファイルからトークンを読み込めれば、そのトークンでセッションを新しく貼ります
ない場合は {nil, conn}を返すのはcookieの方とと同じです

```phoenix/lib/spotties_web/user_auth.ex
  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
```

これで起動時にセッションを復活させることができました

## ログアウト時にトークンを削除する
最後にログアウトした際にトークンファイルを削除し、ログアウトしたはずなのにログインした状態になることを防ぎます

```phoenix/lib/spotties_web/user_auth.ex
  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
```

以上で完了になります

## 動作確認

![eac75578f5402d917dea66dfa9aef753.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/f3931035-ec7d-cb43-8eed-836133fcddd6.gif)


無事セッションの維持、破棄ができるようになりました


# 最後に
ElixirDesktopのアプリにphx.gen.authで認証機能を追加できました
次はナビゲーション周りとphx.gen.liveで　CRUD機能を追加していこうと思います
本記事は以上になりますありがとうございました