※※※この記事は古くなったので最新環境(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"ということになります。
#
# 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は文字列の連結です。)
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 :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を追加します。
#
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とします。以下のようになります。
#
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行追加します。
#
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に対する処理を追加します。
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)を行います。
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 を作成します。
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が違うので第一引数で明示していたのだと思われます。
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を設定します。
#
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は関係ありません。
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
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})のパターンが省かれていたので追加してあります。
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を設定します。
#
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を修正して、以下の数行を追加するだけで実現できます。
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
以上で終わります。