9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PhoenixのGuardianでJWTを扱う

Last updated at Posted at 2018-01-21
※※※この記事は古くなったので最新環境(Phoenix1.3+Guardian1.0)で書き直しました =====> Phoenix1.3+Guardian1.0でJWT - Qiita

 PhoenixでRest APIを作り、Guardianを使って、JWT認証を行うサンプルです。オリジナルのサンプルコードが以下にありますので、それを動かしたときの備忘録となります。いくつかの細かい点を修正してあります。Guardian はElixirアプリのためのtokenベースの認証ライブラリで、主にjwtが想定されているようです。
Elixir + Phoenix Framework + Guardian + JWT + Comeonin

1.PhoenixでRest APIをつくる

 まず後で使うのでシークレットキーを作成しておきます。

mix phx.gen.secret
13mAgznHWlepHO1JuDjbtpu86zjxTclLfNgEVRwwAHJTrPD+vMZ/Kr8iy4q6z+8p

 次にプロジェクトを作成し、データベースを作り、Rest APIのリソースを作成します。詳細は「ElmからPhoenixのRest APIを叩いてみる」を読んでください。

mix phoenix.new phx_jwt
cd phx_jwt
mix ecto.create
mix phoenix.gen.json User users email:string name:string phone:string password_hash:string is_admin:boolean

 phoenix.gen.jsonでUser 関連のリソースが自動作成されます。それらのリソースはRest APIの"/users"パスのアクセス時に使われます。コマンドの出力として、以下のようなメッセージが表示されますので、指示に従います。

Add the resource to your api scope in web/router.ex:

    resources "/users", UserController, except: [:new, :edit]

Remember to update your repository by running migrations:

    $ mix ecto.migrate

 指示に従って、router.exに行を追加します。scopeを"/api/v1"にしておきます。従って上で"/users"と書きましたが正確には"/api/v1/users"ということになります。

web/router.ex
  #
  # Other scopes may use custom stacks.
  scope "/api/v1", PhxJwt do
    pipe_through :api
    resources "/users", UserController, except: [:new, :edit]
  end
  #

 router.exの設定が終わったら、以下のコマンドでmigrationファイルからテーブルを作成しておきます。ここまでで"/api/v1/users"というpathに対するContorollerやView、Schema、tableなどの基本的なフレームが出来上がりです。簡単でいいですね。

mix ecto.migrate

2.Guardianの設定

 Guardianの設定ですが、まずserializerファイルを設定します。これによってリソース(user)が与えられたときuser.idからtokenを作成し、逆にtokenからリソースが取得できるようになります。つまりtokenを "User:" <> id にマッチさせ、idを抽出して、Repo.get(User, id) でデータベースからuserをgetします。(<> opは文字列の連結です。)

lib/phx_jwt/token_serializer.ex
defmodule PhxJwt.GuardianSerializer do
  @behaviour Guardian.Serializer

  alias PhxJwt.Repo
  alias PhxJwt.User

  def for_token(user = %User{}), do: { :ok, "User:#{user.id}" }
  def for_token(_), do: { :error, "Unknown resource type" }

  def from_token("User:" <> id), do: { :ok, Repo.get(User, id) }
  def from_token(_), do: { :error, "Unknown resource type" }
end

 次にconfigファイルにGuardianの設定を書きます。ここのシークレットキーに最初に生成したものを設定します。以下の行を末尾に追加します。

config/config.exs
#
config :guardian, Guardian,
  allowed_algos: ["HS512"], # optional
  verify_module: Guardian.JWT,  # optional
  issuer: "PhxJwt",
  ttl: { 30, :days },
  allowed_drift: 2000,
  verify_issuer: true, # optional
  secret_key: "13mAgznHWlepHO1JuDjbtpu86zjxTclLfNgEVRwwAHJTrPD+vMZ/Kr8iy4q6z+8p", 
  serializer: PhxJwt.GuardianSerializer
#

 次にmix.exsのdepsにguardianを追加します。

mix.exs
  #
  defp deps do
    [
      {:phoenix, "~> 1.3.0-rc"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:guardian, "~> 0.14"} # これを追加
    ]
  end
  #

 次のコマンドでguardianをインストールします。

