はじめに
ひとり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のタプルを返しているだけですが、次の節で解説するので一旦置いておきます。
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
defmodule LiveLoggerWeb.UserApiSessionView do
use LiveLoggerWeb, :view
def render("token.json", %{token: token}) do
%{token: token}
end
end
routerに api/signin
でendpointを追加します
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しか定義されていなかったので、認証失敗とバリデーションエラー時を追加します
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 Found
やUnauthorized
が表示されるのですが、
それがなぜかというと、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名が返ってきます
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が返ってきています
バリデーションエラーを起こした場合はこちらのViewを指定しています
内容としては Ecto.Changeset.traverse_errorsを実行していい感じのJSONにしてエラーを返しています
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します。
無事バリデーションエラーの内容が返ってくるのを確認しました
API request時のトークン認証
認証はauthorization headerのBearer tokenで行います
authorization haderには "Bearer " + tokenで間に半角スペースがあるのでパターンマッチさせる場合には注意してください。
認証に成功した場合はuserとtokenをアサインしてAPIの関数を実行します
エラー時の処理ですが、user_authはコントローラーではないので action_fallbackは指定できないため、直接FallbackControllerの関数を呼び出しています。
最後にhalt()でコネクションを落とすのを忘れないようにしましょう
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を編集します
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
それでは動作確認を行っていきましょう
token リフレッシュ
最後にトークンリフレッシュを実装します。
アプリで一度認証して同じトークンを長期間使い回すとセキュリティ上あまり良くはないので、
古いトークンを破棄して新しいトークンを作成するAPIを実装します。
関数の引数はパターンマッチマッチで展開することで特定の値を取得することができます。
これを応用して同じ関数でも引数によって挙動を変えることができます。
トークンの破棄と新規作成はphx.gen.authで作成済みなので、encodeしてトークンを返すだけです
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に追加して完了です
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
動作確認をしていきましょう
古いのは破棄されているので 401が返ってきます
おまけ
tokenの文字数を増やしたり、有効期間を長くしたい場合は以下を変更することで可能です。
tokenの文字数を増やしたい
defmodule LiveLogger.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
@hash_algorithm :sha256
@rand_size 32 # ここを増やす
...
end
tokenの有効期限を長くしたい
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