LoginSignup
6
1

More than 1 year has passed since last update.

Phoenixで作るGPS Logging System 5 APIの認証

Last updated at Posted at 2021-12-08

はじめに

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

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

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

  • 認証トークン発行API
  • APIのエラーハンドリング
  • API request時のトークン認証
  • token リフレッシュ

認証トークン発行API

day1 で phx.gen.authをで認証機能を追加しましたが、APIの認証は含まれていないのでこちらで実装していきます。
こちらの記事ではif文を使っていましたが、今回はwithで書いていきます。

withは式の右側の結果を左側とパターンマッチさせます。
例外が起きた時はelse側に行きますが、get_user_by_email_and_passwordは見つからなかった場合はnilを返すため、常にtrue側になってしまいます。
なのでガード節でnilでないかをチェックしています。

withを全てパスした場合はtokenを返す viewを表示します。

また action_fallbackが付いて認証失敗時も errorのタプルを返しているだけですが、次の節で解説するので一旦置いておきます。

lib/live_logger_web/controllers/user_api_session_controller.ex
defmodule LiveLoggerWeb.UserApiSessionController do
  use LiveLoggerWeb, :controller

  alias LiveLogger.Accounts
  action_fallback LiveLoggerWeb.FallbackController

  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
end
lib/live_logger_web/views/user_api_session_view.ex
defmodule LiveLoggerWeb.UserApiSessionView do
  use LiveLoggerWeb, :view

  def render("token.json", %{token: token}) do
    %{token: token}
  end
end

routerに api/signinでendpointを追加します

lib/live_logger_web/router.ex
defmodule LiveLoggerWeb.Router do
  use LiveLoggerWeb, :router

  # Other scopes may use custom stacks.
  scope "/api", LiveLoggerWeb do
    pipe_through :api

    resources "/maps", MapController, except: [:new, :edit]
    resources "/points", PointController, only: [:create]
    post "/signin", UserApiSessionController, :create #追加
  end
end

APIのエラーハンドリング

phx.gen.jsonを実行した際に fallback_controllerというものが作成されます。
これをaction_fallbackマクロに指定することによって、エラー時にfallback_controllerに定義されたエラーをレスポンスとして返します。

action_callbackは各actionがconnを返さなかった場合にfallbackに指定したモジュールの関数にマッチしたエラーレスポンスを返します、phx.gen.jsonでは404しか定義されていなかったので、認証失敗とバリデーションエラー時を追加します

lib/live_logger_web/controllers/fallback_controller.ex
defmodule LiveLoggerWeb.FallbackController do
  @moduledoc """
  Translates controller action results into valid `Plug.Conn` responses.

  See `Phoenix.Controller.action_fallback/1` for more details.
  """
  use LiveLoggerWeb, :controller

  # This clause is an example of how to handle resources that cannot be found.
  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(LiveLoggerWeb.ErrorView)
    |> render(:"404")
  end

  # 以下追加
  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:unauthorized)
    |> put_view(LiveLoggerWeb.ErrorView)
    |> render(:"401")
  end

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(LiveLoggerWeb.ChangesetView)
    |> render("error.json", changeset: changeset)
  end
end

render :401,:404と指定するだけでNot FoundUnauthorizedが表示されるのですが、
それがなぜかというと、phx.gen.jsonで作成されたerror_viewで以下の関数が実行されて
Phoenix.Controller.status_message_from_template
その関数内でPlug.Conn.Status.reason_phrase()が呼ばれています。
Plug.Conn.Statusをみると各http statusが列挙されていて、
reason_phraseに status codeを入れることでstatus名が返ってきます

lib/live_logger_web/views/error_view.ex
defmodule LiveLoggerWeb.ErrorView do
  use LiveLoggerWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  # def render("500.html", _assigns) do
  #   "Internal Server Error"
  # end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

では動作確認をしてみましょう、登録していないemailとpasswordを入力してpostすると
401 Unauthorizedが返ってきています

スクリーンショット 2021-12-08 15.25.29.png

バリデーションエラーを起こした場合はこちらのViewを指定しています
内容としては Ecto.Changeset.traverse_errorsを実行していい感じのJSONにしてエラーを返しています

lib/live_logger_web/views/changeset_view.ex
defmodule LiveLoggerWeb.ChangesetView do
  use LiveLoggerWeb, :view

  @doc """
  Traverses and translates changeset errors.

  See `Ecto.Changeset.traverse_errors/2` and
  `LiveLoggerWeb.ErrorHelpers.translate_error/1` for more details.
  """
  def translate_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
  end

  def render("error.json", %{changeset: changeset}) do
    # When encoded, the changeset returns its errors
    # as a JSON object. So we just pass it forward.
    %{errors: translate_errors(changeset)}
  end
end

こちらも動作確認を行いましょう。
前に作った api/pointsに関係ないデータをpostします。

スクリーンショット 2021-12-08 15.30.43.png

無事バリデーションエラーの内容が返ってくるのを確認しました

API request時のトークン認証

