ElixirDesktop APIレス開発 認証機能とセッション保持

# はじめに
この記事は Elixirアドベントカレンダーのシリーズ４の18日目の記事です


今回はAPIレス開発での認証機能とセッションの保持について解説します


# phx.gen.authでひな形作成

以下のコマンドでデフォルトの認証機能を生成します

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

# ライブラリの差し替え

上記のコマンドで bcrypt_elixirが追加されていますが、こちらはELixirDesktopでは使えないので別のライブラリに差し替えます

```diff_elixir:mix.exs
  defp deps do
    [
-     {:bcrypt_elixir, "~> 3.0"},
+     {:pbkdf2_elixir, "~> 2.0"}
      {:phoenix, "~> 1.7.10"},

```

変更したら以下を実行します

```
mix deps.get
mix ecto.migrate
```

コード内のBcryptを使用している箇所も変更していきます

```diff_elixir:lib/spotter/accounts/user.ex
defmodule Spotter.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  ...
  # L62
  defp maybe_hash_password(changeset, opts) do
    hash_password? = Keyword.get(opts, :hash_password, true)
    password = get_change(changeset, :password)

    if hash_password? && password && changeset.valid? do
      changeset
-     # If using Bcrypt, then further validate it is at most 72 bytes long
+     # If using Pbkdf2, then further validate it is at most 72 bytes long
      |> validate_length(:password, max: 72, count: :bytes)
      # Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
      # would keep the database transaction open longer and hurt performance.
-     |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
+     |> put_change(:hashed_password, Pbkdf2.hash_pwd_salt(password))
      |> delete_change(:password)
    else
      changeset
    end
  end

  ...
  # L131
  @doc """
  Verifies the password.

  If there is no user or the user doesn't have a password, we call
-  `Bcrypt.no_user_verify/0` to avoid timing attacks.
+  `Pbkdf2.no_user_verify/0` to avoid timing attacks.
  """
  def valid_password?(%Spotter.Accounts.User{hashed_password: hashed_password}, password)
      when is_binary(hashed_password) and byte_size(password) > 0 do
-   Bcrypt.verify_pass(password, hashed_password)
+   Pbkdf2.verify_pass(password, hashed_password)
  end

  def valid_password?(_, _) do
-   Bcrypt.no_user_verify()
+   Pbkdf2.no_user_verify()    
    false
  end
  ...
end
```

test.exsにもあるので忘れないようにしましょう

```diff_elixir:config/test.exs
import Config

# Only in tests, remove the complexity from the password hashing algorithm
- config :bcrypt_elixir, :log_rounds, 1
+ config :pbkdf2_elixir, :log_rounds, 1
```

Webアプリも作る場合は、暗号化アルゴリズムが同じである必要があるため両方pbkdf2で統一する必要がありますので気をつけましょう

# ログイン有無でのリダイレクト処理
アプリを起動した時に、ログインしていたら設定ページ、していなかったらログインページにリダイレクトするようにします


```diff_elixir:lib/spotter_web/controllers/page_controller.ex
defmodule SpotterWeb.PageController do
  use SpotterWeb, :controller
+ alias Spotter.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

+ defp redirect_to(conn, %User{}) do
+   redirect(conn, to: ~p"/users/settings")
+ end

+ defp redirect_to(conn, nil) do
+   redirect(conn, to: ~p"/users/log_in")
+ end
end
```

トップページで開くパスを変更します

```diff_elixir:lib/spotter_web/router.ex
  scope "/", SpotterWeb do
    pipe_through :browser

-   get "/", PageController, :home
-   get "/", PageController, :index
  end
```

# セッション保持
ElixirDesktopはセッション情報をetsというインメモリDBで管理するのでアプリを終了するとセッション情報は消えてしまうので起動毎にログインを要求されます

これはUX的に良くないのでセッション情報を保持するようにします

`maybe_write_remember_me_cookie`関数を書き換えてログイン時にトークンをテキストファイルに書き出します
```diff_elixir:lib/spotter_web/user_auth.ex:L39
- defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
-   put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
+  defp maybe_write_remember_me_cookie(conn, token, _params) do
+   File.write!(Spotter.config_dir() <> "/token", Base.encode64(token))
+   conn    
  end

- defp maybe_write_remember_me_cookie(conn, _token, _params) do
-   conn
- end
  
```

# セッション復活
起動時にセッションを復活できるようにします
以下の関数がfetch_current_userで実行されているので、sessionにトークンがなかった場合はクッキーを見に行っているのをトークンファイルを見に行くように変更します

```diff_elixir:lib/spotter_web/user_auth.ex:L98
  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}
+     case File.read(Spotter.config_dir() <> "/token") do
+       {:ok, token} ->
+         {token, put_token_in_session(conn, Base.decode64!(token))}
+ 
+       {:error, :enoent} ->
+         {nil, conn}
      end      
    end
  end
```
これで起動時にセッションを復活できるようになりました

# ログアウト時にトークンを削除する
ログアウトをしてもトークンファイルが残り続けて、起動の度にログインされるので、ログアウト時にトークンファイルを削除するようにします


```diff_elixir:lib/spotter_web/user_auth.ex:L74
  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_user_session_token(user_token)
+   File.rm(Spotter.config_dir() <> "/token")

    if live_socket_id = get_session(conn, :live_socket_id) do
      SpotterWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    conn
    |> renew_session()
-   |> delete_resp_cookie(@remember_me_cookie)
    |> redirect(to: ~p"/")
  end
```

# 動作確認
セッション保持とログアウトで破棄されているのを確認できました

![b91ae56de77a6d353c399826ef6c14be.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/eb946629-8894-fe00-466d-69fe0ff1dead.gif)

# 最後に
本記事ではAPIレスアプリ開発で認証機能とセッション保持を実装しました
端末にデータを保存するときは config_dirの配下に置けば良いので楽ですね
平文で保存しておくのが抵抗がある場合はdetsを使えば良さそうですね

次はナビゲーションとCRUDを実装してきます

本記事は以上になりますありがとうございました