9
8

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でAPIによるログイン機能を実装する

Last updated at Posted at 2016-12-26

Comeonin 3.0Guardian 0.14.0を使ってログイン機能を作成する。

Comeoninはパスワードのハッシュ化を行い、GuardianはJWT Tokenを生成する。

インストールと設定

次の依存関係を追加し、mix deps.getを実行する。

mix.exs
def application do
  [applications: [:logger, :comeonin]]
end

defp deps do 
  {:comeonin, "~> 3.0"},
  {:guardian, "~> 0.14"}
end

また、config/config.exsにGuardianの設定を書く。

config/config.exs
# 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について参照。

lib/my_app/guardian/serializer.ex
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モデル

emailencrypted_passwordのカラムを持つUserモデルを生成する。

mix phoenix.gen.model User users email encrypted_password

emailカラムにはユニークインデックスを生成しておく。

Userモデルを次のように変更していく。

  • Repoへのaliasを作る。
  • password_plainterms_confirmedの2つの擬似フィールドを追加する。
  • changeset/2関数ではバリデーションを行い、cs_encrypt_passwordを呼び出し、encrypted_passwordを生成、セットする。
  • cs_encrypt_passwordではバリデーションを全て追加していればパスワードのハッシュ化を行う。
  • ログインと新規作成を行うための、sign_insign_up関数を追加する。
  • check_password関数では、パスワードが正しいか確認する。本番環境では、ユーザーが見つからなかった場合でも見つかった場合と比べてレスポンスタイムが早くならないようにダミーの関数を呼び出す。(悪意のある人間を欺くため)

これらを行うと、次のようになる。

web/models/user.ex
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はこのあと作成する。

web/router.ex
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つの処理を作っていく。

  1. 認証の済んだユーザーに対してトークンを発行する
  2. リクエストごとにトークンの確認をし、不正であれば弾く。
  3. ログアウトの処理を作る
web/controllers/v1/token_controller.ex
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を生成する。

web/views/v1/token_view.ex
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

また、トークンが不正だった場合のエラーハンドラーを追加する。

web/controllers/v1/guardian_error_handler.ex
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は次の通り。

web/views/v1/guardian_error_view.ex
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

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?