認証はauthorization headerのBearer tokenで行います
authorization haderには "Bearer " + tokenで間に半角スペースがあるのでパターンマッチさせる場合には注意してください。

認証に成功した場合はuserとtokenをアサインしてAPIの関数を実行します
エラー時の処理ですが、user_authはコントローラーではないので action_fallbackは指定できないため、直接FallbackControllerの関数を呼び出しています。
最後にhalt()でコネクションを落とすのを忘れないようにしましょう

lib/live_logger_web/controllers/user_auth.ex
defmodule LiveLoggerWeb.UserAuth do
...
  def require_authenticated_token(conn, _opts) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         {:ok, binary_token} <- Base.decode64(token),
         user when is_struct(user) <- Accounts.get_user_by_session_token(binary_token) do
      conn
      |> assign(:current_user, user)
      |> assign(:token, binary_token)
    else
      _error ->
        conn
        |> LiveLoggerWeb.FallbackController.call({:error, :unauthorized})
        |> halt()
    end
  end
...
end

mapとpointsのAPIをトークン認証を行わないと実行できないように routerを編集します

lib/live_logger_web/router.ex
defmodule LiveLoggerWeb.Router do
  ...
  # maps,points削除
  scope "/api", LiveLoggerWeb do
    pipe_through :api

    post "/signin", UserApiSessionController, :create
  end
  # 以下追加
  scope "/api", LiveLoggerWeb do
    pipe_through [:api, :require_authenticated_token]

    resources "/maps", MapController, except: [:new, :edit]
    resources "/points", PointController, only: [:create]
  end
  ...
end

それでは動作確認を行っていきましょう

トークン作成
スクリーンショット 2021-12-08 15.58.15.png

トークン認証成功
スクリーンショット 2021-12-08 15.58.40.png

トークン認証失敗
スクリーンショット 2021-12-08 16.04.25.png

token リフレッシュ

最後にトークンリフレッシュを実装します。
アプリで一度認証して同じトークンを長期間使い回すとセキュリティ上あまり良くはないので、
古いトークンを破棄して新しいトークンを作成するAPIを実装します。
関数の引数はパターンマッチマッチで展開することで特定の値を取得することができます。
これを応用して同じ関数でも引数によって挙動を変えることができます。

トークンの破棄と新規作成はphx.gen.authで作成済みなので、encodeしてトークンを返すだけです

lib/live_logger_web/controllers/user_api_session_controller.ex
defmodule LiveLoggerWeb.UserApiSessionController do
  ...
  def refresh_token(%{assigns: %{current_user: user, token: old_token}} = conn, _params) do
    Accounts.delete_session_token(old_token)

    new_token =
      user
      |> Accounts.generate_user_session_token()
      |> Base.encode64()

    render(conn, "token.json", token: new_token)
  end
end

routerに追加して完了です

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

    resources "/maps", MapController, except: [:new, :edit]
    resources "/points", PointController, only: [:create]
    post "/refresh_token", UserApiSessionController, :refresh_token # 追加
  end
  ...
end

動作確認をしていきましょう

スクリーンショット 2021-12-08 16.48.43.png
新しいtokenが発行されています

古いトークンで再度実行します
スクリーンショット 2021-12-08 16.55.43.png

古いのは破棄されているので 401が返ってきます

おまけ

tokenの文字数を増やしたり、有効期間を長くしたい場合は以下を変更することで可能です。

tokenの文字数を増やしたい

lib/live_logger/accounts/user_token.ex
defmodule LiveLogger.Accounts.UserToken do
  use Ecto.Schema
  import Ecto.Query

  @hash_algorithm :sha256
  @rand_size 32 # ここを増やす
...
end

tokenの有効期限を長くしたい

lib/live_logger_web/controllers/user_auth.ex
defmodule LiveLoggerWeb.UserAuth do
  import Plug.Conn
  import Phoenix.Controller

  alias LiveLogger.Accounts
  alias LiveLoggerWeb.Router.Helpers, as: Routes

  # Make the remember me cookie valid for 60 days.
  # If you want bump or reduce this value, also change
  # the token expiry itself in UserToken.
  @max_age 60 * 60 * 24 * 60 # 最後の60が日数なのでここを増やす
end

最後に

API認証もphx.gen.authとphx.gen.jsonで楽に実装できました
本記事は以上になります。

次はemailとパスワードを入力しにくいデバイス用にパスコードを使用したAPI認証を実装します

code

参考ページ

https://qiita.com/the_haigo/items/a194afc7d859b4ef48e5
https://hexdocs.pm/phoenix/Phoenix.Controller.html#action_fallback/1
https://hexdocs.pm/phoenix/Phoenix.Controller.html#status_message_from_template/1
https://github.com/phoenixframework/phoenix/blob/a3a470e6bc4ecefbfac783f465e0fa130f672d4c/lib/phoenix/controller.ex#L1430
https://hexdocs.pm/plug/Plug.Conn.Status.html#reason_phrase/1
https://hexdocs.pm/ecto/Ecto.Changeset.html#traverse_errors/2

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