Phoenix (Elixir) + Guardian + Comeonin で簡単なログインを実装してみた

# インストール

今回はPhoenixのバージョンは`1.2.4`

## Elixirのインストール

```
brew install elixir
```

## Phoenixのインストール

```
mix local.hex
mix archive.install https://github.com/phoenixframework/archives/raw/master/phoenix_new.ez
```

# サンプルプロジェクトの新規作成とDB作成

## プロジェクト作成

```
mix phoenix.new my_app
cd my_app
mix ecto.create
```

## UserリソースのScaffold

今回は[comeonin](https://github.com/riverrun/comeonin)を使ってパスワードをハッシュ化し、ユーザが入力したパスワードとの比較を簡単にできるように`User`モデルのハッシュ化したパスワードを保存するカラム名は
`password_hash`か`encrypted_password`にするのをオススメします！

```
mix phoenix.gen.html User users email:string encrypted_password:string
mix ecto.migrate
```

```web/router.ex
scope "/", MyApp do
  pipe_through :browser 
  # ...
  resources "/users", UserController
end
```

# 関連モジュールのインストールと設定

```mix.exs
defp deps do
  [
    # ...
    {:comeonin, "~> 4.0"},
    {:bcrypt_elixir, "~> 0.12.0"},
    {:guardian, "~> 0.14"}
  ]
end
```

## 関連モジュールのインストール

```
mix deps.get
```

## Guardianの設定

公式の[ドキュメント](https://github.com/ueberauth/guardian)の設定通りやったのですが、例にある`config.exs`ではうまく動かなかったので、注意してください！

内部的に[JOSE](https://github.com/potatosalad/erlang-jose)を使っているのでそれ向けにconfigをかかたないとだめでした。なので、[公式に書かれている内容](https://github.com/ueberauth/guardian#secret-key)で`secret key`の設定をしました。

### Secret Keyの生成

```
iex -S mix phoenix.server
```

```
iex(2)> JOSE.JWS.generate_key(%{"alg" => "HS512"}) |> JOSE.JWK.to_map |> elem(1) |> Map.take(["k", "kty"])
```

```config/dev.exs
config :guardian, Guardian,
  allowed_algos: ["HS512"], # optional
  verify_module: Guardian.JWT,  # optional
  issuer: "MyApp",
  ttl: { 30, :days },
  allowed_drift: 2000,
  verify_issuer: true, # optional
  secret_key: %{
    "k" => "SET HERE",
    "kty" => "oct"
  },
  serializer: MyApp.GuardianSerializer
```

### Serializerの実装

```lib/my_app/guardian_serializer.ex
defmodule MyApp.GuardianSerializer 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モデルの実装

ユーザを新規作成する時にパスワードをハッシュ化する必要があるので、その実装です。

```web/model/user.ex
defmodule MyApp.User do
  use MyApp.Web, :model

  schema "users" do
    field :email, :string
    field :encrypted_password, :string

    field :password, :string, virtual: true

    timestamps()
  end

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

  defp encrypted_password(password) do
    if password do
      Comeonin.Bcrypt.hashpwsalt(password)
    end
  end
end
```

## ユーザのフォームの修正

フォームがencrypted_passwordになっているのでpasswordに変更

```web/templates/user/form.html.eex
<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <div class="form-group">
    <%= label f, :email, class: "control-label" %>
    <%= text_input f, :email, class: "form-control" %>
    <%= error_tag f, :email %>
  </div>

  <div class="form-group">
    <%= label f, :password, class: "control-label" %>
    <%= text_input f, :password, class: "form-control" %>
    <%= error_tag f, :password %>
  </div>

  <div class="form-group">
    <%= submit "Submit", class: "btn btn-primary" %>
  </div>
<% end %>
```

## 画面からユーザを新規作成

```
mix phoenix.server
```

http://localhost:4000/users/new にアクセスしメールアドレスとパスワードを入力する

# ログイン・ログアウト

## ログイン画面の実装

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

  schema "users" do
    field :email, :string
    field :encrypted_password, :string

    field :password, :string, virtual: true

    timestamps()
  end

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

  defp encrypted_password(password) do
    if password do
      Comeonin.Bcrypt.hashpwsalt(password)
    end
  end
end
```

```web/models/auth.ex
defmodule MyApp.Auth do
  alias MyApp.Repo
  alias MyApp.User

  def valid?(changeset) do
    Repo.get_by(User, email: String.downcase(changeset.changes.email))
    |> Comeonin.Bcrypt.check_pass(changeset.changes.password)
  end
end
```

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

  alias MyApp.User
  alias MyApp.Auth

  def new(conn, _params) do
    changeset = User.changeset(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)
    case Auth.valid?(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "Log In successfully.")
        |> Guardian.Plug.sign_in(user)
        |> redirect(to: user_path(conn, :index))
      {:error, _} ->
        conn
        |> put_flash(:error, "Invalid password or email")
        |> render("new.html", changeset: changeset)
    end
  end

  def delete(conn, %{}) do
    conn
    |> Guardian.Plug.sign_out
    |> put_flash(:info, "Logged out")
    |> redirect(to: login_path(conn, :new))
  end
end
```

```web/router.ex
defmodule MyApp.Router do
  use MyApp.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 :browser_session do
    plug Guardian.Plug.VerifySession # looks in the session for the token
    plug Guardian.Plug.LoadResource
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", MyApp do
    pipe_through [:browser, :browser_session] # Use the default browser stack

    get "/", PageController, :index

    get "/login", SessionController, :new, as: :login
    post "/login", SessionController, :create, as: :login
    delete "/logout", SessionController, :delete, as: :logout

    resources "/users", UserController
  end

  # Other scopes may use custom stacks.
  # scope "/api", MyApp do
  #   pipe_through :api
  # end
end
```

```web/templates/session/new.html.eex
<h2>Sign In</h2>

<%= render "form.html", changeset: @changeset,
                        action: login_path(@conn, :create) %>
```

```web/templates/session/form.html.eex
<%= form_for @changeset, @action, fn f -> %>
  <%= if @changeset.action do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below.</p>
    </div>
  <% end %>

  <div class="form-group">
    <%= label f, :email, class: "control-label" %>
    <%= text_input f, :email, class: "form-control" %>
    <%= error_tag f, :email %>
  </div>

  <div class="form-group">
    <%= label f, :password, class: "control-label" %>
    <%= text_input f, :password, class: "form-control" %>
    <%= error_tag f, :password %>
  </div>

  <div class="form-group">
    <%= submit "Login", class: "btn btn-primary" %>
  </div>
<% end %>
```

```web/views/session_view.ex
defmodule MyApp.SessionView do
  use MyApp.Web, :view
end
```

## ログイン

http://localhost:4000/login にアクセスし先程登録したメールアドレスとパスワードを入力するとログインが成功し、間違ったメールアドレスとパスワードのペアだとエラーになります。

## ログインが必要なページの実装

UserControllerへはログインが必要になるように実装します。

```web/controllers/user_controller.ex
defmodule MyApp.UserController do
  use MyApp.Web, :controller

  use Guardian.Phoenix.Controller

  alias MyApp.User

  plug Guardian.Plug.EnsureAuthenticated, handler: __MODULE__, typ: "access"

  def index(conn, _params, _user, _claims) do
    users = Repo.all(User)
    render(conn, "index.html", users: users)
  end

  def new(conn, _params, _user, _claims) do
    changeset = User.changeset(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}, _user, _claims) do
    changeset = User.changeset(%User{}, user_params)

    case Repo.insert(changeset) do
      {:ok, _user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: user_path(conn, :index))
      {:error, changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

  def show(conn, %{"id" => id}, _user, _claims) do
    user = Repo.get!(User, id)
    render(conn, "show.html", user: user)
  end

  def edit(conn, %{"id" => id}, _user, _claims) do
    user = Repo.get!(User, id)
    changeset = User.changeset(user)
    render(conn, "edit.html", user: user, changeset: changeset)
  end

  def update(conn, %{"id" => id, "user" => user_params}, _user, _claims) do
    user = Repo.get!(User, id)
    changeset = User.changeset(user, user_params)

    case Repo.update(changeset) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User updated successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, changeset} ->
        render(conn, "edit.html", user: user, changeset: changeset)
    end
  end

  def delete(conn, %{"id" => id}, _user, _claims) do
    user = Repo.get!(User, id)

    # Here we use delete! (with a bang) because we expect
    # it to always work (and if it does not, it will raise).
    Repo.delete!(user)

    conn
    |> put_flash(:info, "User deleted successfully.")
    |> redirect(to: user_path(conn, :index))
  end

  def unauthenticated(conn, _params) do
    conn
    |> put_flash(:error, "Authentication required")
    |> redirect(to: login_path(conn, :new))
  end
end
```

## ログアウト

任意の場所に以下のログアウトボタンを記述すればログアウトができるようになります。

```
<%= link "Logout", to: logout_path(@conn, :delete), method: :delete, data: [confirm: "Are you sure?"], class: "btn btn-danger btn-xs" %>
```

# まとめ

ドキュメントや関連するサンプルのソースを眺めてもはまるところがあったので、簡単ですがまとめてみました。

ElixrやPhoenixはまだまた初心者なので、不備があればツッコミお待ちしています！！

# 参考にしたページ

- https://github.com/riverrun/comeonin
- https://github.com/ueberauth/guardian
- https://github.com/hassox/phoenix_guardian
- https://hexdocs.pm/comeonin/Comeonin.Bcrypt.html
- https://elixirschool.com/en/lessons/libraries/guardian/#where-to-use-them

# Elixir / Phoenixを基礎から学ぶ！
<a href="https://www.amazon.co.jp/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0Elixir-%EF%BC%A4%EF%BD%81%EF%BD%96%EF%BD%85%EF%BC%B4%EF%BD%88%EF%BD%8F%EF%BD%8D%EF%BD%81%EF%BD%93-ebook/dp/B01KFCXP04/ref=as_li_ss_il?_encoding=UTF8&psc=1&refRID=ZX4J5YX19SFANPT7XEKN&linkCode=li2&tag=qiita-rneuo-22&linkId=2301aa0441dc686ecefd90d4908bf651" target="_blank"><img border="0" src="//ws-fe.amazon-adsystem.com/widgets/q?_encoding=UTF8&ASIN=B01KFCXP04&Format=_SL160_&ID=AsinImage&MarketPlace=JP&ServiceVersion=20070822&WS=1&tag=qiita-rneuo-22" ></a><img src="https://ir-jp.amazon-adsystem.com/e/ir?t=qiita-rneuo-22&l=li2&o=9&a=B01KFCXP04" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />

<a href="https://www.amazon.co.jp/Elixir-Phoenix-%E5%88%9D%E7%B4%9A%E2%91%A0-%E3%81%AF%E3%81%98%E3%82%81%E3%81%AE%E4%B8%80%E6%AD%A9-BOOKS-ebook/dp/B01N2K6UBZ/ref=as_li_ss_il?ie=UTF8&qid=1503243680&sr=8-2&keywords=phoenix+elixir&linkCode=li2&tag=qiita-rneuo-22&linkId=71d46ac942eb332a2fe80e218d20fba6" target="_blank"><img border="0" src="//ws-fe.amazon-adsystem.com/widgets/q?_encoding=UTF8&ASIN=B01N2K6UBZ&Format=_SL160_&ID=AsinImage&MarketPlace=JP&ServiceVersion=20070822&WS=1&tag=qiita-rneuo-22" ></a><img src="https://ir-jp.amazon-adsystem.com/e/ir?t=qiita-rneuo-22&l=li2&o=9&a=B01N2K6UBZ" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />

<a href="https://www.amazon.co.jp/dp/B06X9PL5WD/ref=as_li_ss_il?ie=UTF8&qid=1503243680&sr=8-4&keywords=phoenix+elixir&linkCode=li2&tag=qiita-rneuo-22&linkId=6a12bcb2d8fbc7b15e22aee2c3d6ccf8" target="_blank"><img border="0" src="//ws-fe.amazon-adsystem.com/widgets/q?_encoding=UTF8&ASIN=B06X9PL5WD&Format=_SL160_&ID=AsinImage&MarketPlace=JP&ServiceVersion=20070822&WS=1&tag=qiita-rneuo-22" ></a><img src="https://ir-jp.amazon-adsystem.com/e/ir?t=qiita-rneuo-22&l=li2&o=9&a=B06X9PL5WD" width="1" height="1" border="0" alt="" style="border:none !important; margin:0px !important;" />