mix do deps.get, compile

 以上でGuardianの設定は終了です。

3.Comeoninの設定

 JWTはGuardianで扱いますが、パスワードのハッシュ化などはComeoninで扱います。

 まずphoenix.gen.jsonコマンドで作成したweb/models/user.exを微調整します。テーブルのfieldとしてpassword_hashを作成しました。しかしユーザからの入力は平文のpasswordで受け取りますので、その受け皿として、Elixir structにpasswordの項目を追加します。この項目は入力を受け取りハッシュを計算するまでのもので、最終的なテーブルには存在しないのでvirtualとします。以下のようになります。

web/models/user.ex
  #
  schema "users" do
    field :email, :string
    field :name, :string
    field :phone, :string
    field :password, :string, virtual: true # これを追加
    field :password_hash, :string
    field :is_admin, :boolean, default: false

    timestamps()
  end
 #

 Comeoninの設定をmix.exsに1行追加します。

mix.exs
  #
  defp deps do
    [
      {:phoenix, "~> 1.3.0-rc"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:guardian, "~> 0.14"},
      {:comeonin, "~> 3.0"}   # これを追加
    ]
  end
  #

 インストールします。

mix deps.get

 以上でComeoninの設定を終わります。

4.ユーザ登録(sign_up -- Registration)

 パスワードのハッシュ化が可能になったのでユーザ登録に進みたいと思います。まずSchemaとChangesに対する処理を追加します。

web/models/user.ex
defmodule PhxJwt.User do
  use PhxJwt.Web, :model

  schema "users" do
    field :email, :string
    field :name, :string
    field :phone, :string
    field :password, :string, virtual: true # We need to add this row
    field :password_hash, :string
    field :is_admin, :boolean, default: false

    timestamps()
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :name, :phone, :password, :is_admin])
    |> validate_required([:email, :name, :password])
    |> validate_changeset
  end

  def registration_changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:email, :name, :phone, :password])
    |> validate_required([:email, :name, :phone, :password])
    |> validate_changeset
  end

  defp validate_changeset(struct) do
    struct
    |> validate_length(:email, min: 5, max: 255)
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:email)
    |> validate_length(:password, min: 8)
    |> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
    |> generate_password_hash
  end

  defp generate_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
        put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
      _ ->
        changeset
    end
  end
end

 ほとんどがvalidationのチェックです。重要な点は、以下の行でパスワードをハッシュ化して、changesetのstructに保存しているところです。

 put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))

 またオリジナルコードのunique_constraint(:email)をそのまま載せていますが、このユニーク性のチェックはデータベースの設定に依存しているので、以下のような設定が行われていなければ駄目のようです。今回は設定していないので意味がないチェックとなっています。

create unique_index(:users, [:email])

 ユーザ登録はRegistration 名で行います。まずcontrollersを作成します。成功した場合はput_status(:created)、失敗した場合はput_status(:unprocessable_entity)を行います。

web/controllers/registration_controller.ex
defmodule PhxJwt.RegistrationController do
  use PhxJwt.Web, :controller

  alias PhxJwt.User

  def sign_up(conn, %{"user" => user_params}) do
    changeset = User.registration_changeset(%User{}, user_params)

    case Repo.insert(changeset) do
      {:ok, user} ->
        conn
        |> put_status(:created)
        |> put_resp_header("location", user_path(conn, :show, user))
        |> render("success.json", user: user)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(PhxJwt.ChangesetView, "error.json", changeset: changeset)
    end
  end
end

 put_statusはresponse statusをconnに書き込みます。ステータスコードの代わりにput_status(conn, :not_found)のようにatomを使えます。これは404を返します。 詳細は公式サイト Plug.Conn を参照してください。

 次に view を作成します。

web/views/registration_view.ex
defmodule PhxJwt.RegistrationView do
  use PhxJwt.Web, :view

  def render("success.json", %{user: user}) do
    %{
      status: :ok,
      message: """
        Now you can sign in using your email and password at /api/sign_in. You will receive JWT token.
        Please put this token into Authorization header for all authorized requests.
      """
    }
  end
