Phoenixで作るGPS Logging System 6 APIの認証2


はじめに

ひとりLiveView Advent Calendar の6日目の記事です

この記事はElixir Conf US 2021発表したシステムの構築と関連技術の解説を目的とした記事です

キーボードで入力が困難なデバイスまたはemail,passwordを保存したくないデバイス(Apple Watch, raspberry pi等)で認証トークン発行するために再発行が容易なパスコードを使用できるようにします。

今回は以下の3つを実装します

  • Userにpasscodeカラム追加
  • passcode発行画面
  • passcode認証

Userにpasscodeカラム追加

カラムを追加する場合は以下のコマンドで空のmigrationファイルを作成します

mix ecto.gen.migration add_passcode_to_users

カラムを追加する場合はalter table(table名) doで囲って追加するカラムを記述します

priv/repo/migrations/20211208151908_add_passcode_to_users.exs
defmodule LiveLogger.Repo.Migrations.AddPasscodeToUsers do
  use Ecto.Migration

  def change do
    alter table("users") do
      add :passcode, :string
    end
  end
end

DBに反映させます

mix ecto.migrate

field追加

lib/live_logger/accounts/user.ex
defmodule LiveLogger.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :email, :string

    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime
    field :passcode, :string # 追加

    timestamps()
  end
  ...
end

これでpasscodeカラムが追加できました

passcode発行画面

次にパスコード発行ボタンをusers/settingsに追加します

passcodeを生成するだけのchangesetを作成します
15桁の数値を保存しようとすると桁数が多すぎると怒られるため、string型にしています

lib/live_logger/accounts/user.ex
defmodule LiveLogger.Accounts.User do
  ...
  def passcode_changeset(user) do
    user
    |> cast(%{}, [:passcode])
    |> put_change(
      :passcode,
      Enum.random(100_000_000_000_000..999_999_999_999_999)
      |> to_string()
    )
  end
  ...
end

Accountsでpasscodeをupdateする関数を追加

lib/live_logger/accounts.ex
defmodule LiveLogger.Accounts do
  ...
  def generate_passcode(%User{} = user) do
    user
    |> User.passcode_changeset()
    |> Repo.update()
  end
  ...
end

衝突しないようにpasscodeをuniqueにすればエラーが出るかもしれませんが、今回はしないので正常系のみ実装

lib/live_logger_web/controllers/user_settings_controller.ex
defmodule LiveLoggerWeb.UserSettingsController do
  ... 
  def generate_passcode(conn, _params) do
    case Accounts.generate_passcode(conn.assigns.current_user) do
      {:ok, _ } -> 
        conn
        |> put_flash(:info, "Passcode generate successfully.")
        |> redirect(to: Routes.user_settings_path(conn, :edit))
    end
  end
  ...
end

テンプレートがheexになってform_forが使えないので、linkをmethod postにしてformの代替にしています

lib/live_logger_web/templates/user_settings/edit.html.heex
<h1>Settings</h1>

<h3>Generate Passcode</h3>
<%= link "Generate Passcode", 
          to: Routes.user_settings_path(@conn, :generate_passcode), 
          method: :post, 
          class: "button is-info" 
%>
<%= if @conn.assigns.current_user.passcode do %>
  <p><%= "passcode:" <> @conn.assigns.current_user.passcode %></p>
<% end %>
...

routerに追加

lib/live_logger_web/router.ex
defmodule LiveLoggerWeb.Router do
  ...
  scope "/", LiveLoggerWeb do
    pipe_through [:browser, :require_authenticated_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
    post "/users/settings/generate_passcode", UserSettingsController, :generate_passcode # 追加

    live "/maps", MapLive.Index, :index
    live "/maps/new", MapLive.Index, :new
    live "/maps/:id/edit", MapLive.Index, :edit

    live "/maps/:id", MapLive.Show, :show
    live "/maps/:id/show/edit", MapLive.Show, :edit
  end
  ...
end

動作確認をしていきます


ボタンを押してpasscodeが発行されるのを確認できました

passcode認証

最後に認証部分を実装します

passcodeでuserを取得するクエリーを追加します

lib/live_logger/accounts.ex
defmodule LiveLogger.Accounts do
  ...
  def get_user_by_passcode(passcode) when is_binary(passcode) do
    Repo.get_by(User, passcode: passcode)
  end
  ...
end

paramsの部分の引数のパターンマッチを変えているので、同名の関数でも処理を分けて実行することができます

lib/live_logger_web/controllers/user_api_session_controller.ex
defmodule LiveLoggerWeb.UserApiSessionController do
  ...
  def create(conn, %{"email" => email, "password" => password}) do
    with user when is_struct(user) <- Accounts.get_user_by_email_and_password(email, password),
         token <- user |> Accounts.generate_user_session_token() |> Base.encode64() do
      render(conn, "token.json", token: token)
    else
      _error -> {:error, :unauthorized}
    end
  end

  # 以下追加
  def create(conn, %{"passcode" => passcode}) do
    with user when is_struct(user) <- Accounts.get_user_by_passcode(passcode),
         token <- user |> Accounts.generate_user_session_token() |> Base.encode64() do
      render(conn, "token.json", token: token)
    else
      _error -> {:error, :unauthorized}
    end
  end
  ...
end

動作確認をしていきます


先程発行したpasscodeで認証トークンを取得することができました

最後に

これでemail, passwordで認証トークンが発行できないデバイスをパスコードを使用して認証トークンを発行できるようになりました
本記事は以上になります

次はLiveViewでGoogleMapを表示する部分を実装します

code