PhoenixでAPIによるログイン機能を実装する


[Comeonin 3.0](https://github.com/riverrun/comeonin)と[Guardian 0.14.0](https://github.com/ueberauth/guardian)を使ってログイン機能を作成する。

Comeoninはパスワードのハッシュ化を行い、Guardianは[JWT Token](https://tools.ietf.org/html/rfc7519)を生成する。

## インストールと設定

次の依存関係を追加し、``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モデル

``email``、``encrypted_password``のカラムを持つUserモデルを生成する。

```
mix phoenix.gen.model User users email encrypted_password
```

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

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

* Repoへのaliasを作る。
* ``password_plain``と``terms_confirmed``の２つの擬似フィールドを追加する。
* changeset/2関数ではバリデーションを行い、``cs_encrypt_password``を呼び出し、encrypted_passwordを生成、セットする。
* ``cs_encrypt_password``ではバリデーションを全て追加していればパスワードのハッシュ化を行う。
* ログインと新規作成を行うための、``sign_in``、``sign_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では、次の３つの処理を作っていく。

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](https://github.com/hassox/guardian_db)を使っていると上記のコードでバックエンド側でTokenを使えないようにすることができる。（現時点で最新のGuardian 3.0とGuardian Db 0.7の組み合わせだと[バグ](https://github.com/ueberauth/guardian/issues/242)があり、うまく動作しない。）

ログイン中のユーザーの取得は``user = Guardian.Plug.current_resource(conn)``で行うことができる。あとは適宜、認証が必要な関数に対して``Guardian.Plug.EnsureAuthenticated``をしていく。

## 参考URL

* [github.com/riverrun/comeonin](https://github.com/riverrun/comeonin)
* [github.com/ueberauth/guardian](https://github.com/ueberauth/guardian)
* [Phoenix + React: love story. RePh 1.](https://medium.com/@chvanikoff/phoenix-react-love-story-reph-1-c68512cfe18)
* [Phoenix + React: love story. RePh 2.](https://medium.com/@chvanikoff/phoenix-react-love-story-reph-2-14a6dcadbbd0)
* [Simple Guardian - Browser login](http://blog.overstuffedgorilla.com/simple-guardian/)
* [Simple Guardian - API authentication](http://blog.overstuffedgorilla.com/simple-guardian-api-authentication/)
* [Simple Guardian - Permissions](http://blog.overstuffedgorilla.com/simple-guardian-permissions/)
* [Guard this with your life... Or authenticating APIs with Guardian](http://hostiledeveloper.com/2016/05/23/guard-this-with-your-life-or-authenticating-apis-with-guardian.html)
* [GETTING STARTED WITH GUARDIAN](http://hassox.github.io/elixir/guardian/2015/06/19/guardian-getting-started.html)
* [API AUTHENTICATION WITH GUARDIAN](http://hassox.github.io/elixir/guardian/2015/06/30/api-authentication-with-guardian.html)