end

 また上でエラー時に呼ばれれていた changeset_view.ex はphoenix.gen.jsonで自動生成されています。ちょっと覗いてみましょう。上ではrender(PhxJwt.ChangesetView, "error.json", changeset: changeset)で呼んでいましたが、moduleが違うので第一引数で明示していたのだと思われます。

web/views/changeset_view.ex
defmodule PhxJwt.ChangesetView do
  use PhxJwt.Web, :view

  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
~

 最後にrouterを設定します。

web/router.ex
  #
  scope "/api/v1", PhxJwt do
    pipe_through :api
    post "/sign_up", RegistrationController, :sign_up  # これを追加

    resources "/users", UserController, except: [:new, :edit]
  end
  #

 さてここまで設定出来たら、ユーザ登録を確かめることができます。まず以下のコマンドでサーバを立ち上げます。

mix phx.server

 次に別ターミナルから、curlコマンドでエラー入力を与えてみましょう。以下のようにエラーが出力されますが、これが望みの結果です。

curl -H 'Content-Type: application/json' -X POST -d '{ "user":{}}' http://localhost:4000/api/v1/sign_up

{"errors":{
    "phone":["can't be blank"], "password":["can't be blank"],
    "name":["can't be blank"], "email":["can't be blank"]}}

 次に、正常の値を設定して、curlコマンドを打ってみます。status okとmessageを受け取りますが、これも望みの結果です。

curl -H 'Content-Type: application/json' -X POST -d '{"user": {"email": "hello@world.com","name": "John Doe","phone": "033-64-22","password": "MySuperPa55"}}' http://localhost:4000/api/v1/sign_up

