はじめに
この記事は Elixirアドベントカレンダーのシリーズ4の14日目の記事です
PhoenixのAPIサーバーとElixirDesktopをクライアントとしたクラサバ構成の構築と認証について解説します
今回はクライアントアプリの作成と認証情報の保存について解説します
プロジェクトの作成
最初にサーバーと同じBlogでアプリを作りたいので、前回のサーバーのフォルダ名を変更します
mv blog blog_server
次にクライアントアプリを作成します。DBは必要ないのですが、色々使いまわしたいので --database sqlite3
を指定します
mix phx.new blog --database sqlite3
cd blog
ElixirDesktopアプリ化
こちらに沿って行っていきます
以下3つのライブラリを追加します
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 :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
に変更します
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修正
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は起動しません
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
アプリケーションの設定ファイルを差し替えます
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でこれが入っていると起動しないので消します
defp deps do
[
- {:bcrypt_elixir, "~> 3.0"},
テストからも消す
import Config
- # Only in tests, remove the complexity from the password hashing algorithm
- config :bcrypt_elixir, :log_rounds, 1
Bcrypt周りのコードをごっそり消します
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にテキストファイルとしてトークンを生成します
こちらを通信時のヘッダーに付与したりします
トークンファイルのパスを取得するヘルパーを実装します
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
が帰ってくるようにクライアントだけは返します
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で可否を判断して処理を分けます
ログイン、登録に成功したらトークンファイルを書き出します
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
認証周りの調整
認証状態をチェックする関数は主にセッションの情報を使うので、トークンファイルの有無を使うようにします
またクッキー周りも使わないので削除します
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のレスポンスからエラーメッセージの表示を行えるようにします
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に移動
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
動作確認
新規作成、重複チェック、ログアウト、ログインが問題なくできます
動画には写してないですが、再起動後ログイン後画面が表示されます
最後に
phx.gen.authやphx.gen.liveでベースファイルを作って、 APIに合わせて変更することで楽にクライアントアプリを作ることができました
画面を生成して、contextをAPI通信に書き換えるだけでクライアント側でバリデーションも行えます
認証周りが使いまわしができますが、どこの関数が実行されたのかわかりにくいので慣れが必要かもしれません
次はスマホアプリとしてシミュレーターで起動した時にローカルホストのサーバーとの通信について解説します
本記事は以上になりますありがとうごうざいました