Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編

公開:2020/09/24
更新:2020/09/27
15 min読了の目安(約9400字TECH技術記事

Phoenix とExpoで作るスマホアプリ ①Phoenix セットアップ編 + phx_gen_auth
Phoenix とExpoで作るスマホアプリ ②JWT認証+CRUD編 <-本記事

前回はelixir,phoenixのセットアップとプロジェクト作成、
phx-gen-authで認証機能付きのユーザーを作成しました
今回はGuardianを使用してフロントとのJWT認証とCRUD機能を実装していこうと思います

CRUD作成

前回の記事ではUserを作成しただけで、API周りの必要なファイルが作られていないため最初に以下のコマンドでAPI周りのファイルと一緒にCRUDも作成します

mix phx.gen.json Posts Post posts body:string user_id:references:users

通常のアプリケーションの場合は phx.gen.htmlですがAPIの場合はphx.gen.jsonを使用します
またユーザーに関連付けるのでuser_id:references:usersで外部キーと関連先を設定します
次にリレーションの設定を行っていきます
[new] lib/sns/posts/post.ex

defmodule Sns.Posts.Post do
  use Ecto.Schema
  import Ecto.Changeset

  schema "posts" do
    field :body, :string
    field :user_id, :id # <- こっちは消す
    
    belongs_to :user, Sns.Users.User # <-こっちを書き加える
    timestamps()
  end

  @doc false
  def changeset(post, attrs) do
    post
    |> cast(attrs, [:body, :user_id]) # <- user_idを追加
    |> validate_required([:body, :user_id]) # <- user_idを追加
  end
end

[edit] lib/sns/users/user.ex

defmodule Sns.Users.User do
  use Ecto.Schema
  import Ecto.Changeset

  @derive {Inspect, except: [:password]}
  schema "users" do
    field :email, :string
    field :password, :string, virtual: true
    field :hashed_password, :string
    field :confirmed_at, :naive_datetime

    has_many :posts, Sns.Posts.Post # <- これを追加
    timestamps()
  end
...
end

Guardianのセットアップ

Guardianについては こちら
mix.exsに以下を追加してmix deps.getを実行します

[edit]mix.exs

  defp deps do
    [
      ...
      {:guardian, "~> 2.0"}
    ]
  end

次にGuardianを こちら を参考に設定をしていきます
secret_keyは mix guardian.gen.secret で作成したものを貼り付けるか環境変数をセットしてください

[edit]config/config.exs

config :sns, Sns.Guardian,
  issuer: "sns",
  secret_key: "Secret key. You can use `mix guardian.gen.secret` to get one"

[new]lib/sns/guardian.ex

defmodule Sns.Guardian do
  use Guardian, otp_app: :sns

  # Guardian.encode_and_sign(sign_up/sign_in)で実行
  def subject_for_token(user, _claims) do
    sub = to_string(user.id)
    {:ok, sub}
  end

  # headerのBearerのJWTを検証時(sign_up/sign_in以外のAPI)に実行
  def resource_from_claims(claims) do
    id = claims["sub"]
    resource = Sns.Users.get_user!(id)
    {:ok, resource}
  end
end

README.mdには subject_for_token, resource_from_claimsがエラー時の関数もありますが、compileで以下の警告がでるのと、エラー時には後述する guardianのerror_handlerとfallback_controllerにハンドリングされるため正常時の関数だけとしています

warning: this clause for resource_from_claims/1 cannot match because a previous clause at line 10 always matches

認証機能の実装(Model)

Guardianの設定が完了したので、認証部分を作成していきます
前回 phx-gen-authでweb画面の認証部分は作成されているので、それを流用してJWTの方も実装していきます

[edit]lib/sns/users.ex

defmodule Sns.Users do
  ...
  alias Sns.Guardian

  @doc """
  Generates a JWT
  """
  def token_sign_in(email, password) do
    if user = get_user_by_email_and_password(email, password) do
      Guardian.encode_and_sign(user)
    else
      {:error, :unauthorized}
    end
  end
  ...
end

認証機能の実装(Controller,View)

次にコントローラーとビューを作成します
APIでよくある /api/v1みたいなフォルダ構成は controllers/api/v1とフォルダを作成し、作成したファイルのモジュール名を Sns.Api.V1.UserControllerとすれば大丈夫です

[new]lib/sns_web/api/v1/user_controller.ex

defmodule SnsWeb.Api.V1.UserController do
  use SnsWeb, :controller

  alias Sns.Users
  alias Sns.Users.User
  alias Sns.Guardian

  action_fallback SnsWeb.FallbackController

  def show(conn, _params) do
    user = Guardian.Plug.current_resource(conn)
    render(conn, "show.json", user: user)
  end

  def create(conn, %{"user" => user_params}) do
    with {:ok, %User{} = user} <- Users.register_user(user_params) do
      {:ok, token, _claims} = Guardian.encode_and_sign(user)
      conn |> render("jwt.json", token: token)
    end
  end

  def sign_in(conn, %{"email" => email, "password" => password}) do
    with {:ok, token, _claims} <- Users.token_sign_in(email, password) do
      conn |> render("jwt.json", token: token)
    end
  end
end

action_fallbackで設定しているFallbackControllerは、
controller内でエラーが起こった際に対応したエラーを返すもので、
これによっていちいちエラー処理を書かなくて良くなります
またこれとchangeset_viewはphx.gen.json で先程モデルを作成した時に一緒に作成されています

viewはログインと新規作成時に返すJWTとテスト用のshowしか無いため、必要に応じて作成してください

[new]lib/sns_web/views/api/v1/user_view.ex

defmodule SnsWeb.Api.V1.UserView do
  use SnsWeb, :view

  def render("show.json", %{user: user}) do
    %{data: %{id: user.id, email:  user.email}}
  end

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

認証機能の実装(Router)

最後にrouter部分になります
認証が必要な箇所はjwt_authenticated pipeを通るようにします

[edit]lib/sns_web/router.ex

defmodule SnsWeb.Router do
  alias SnsWeb.ApiAuthPipeline

  pipeline :jwt_authenticated do
    plug ApiAuthPipeline
  end

  scope "/api/v1", SnsWeb do
    pipe_through :api

    post "/sign_up", Api.V1.UserController, :create
    post "/sign_in", Api.V1.UserController, :sign_in
  end

  scope "/api/v1", SnsWeb do
    pipe_through [:api, :jwt_authenticated]

    get "/mypage", Api.V1.UserController, :show
    resources "/posts", Api.V1.PostController, except: [:new, :edit]    
  end
end

Guardianのセットアップで作成したguardian.exとguardianのエラー時のハンドリングを行うmoduleを設定します

[new]lib/sns_web/api_auth_pipeline.ex

defmodule SnsWeb.ApiAuthPipeline do
  use Guardian.Plug.Pipeline, otp_app: :sns,
    module: Sns.Guardian,
    error_handler: SnsWeb.ApiAuthErrorHandler

  plug Guardian.Plug.VerifyHeader, realm: "Bearer"
  plug Guardian.Plug.EnsureAuthenticated
  plug Guardian.Plug.LoadResource
end

どのようなハンドリングを行うかを設定します

[new]lib/sns_web/api_auth_error_handler.ex

defmodule SnsWeb.ApiAuthErrorHandler do
  import Plug.Conn

  def auth_error(conn, {type, _reason}, _opts) do
    body = Jason.encode!(%{error: to_string(type)})
    send_resp(conn, 401, body)
  end
end

ユーザーsignup/signin 動作確認

実装は以上で終了ですので、動作確認をしていきましょう
動作確認にはPostmanを使用しました

新規作成失敗時
phx-auth-genの初期設定でパスワードは12文字以上となっているのでエラーになります

新規作成成功時
成功したのでトークンが返ってきました

ログイン失敗時

ログイン成功時
成功したのでトークンが返ってきました

トークン認証はAuthorizationタブを選択しtypeをBearer Tokenにしてください
jwt認証失敗時

jwt認証成功時
ログイン成功か新規作成成功で取得したtokenをAuthorizationのTokenにセットしてください

正常時のレスポンスとエラー時のレスポンス両方が来ていることを確認できました
#CRUD動作確認
次にCRUDの方も動作確認を行っていきますが、その前に少し細工をしていきます
Postを作成するときにuser_idが必要になるのですがそれを取得する際に

alias Sns.Guardian

def create(conn, %{"post" => params})
  post_params = Map.put(
      params,
      :user_id, 
      Guardian.Plug.current_resource(conn).id
  )
  ....
end

と面倒なのでpipelineで処理を行ってconn.user_idで取得できるようにしていきます

[new] lib/sns_web/auth_helper.ex

defmodule SnsWeb.AuthHelper do
  import Guardian.Plug

  def init(opts), do: opts

  def call(conn, _opts) do
    Map.put(conn, :user_id, current_resource(conn).id)
  end
end

[edit] lib/sns_web/router.ex

defmodule SnsWeb.Router do
  use SnsWeb, :router
  alias SnsWeb.ApiAuthPipeline
  alias SnsWeb.AuthHelper # <- これを追加
  import SnsWeb.UserAuth

  pipeline :jwt_authenticated do
    plug ApiAuthPipeline
    plug AuthHelper # <- これを追加
  end

[edit] lib/sns_web/controllers/api/v1/post_controller.ex

  def create(conn, %{"post" => post_params}) do
    with {:ok, %Post{} = post} <- Posts.create_post(
      Map.put(post_params, "user_id", conn.user_id) # <- ここを書き換え
    ) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", Routes.post_path(conn, :show, post))
      |> render("show.json", post: post)
    end
  end

これで少し楽になりました
このように全体を通して途中で処理を入れたいときはpipelineに組み込むとスッキリします
では動作確認を行いましょう

記事作成

記事一覧

記事編集

記事削除
204 no_contentが返ってきてます

CRUDが正常に動いているのが確認できました

JWT認証+CRUD編は以上になりますありがとうございました

次回はPostに画像を添付できる、ファイルアップロード部分を作成していきます

つまずきポイント

Repoでuserのレコードは取得できたがGuardianの認証がうまく行かないときに
IO.inspect(Guardian.encode_and_sign(user))
で実行した時に以下のエラーが出ていました
{:error, :secret_not_found}

原因はconfig.exs のsercret_keyのタイポでした
otp_appやissuer,app名もタイポミスしやすい箇所ですので、Guardianだけがうまく行かないというときはまずタイポを疑ってください

今回の差分

https://github.com/thehaigo/sns/commit/1fe9475ba0c57c0280c3d4446b63e2a2fe9ff6e7

参考ページ