11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2023

Day 14

ElixirDesktop クラサバ構成 クライアントアプリ構築

Last updated at Posted at 2023-12-15

はじめに

この記事は Elixirアドベントカレンダーのシリーズ4の14日目の記事です

PhoenixのAPIサーバーとElixirDesktopをクライアントとしたクラサバ構成の構築と認証について解説します
今回はクライアントアプリの作成と認証情報の保存について解説します

プロジェクトの作成

最初にサーバーと同じBlogでアプリを作りたいので、前回のサーバーのフォルダ名を変更します

mv blog blog_server

次にクライアントアプリを作成します。DBは必要ないのですが、色々使いまわしたいので --database sqlite3を指定します

mix phx.new blog --database sqlite3
cd blog

ElixirDesktopアプリ化

こちらに沿って行っていきます

以下3つのライブラリを追加します

mix.exs
  defp deps do
    [
      ...      
      {:desktop, "~> 1.5"},
      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]},
      {:req, "~> 0.4.0"}
    ]
  end
mix deps.get

configのRepoとEndpointを書き換えて、runtime.exsをruntime_disable.exsにリネームします

config/config.exs
- config :blog,
-  ecto_repos: [Blog.Repo],
-  generators: [timestamp_type: :utc_datetime]


config :blog, BlogWeb.Endpoint,
- url: [host: "localhost"],
+ http: [ip: {127, 0, 0, 1}, port: 10_000 + :rand.uniform(45_000)],
  adapter: Phoenix.Endpoint.Cowboy2Adapter,
  render_errors: [
    formats: [html: BlogWeb.ErrorHTML, json: BlogWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: Blog.PubSub,
- live_view: [signing_salt: "ryE8f7GX"]
+ live_view: [signing_salt: "ryE8f7GX"],
+ secret_key_base: :crypto.strong_rand_bytes(32),
+ server: true

Repoの設定を消し、デスクトップアプリモードで起動してblog_serverとポートがかぶらないように4001に変更します

config/dev.exs
import Config

# Configure your database
- config :blog, Blog.Repo,
-  database: Path.expand("../blog_dev.db", Path.dirname(__ENV__.file)),
-  pool_size: 5,
-  stacktrace: true,
-  show_sensitive_data_on_connection_error: true

config :blog, BlogWeb.Endpoint,
- http: [ip: {127, 0, 0, 1}, port: 4000],
+ http: [ip: {127, 0, 0, 1}, port: 4001],
...

endpoint修正

lib/blog_web/endpoint.ex
defmodule BlogWeb.Endpoint do
- use Phoenix.Endpoint, otp_app: :blog
+ use Desktop.Endpoint, otp_app: :blog

  @session_options [
-   store: :cookie,
+   store: :ets,
    key: "_blog_key",
+   table: :session,
-   signing_salt: "aTwtJaw/",
-   same_site: "Lax"
  ]

起動時の設定を行います
前回との変更点としてDBは使わないのでRepoは起動しません

lib/blog.ex
defmodule Blog do
  use Application

  def config_dir() do
    Path.join([Desktop.OS.home(), ".config", "blog"])
  end

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    # configフォルダを掘る
    File.mkdir_p!(config_dir())

    # session用のETSを起動
    :session = :ets.new(:session, [:named_table, :public, read_concurrency: true])

    children = [
      {Phoenix.PubSub, name: Blog.PubSub},
      BlogWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: Blog.Supervisor]
    # メインのsuperviser起動
    {:ok, sup} = Supervisor.start_link(children, opts)


    # phoenixサーバーが起動中のポート番号を取得
    port = :ranch.get_port(BlogWeb.Endpoint.HTTP)
    
    # メインのsuperviserの配下にElixirDesktopのsuperviserを追加
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: BlogWindow,
          title: "Blog",
          size: {400, 800},
          url: "http://localhost:#{port}"
        ]
      })
  end

  def config_change(changed, _new, removed) do
    BlogWeb.Endpoint.config_change(changed, removed)
    :ok
  end
end

アプリケーションの設定ファイルを差し替えます