{"status":"ok",
 "message":"  Now you can sign in using your email and password at /api/sign_in. 
You will receive JWT token.\n  
Please put this token into Authorization header for all authorized requests.\n"}

 さて上の"/api/v1/sign_up"のパスは手動で設定したものでした。しかし一番最初に phoenix.gen.jsonで"/users"パスに対するUserリソースを自動生成していました。User contorollerやUser viewが自動生成されていたはずです。データの入力が完了した今のタイミングで以下のコマンドが使えることを確認します。

curl -X GET "http://localhost:4000/api/v1/users"

{"data":[{"phone":"033-64-22",
"password_hash":"$2b$12$wIOCCY33P4dJ/57zMpOuZu1ta7Xa0nxLkLVKU7RY/wENNSVJ/kSqe",
"name":"John Doe","is_admin":false,"id":1,"email":"hello@world.com"}]}

 これでユーザ登録(sign_up)の設定が完了です。

5.ログイン(sign_in -- Session)

 まずログイン処理に必要となる関数を追加します。find_and_confirm_passwordはメールとパスワードでユーザ認証を行う関数です。Comeonin.Bcrypt.checkpw(password, user.password_hash)でチェックしてokかerrorかを決めます。このレベルではjwtは関係ありません。

web/models/user.ex

defmodule PhxJwt.User do
  # ...
  alias PhxJwt.Repo
  alias PhxJwt.User
  def find_and_confirm_password(email, password) do
    case Repo.get_by(User, email: email) do
      nil ->
        {:error, :not_found}
      user ->
        if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
          {:ok, user}
        else
          {:error, :unauthorized}
        end
    end
  end
  # ...
end

 ログインはSession 名で行います。まずcontrollerを作成します。sign_in関数はfind_and_confirm_password関数で認証を行い、okならuserからjwtを作って返します。jwtはGuardian.encode_and_signで作ります。公式ドキュメントGuardian v1.0.1

web/controllers/session_controller.ex
defmodule PhxJwt.SessionController do
  use PhxJwt.Web, :controller

  alias PhxJwt.User

  def sign_in(conn, %{"session" => %{"email" => email, "password" => password}}) do  
    case User.find_and_confirm_password(email, password) do
      {:ok, user} ->
         {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :api)

         conn
         |> render "sign_in.json", user: user, jwt: jwt
      {:error, _reason} ->
        conn
        |> put_status(401)
        |> render "error.json", message: "Could not login"
    end
  end  
end

 次にviewを作成します。ちなみにオリジナルのコードにはrender("error.json", %{message: msg})のパターンが省かれていたので追加してあります。

web/views/session_view.ex
defmodule PhxJwt.SessionView do
  use PhxJwt.Web, :view

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

  def render("error.json", %{message: msg}) do
    %{"error": msg}
  end
end

 最後にrouterを設定します。

web/router.ex
  #
  scope "/api/v1", PhxJwt do
    pipe_through :api
    post "/sign_up", RegistrationController, :sign_up
    post "/sign_in", SessionController, :sign_in  # これを追加

    resources "/users", UserController, except: [:new, :edit]
  end
  #

 ログイン設定が完了したので、以下のcurlコマンドでログインを行います。tokenが長すぎて見づらいですが、望みの結果が表示されます。tokenを得たブラウザはログイン状態に入ります。次のリクエストからtokenをヘッダーにつけて渡すことになります。

curl -H 'Content-Type: application/json' -X POST -d '{"session": {"email": "hello@world.com","password": "MySuperPa55"}}' http://localhost:4000/api/v1/sign_in

{"status":"ok",
 "message":"You are successfully logged in! Add this token to authorization header to make authorized requests.",
"data":{"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJVc2VyOjEiLCJleHAiOjE1MTkxMDg1MzUsImlhdCI6MTUxNjUxNjUzNSwiaXNzIjoiUGh4Snd0IiwianRpIjoiNzMxMWY5OGMtZGMwOC00MjE4LTg3Y2EtMzE0ZmVmYWM5YWYwIiwicGVtIjp7fSwic3ViIjoiVXNlcjoxIiwidHlwIjoiYXBpIn0.XP21dzffZS4hB8G7E822dHfPPOJ71jC8LBzZfcGSCZqFK7xtPBWzg0WWpc4C64SWMJQ7u4lxv8xHhVCH-CznhQ",
"email":"hello@world.com"}}

 念のために、ログイン失敗のケースも見ておきましょう。パスワードを少し変えてログインしてみます。返ってくるエラーメッセージも期待通りです。

curl -H 'Content-Type: application/json' -X POST -d '{"session": {"email": "hello@world.com","password": "MySuperPa56"}}' http://localhost:4000/api/v1/sign_in

{"error":"Could not login"}

6.アクセス制限(Authorization)

 さて現状では、以下のユーザ一覧取得のコマンドは、非ログイン状態でも成功します。これをログインユーザのみに制限したいと思います。

curl -X GET "http://localhost:4000/api/v1/users"

 ログインした時だけ "/api/v1/users" のアクセスを許可(Autherization)するように変更します。router.exを修正して、以下の数行を追加するだけで実現できます。

web/router.ex
defmodule PhxJwt.Router do
  use PhxJwt.Web, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
    plug Guardian.Plug.VerifyHeader, realm: "Bearer" # 追加
    plug Guardian.Plug.LoadResource    # 追加
  end

  pipeline :authenticated do    # 3行追加
    plug Guardian.Plug.EnsureAuthenticated
  end

  scope "/", PhxJwt do
    pipe_through :browser # Use the default browser stack

    get "/", PageController, :index
  end

  # Other scopes may use custom stacks.
  scope "/api/v1", PhxJwt do
    pipe_through :api
    post "/sign_up", RegistrationController, :sign_up
    post "/sign_in", SessionController, :sign_in

    pipe_through :authenticated    # 追加 下のusersのみ制限
    resources "/users", UserController, except: [:new, :edit]
  end
end

 Guardian.Plug.VerifyHeaderはAuthorization headerを見て、tokenが存在し改竄が無いことを確認します。一般的に、jwtはidを内包しているだけでなく、改竄チェックが行えるという優良な性質を持っています。またrealm: "Bearer"を明示的に指定する必要があります。これ無しだとElmのJWTパッケージを通してアクセスし他時に認証が通りませんでした。Guardian.Plug.LoadResource は改竄チェックを終えたtokenからリソース(user)をロードします。このような操作を経て、Guardian.Plug.EnsureAuthenticatedは正しいtokenの存在を確認し、存在しなければauth_errorを呼んで:unauthenticatedを返します。

 以上の制限により、前と同じコマンドを打てば401を表すUnauthenticatedが返ってくるようになりました。

curl -X GET "http://localhost:4000/api/v1/users"

Unauthenticated

 以上で終わります。

9
3
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
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?