Comeonin 3.0とGuardian 0.14.0を使ってログイン機能を作成する。
Comeoninはパスワードのハッシュ化を行い、GuardianはJWT Tokenを生成する。
インストールと設定
次の依存関係を追加し、mix deps.get
を実行する。
def application do
[applications: [:logger, :comeonin]]
end
defp deps do
{:comeonin, "~> 3.0"},
{:guardian, "~> 0.14"}
end
また、config/config.exs
にGuardianの設定を書く。
# Configures Guardian
config :guardian, Guardian,
issuer: "MyApp",
ttl: {30, :days},
allowed_drift: 2000,
secret_key: "a big secured secret key",
serializer: MyApp.Guardian.Serializer
設定を書いた後にmix phoenix.gen.secret
を実行して、上記secret_key
を上書きする。
設定したMyApp.Guardian.Serializer
を次のように作成する。Userモデルはこのあと生成する。
ここで何をしているかは、JWT Tokenについて参照。
defmodule MyApp.Guardian.Serializer do
@behaviour Guardian.Serializer
alias MyApp.Repo
alias MyApp.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
Userモデル
email
、encrypted_password
のカラムを持つUserモデルを生成する。
mix phoenix.gen.model User users email encrypted_password
email
カラムにはユニークインデックスを生成しておく。
Userモデルを次のように変更していく。
- Repoへのaliasを作る。
-
password_plain
とterms_confirmed
の2つの擬似フィールドを追加する。 - changeset/2関数ではバリデーションを行い、
cs_encrypt_password
を呼び出し、encrypted_passwordを生成、セットする。 -
cs_encrypt_password
ではバリデーションを全て追加していればパスワードのハッシュ化を行う。 - ログインと新規作成を行うための、
sign_in
、sign_up
関数を追加する。 -
check_password
関数では、パスワードが正しいか確認する。本番環境では、ユーザーが見つからなかった場合でも見つかった場合と比べてレスポンスタイムが早くならないようにダミーの関数を呼び出す。(悪意のある人間を欺くため)
これらを行うと、次のようになる。
defmodule MyApp.User do
use MyApp.Web, :model
alias MyApp.Repo
schema "users" do
field :email, :string
field :encrypted_password, :string
field :password_plain, :string, virtual: true
field :terms_confirmed, :boolean, virtual: true
timestamps()
end
@required_fields ~w(email password_plain terms_confirmed)
@optional_fields ~w(encrypted_password)
def changeset(model, params \\ :empty) do
model
|> cast(params, @required_fields, @optional_fields)
|> validate_format(:email, ~r/@/, message: "Email format is not valid")
|> validate_length(:password_plain, min: 5, message: "Password should be 5 or more characters long")
|> validate_confirmation(:password_plain, message: "Password confirmation doesn’t match")
|> unique_constraint(:email, message: "This email is already taken")
|> validate_change(:terms_confirmed, fn
_, true -> []
_, _ -> [terms_confirmed: "Please confirm terms and conditions"]
end)
|> cs_encrypt_password()
end
def signup(params) do
%__MODULE__{}
|> changeset(params)
|> Repo.insert()
end
def signin(params) do
email = Map.get(params, "email", "")
password = Map.get(params, "password", "")
__MODULE__
|> Repo.get_by(email: String.downcase(email))
|> check_password(password)
end
defp cs_encrypt_password(%Ecto.Changeset{valid?: true, changes: %{password_plain: pwd}} = cs) do
put_change(cs, :encrypted_password, Comeonin.Bcrypt.hashpwsalt(pwd))
end
defp cs_encrypt_password(cs), do: cs
defp check_password(%__MODULE__{encrypted_password: hash} = user, password) do
case Comeonin.Bcrypt.checkpw(password, hash) do
true -> {:ok, user}
false -> {:error, "Invalid email or password"}
end
end
defp check_password(nil, _password) do
if Mix.env == :prod do
Comeonin.Bcrypt.dummy_checkpw()
end
{:error, "Invalid email or password"}
end
end
ログイン・ログアウトの機能
認証および新規登録の処理は以上で終了した。続いてGuardianを用いたTokenの作成を行う。
まずは、ヘッダの確認とリソース(User)の読み込みをするためにRouterにplugを追加する。また、UserControllerと、ログイン・ログアウトを実行するTokenControllerへのルートを追加しておく。TokenControllerはこのあと作成する。
pipeline :api do
plug :accepts, ["json"]
plug Guardian.Plug.VerifyHeader, realm: "Bearer"
plug Guardian.Plug.LoadResource
end
scope "/api", MyApp do
pipe_through :api
scope "/v1", V1, as: :v1 do
resources "/tokens", TokenController, only: [:create, :delete]
resources "/users", UserController, except: [:new, :edit]
end
end
ここでは、クライアントから「Authorization: Bearer SECRET_TOKEN」という形式のAuthorizationヘッダでトークンが渡されることを想定している。
TokenControllerでは、次の3つの処理を作っていく。
- 認証の済んだユーザーに対してトークンを発行する
- リクエストごとにトークンの確認をし、不正であれば弾く。
- ログアウトの処理を作る
defmodule MyApp.V1.TokenController do
use MyApp.Web, :controller
alias MyApp.User
plug Guardian.Plug.EnsureAuthenticated, [handler: MyApp.V1.GuardianErrorHandler] when not action in [:create]
def create(conn, params) do
case User.signin(params) do
{:ok, user} ->
conn
|> sign_in(user)
|> render("show.json")
{:error, _changeset} ->
conn
|> put_status(401)
|> render("error.json", message: "Could not login")
end
end
def delete(conn, %{"id" => id}) do
jwt = Guardian.Plug.current_token(conn)
claims = Guardian.Plug.claims(conn)
Guardian.revoke!(jwt, claims)
render "logout.json"
end
defp sign_in(conn, user) do
conn
|> Guardian.Plug.api_sign_in(user)
|> add_jwt
end
defp add_jwt(conn) do
jwt = Guardian.Plug.current_token(conn)
assign(conn, :jwt, jwt)
end
end
次のようにViewを生成する。
defmodule MyApp.V1.TokenView do
use MyApp.Web, :view
def render("show.json", %{jwt: jwt}) do
%{jwt: jwt}
end
def render("error.json", %{message: message}) do
%{message: message}
end
end
また、トークンが不正だった場合のエラーハンドラーを追加する。
defmodule MyApp.V1.GuardianErrorHandler do
use MyApp.Web, :controller
def unauthenticated(conn, _params) do
conn
|> put_status(:forbidden)
|> render(MyApp.V1.GuardianErrorView, :forbidden)
end
end
Viewは次の通り。
defmodule MyApp.V1.GuardianErrorView do
use MyApp.Web, :view
def render("forbidden.json", _assigns) do
%{message: "Forbidden"}
end
end
ログアウトの処理は、クライアント側でTokenを削除する。GuardianDbを使っていると上記のコードでバックエンド側でTokenを使えないようにすることができる。(現時点で最新のGuardian 3.0とGuardian Db 0.7の組み合わせだとバグがあり、うまく動作しない。)
ログイン中のユーザーの取得はuser = Guardian.Plug.current_resource(conn)
で行うことができる。あとは適宜、認証が必要な関数に対してGuardian.Plug.EnsureAuthenticated
をしていく。
参考URL
- github.com/riverrun/comeonin
- github.com/ueberauth/guardian
- Phoenix + React: love story. RePh 1.
- Phoenix + React: love story. RePh 2.
- Simple Guardian - Browser login
- Simple Guardian - API authentication
- Simple Guardian - Permissions
- Guard this with your life... Or authenticating APIs with Guardian
- GETTING STARTED WITH GUARDIAN
- API AUTHENTICATION WITH GUARDIAN