mix.exs
def application do
    [
-      mod: {Blog.Application, []},
+      mod: {Blog, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end

認証機能のベースコード生成

ベースとなるコードを以下のコマンドで生成します

mix phx.gen.auth Accounts User users

Bcrypt関連を消す

bcrypt_elixirですがiOS、Androidでこれが入っていると起動しないので消します

mix.exs
  defp deps do
    [
-     {:bcrypt_elixir, "~> 3.0"},

テストからも消す

config/text.exs
import Config

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

Bcrypt周りのコードをごっそり消します

lib/blog/accounts/user.ex
defmodule Blog.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  ... 

  defp validate_email(changeset, opts) do
    changeset
    |> validate_required([:email])
    |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
    |> validate_length(:email, max: 160)
-   |> maybe_validate_unique_email(opts)
  end

  defp validate_password(changeset, opts) do
    changeset
    |> validate_required([:password])
    |> validate_length(:password, min: 12, max: 72)
    # Examples of additional password validation:
    # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
    # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
    # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
-   |> maybe_hash_password(opts)
  end

- 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
-     |> 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))
-     |> delete_change(:password)
-   else
-     changeset
-   end
- end

- defp maybe_validate_unique_email(changeset, opts) do
-   if Keyword.get(opts, :validate_email, true) do
-     changeset
-     |> unsafe_validate_unique(:email, Blog.Repo)
-     |> unique_constraint(:email)
-   else
-     changeset
-   end
- end

...

- @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.
- """
- def valid_password?(%Blog.Accounts.User{hashed_password: hashed_password}, password)
-     when is_binary(hashed_password) and byte_size(password) > 0 do
-   Bcrypt.verify_pass(password, hashed_password)
- end
-
- def valid_password?(_, _) do
-   Bcrypt.no_user_verify()
-   false
- end
-
- @doc """
- Validates the current password otherwise adds an error to the changeset.
- """
- def validate_current_password(changeset, password) do
-   if valid_password?(changeset.data, password) do
-     changeset
-   else
-     add_error(changeset, :current_password, "is not valid")
-   end
- end
end

認証トークンの保持

クライアントアプリですが、ユーザーの作成、ログイン時にbase64エンコードしたトークンをAPIサーバーから取得します
その際に config_dirにテキストファイルとしてトークンを生成します
こちらを通信時のヘッダーに付与したりします

トークンファイルのパスを取得するヘルパーを実装します

lib/blog.ex
defmodule Blog do
  use Application

  def config_dir() do
    Path.join([Desktop.OS.home(), ".config", "blog"])
  end

+ def token_path() do
+   Path.join([config_dir(), "token"])
+ end
  ...
end

API通信クライアントを実装

12日の記事でも使用したAPI通信ライブラリのReqを使用します

トークンがあった場合はauthorizationヘッダーにトーケン付与します、ない場合は通信は行えるが401が帰ってくるようにクライアントだけは返します

lib/blog/req.ex
defmodule Blog.Req do
  def client() do
    case File.read(Blog.token_path()) do
      {:ok, token} ->
        Req.new(base_url: "http://localhost:4000/api")
        |> Req.Request.put_header("authorization", "Bearer #{token}")

      {:error, _} ->
        Req.new(base_url: "http://localhost:4000/api")
    end
  end
end

API通信部分を実装

Ectoでクエリを実行している箇所をAPI通信を行う処理に書き換えます

ReqでAPI通信を行い、statusで可否を判断して処理を分けます
ログイン、登録に成功したらトークンファイルを書き出します

lib/blog/accounts.ex
defmodule Blog.Accounts do
  @moduledoc """
  The Accounts context.
  """

  import Ecto.Query, warn: false
+ alias Ecto.Changeset
  alias Blog.Repo

  alias Blog.Accounts.{User, UserToken, UserNotifier}

  def get_user_by_email_and_password(email, password)
      when is_binary(email) and is_binary(password) do
    attr = %{email: email, password: password}

    {:ok, resp} =
      Req.post(Blog.Req.client(), url: "/login", json: attr)

    case resp.status do
      200 ->
        token = resp.body["token"]
        File.write!(Blog.token_path(), token)
        {:ok, token}

      422 ->
        %User{}
        |> User.registration_changeset(attr)
        |> then(
          &Enum.reduce(resp.body["errors"], &1, fn {k, v}, acc ->
            Changeset.add_error(acc, String.to_atom(k), List.first(v))
          end)
        )
        |> then(&{:error, &1})

      _ ->
        {:error, User.registration_changeset(%User{}, attr)}
    end
  end

  @doc """
  Gets the user with the given signed token.
  """
  def get_user_by_session_token() do
    {:ok, resp} = Req.get(Blog.Req.client(), url: "/status")

    case resp.status do
      200 ->
        %User{id: resp.body["id"], email: resp.body["email"]}

      _ ->
        nil
    end
  end

  def register_user(attrs) do
    {:ok, resp} = Req.post(Blog.Req.client(), url: "/register", json: attrs)

    case resp.status do
      200 ->
        token = resp.body["token"]
        File.write!(Blog.token_path(), token)
        {:ok, %User{email: attrs["email"], password: attrs["password"]}}

      422 ->
        %User{}
        |> User.registration_changeset(attrs)
        |> then(
          &Enum.reduce(resp.body["errors"], &1, fn {k, v}, acc ->
            Changeset.add_error(acc, String.to_atom(k), List.first(v))
          end)
        )
        |> then(&{:error, &1})

      _ ->
        {:error, User.registration_changeset(%User{}, attrs)}
    end
  end
  ...
end

認証周りの調整

認証状態をチェックする関数は主にセッションの情報を使うので、トークンファイルの有無を使うようにします
またクッキー周りも使わないので削除します

lib/blog_web/user_auth.ex
defmodule BlogWeb.UserAuth do
  use BlogWeb, :verified_routes
  
  ...

- def log_in_user(conn, user, params \\ %{}) do
-   token = Accounts.generate_user_session_token(user)
+ def log_in_user(conn, _user, _params \\ %{}) do
+   {:ok, token} = File.read(Blog.token_path())   
    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, %{"remember_me" => "true"}) do
-   put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
- end
-
- defp maybe_write_remember_me_cookie(conn, _token, _params) do
-   conn
- end

...

  @doc """
  Logs the user out.

  It clears all session data for safety. See renew_session.
  """
  def log_out_user(conn) do
-   user_token = get_session(conn, :user_token)
-   user_token && Accounts.delete_user_session_token(user_token)
+   File.rm!(Blog.token_path())

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

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

  @doc """
  Authenticates the user by looking into the session
  and remember me token.
  """
  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)
+   user = user_token && Accounts.get_user_by_session_token()
    assign(conn, :current_user, user)
  end

  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
+   case File.read(Blog.token_path()) do
+     {:ok, token} -> {token, conn}
+     {:error, _} -> {nil, conn}
    end

  end

...

-  defp mount_current_user(socket, session) do
+  defp mount_current_user(socket, _session) do
    Phoenix.Component.assign_new(socket, :current_user, fn ->
-     if user_token = session["user_token"] do
-      Accounts.get_user_by_session_token(user_token)
-     end
+     Accounts.get_user_by_session_token()
    end)
  end

  ...
- defp signed_in_path(_conn), do: ~p"/"
+ defp signed_in_path(_conn), do: ~p"/users/settings" 
end

ユーザー登録の修正

ユーザー登録のメール送信部分の削除と、ログイン失敗時のAPIのレスポンスからエラーメッセージの表示を行えるようにします

lib/blog_web/live/user_registration_live.ex
defmodule BlogWeb.UserRegistrationLive do
  use BlogWeb, :live_view

  alias Blog.Accounts
  alias Blog.Accounts.User


  def handle_event("save", %{"user" => user_params}, socket) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
-       {:ok, _} =
-         Accounts.deliver_user_confirmation_instructions(
-           user,
-           &url(~p"/users/confirm/#{&1}")
-         )
-
        changeset = Accounts.change_user_registration(user)
        {:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}

      {:error, %Ecto.Changeset{} = changeset} ->
-       {:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
+       {:noreply,
+          socket
+          |> assign(check_errors: true)
+          |> assign_form(Map.put(changeset, :action, :validate))}
    end
  end
  ...
end

ログイン、登録後にredirect_if_user_is_authenticatedで落とされるので、ログインチェックを行わないscopeに移動

lib/blog_web/router.ex
  scope "/", BlogWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    live_session :redirect_if_user_is_authenticated,
      on_mount: [{BlogWeb.UserAuth, :redirect_if_user_is_authenticated}] do
      live "/users/register", UserRegistrationLive, :new
      live "/users/log_in", UserLoginLive, :new
      live "/users/reset_password", UserForgotPasswordLive, :new
      live "/users/reset_password/:token", UserResetPasswordLive, :edit
    end
-
-   post "/users/log_in", UserSessionController, :create    
  end

  scope "/", BlogWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{BlogWeb.UserAuth, :ensure_authenticated}] do
      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
    end
  end

  scope "/", BlogWeb do
    pipe_through [:browser]

    delete "/users/log_out", UserSessionController, :delete
+   post "/users/log_in", UserSessionController, :create

    live_session :current_user,
      on_mount: [{BlogWeb.UserAuth, :mount_current_user}] do
      live "/users/confirm/:token", UserConfirmationLive, :edit
      live "/users/confirm", UserConfirmationInstructionsLive, :new
    end
  end

動作確認

新規作成、重複チェック、ログアウト、ログインが問題なくできます
動画には写してないですが、再起動後ログイン後画面が表示されます

a408ebc3a554817ad591f5dab405357a.gif

最後に

phx.gen.authやphx.gen.liveでベースファイルを作って、 APIに合わせて変更することで楽にクライアントアプリを作ることができました
画面を生成して、contextをAPI通信に書き換えるだけでクライアント側でバリデーションも行えます
認証周りが使いまわしができますが、どこの関数が実行されたのかわかりにくいので慣れが必要かもしれません

次はスマホアプリとしてシミュレーターで起動した時にローカルホストのサーバーとの通信について解説します

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

11
1
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
11
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?