PhoenixによるWeb開発 - Guardianによるログイン認証の実装まで -

## はじめに

皆様、こんばんは。今回の記事もPhonenixによるWeb開発の続きの記事を書いていきます。
Phonenixを利用してWeb開発を行うモチベーションやコンテクストについては以下の記事をご参照下さい。

[https://qiita.com/himrock922/items/80f33046239a2d178e7e](https://qiita.com/himrock922/items/80f33046239a2d178e7e)

今回はGuardianと呼ばれるElixir用の認証ライブラリを用いてユーザログイン・ログアウトの機能を実装します。

## 認証ライブラリ・Guardianについて

今回は、Guardianと言うトークンベースの認証ライブラリを使います。

https://github.com/ueberauth/guardian
Guardianは、トークンベースの認証をサポートするライブラリです。デフォルトの設定ではJson Web Token(JWT)経由で認証を行います。
一先ず、ユーザログインに関してはこのライブラリを利用することにします。

```elixir:mix.exs
  defp deps do
    [
      {:guardian, "~> 2.0"}
    ]
  end
```

上記のdefpはプライベート関数を定義し、更にその配下のdepsはElixirの依存関係を纏めたライブラリのリストを記述します。ここに記述したライブラリは`deps.get`でインストールすることができます。

```zsh
% mix deps.get
```

### Guardianによる認証実装

Guardianで実際にユーザ認証を行うためにはモジュールが必要となります。そのモジュールには以下の4つの機能が抽象化されたものを実装します。

* Token type
* Configuration
* Encoding/Decoding
* Callbacks

```zsh
% mix phx.gen.context UserManager User users username:string password:string
```

Phoenixアプリケーション上に上記4つの機能を含めたモジュールのサンプル例は以下のようなものになります。なお、このサンプルコードは公式サイトに書かれているものを整理したものです。

```elixir:lib/daily_report/user_manager/guardian.ex
defmodule DailyReport.UserManager.Guardian do
  use Guardian, otp_app: :daily_report

  alias DailyReport.UserManager

  def subject_for_token(user, _claims) do
    {:ok, to_string(user.id)}
  end

  def resource_from_claims(%{"sub" => id}) do
    case UserManager.get_user!(id) do
      nil -> {:error, :resource_not_found}
      user -> {:ok, user}
    end
  end
end
```

`subject_for_token`メソッドは、トークン内のリソースをエンコードするために利用し、
`resource_from_claims`メソッドはクレーム(トークン内の任意の情報)からリソースを取り出すために利用します。

### Guardianの設定

JWTを利用するためには、事前に秘密鍵の設定が必要となります。ここでは、SHA512(512ビットのハッシュ関数を生成できる)アルゴリズムで生成したハッシュ関数(HS512)を利用します。

SHA512によるハッシュ関数は

```zsh
% mix guardian.gen.secret
```

で生成することができます。

```zsh:config/dev.exs
config :daily_report, DailyReport.UserManager.Guardian,
       issuer: "daily_report",
       secret_key: "***"
```

### パスワードのハッシュ化

ユーザのパスワードに対してもハッシュ化できるように設定を行います。パスワードハッシュ化のライブラリとして`Comeonin`、またハッシュアルゴリズムとして`Argon2`を採用することとしました。

```elixir:mix.es
  defp deps do
    [
      {:argon2_elixir, "~> 2.0"},
      {:comeonin, "~> 5.1.0"},
```

ユーザ新規作成時にパスワードのハッシュを行えるようにします。

```elixir:lib/daily_report/user_manager/user.ex
  alias Argon2
  alias DailyReport.UserManager.User

  def changeset(%User{} = user, attrs) do
    user
    |> cast(attrs, [:username, :password])
    |> validate_required([:username, :password])
    |> put_password_hash()
  end

  defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
    change(changeset, password: Argon2.hash_pwd_salt(password))
  end

  defp put_password_hash(changeset), do: changeset
```

ユーザログイン時に、`vertify_pass`メソッドで保存したパスワードのハッシュと入力されたハッシュ値を比較し、パスワードの正当性を検証します。

```elixir:lib/daily_report/user_manager.ex
defmodule DailyReport.UserManager.UserManager do
  import Ecto.Query, only: [from: 2]
  alias Argon2

  def authenticate_user(username, plain_text_password) do
    query = from u in User, where: u.username == ^username
    case Repo.one(query) do
      nil ->
        Argon2.no_user_verify()
        {:error, :invalid_credentials}
      user ->
        if Argon2.verify_pass(plain_text_password, user.password) do
          {:ok, user}
        else
          {:error, :invalid_credentials}
        end
    end
  end
end
```

## PhonenixのRouters, Plugについて

ここで一旦、Phonenixの設定の一部`Router`について記述します。`Router`は、リソースに対応するURLやパスを生成する機能です。Routesの書き方には色々ありますが、その内、`Pipelines`については、複数のPlugをグルーピングしたものとして取り扱うことができます。Routersは内部でPlugとやり取りしています。
自作のPipelineは以下のようなコードで記述します。

```elixir:lib/daily_report/user_manager/pipeline.ex
defmodule DailyReport.UserManager.Pipeline do
  use Guardian.Plug.Pipeline,
      otp_app: :daily_report,
      error_handler: DailyReport.UserManager.ErrorHandler,
      module: DailyReport.UserManager.Guardian

  # If there is a session token, restrict it to an access token and validate it
  plug Guardian.Plug.VerifySession, claims: %{"typ" => "access"}
  # If there is an authorization header, restrict it to an access token and validate it
  plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
  # Load the user if either of the verifications worked
  plug Guardian.Plug.LoadResource, allow_blank: true
end
```

ユーザ認証に失敗した際のエラーハンドリングもここで記述します。

```elixir:lib/daily_report/user_manager/error_handler.ex
defmodule DailyReport.UserManager.ErrorHandler do
  import Plug.Conn

  @behaviour Guardian.Plug.ErrorHandler

  @impl Guardian.Plug.ErrorHandler
  def auth_error(conn, {type, _reason}, _opts) do
    body = to_string(type)
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(401, body)
  end
end
```

## ログイン・ログアウト関係のController,View,Routesのコーディング

モジュールの実装が完了した後は、認証に関係するController,View,Routesのコーディングを行っていきます。

```elixir:lib/daily_report_web/session_controller.ex
defmodule DailyReportWeb.SessionController do
  use DailyReportWeb, :controller

  alias DailyReport.{UserManager, UserManager.User, UserManager.Guardian}

  def new(conn, _) do
    changeset = UserManager.change_user(%User{})
    maybe_user = Guardian.Plug.current_resource(conn)
    if maybe_user do
      redirect(conn, to: "/protected")
    else
      render(conn, "new.html", changeset: changeset, action: Routes.session_path(conn, :login))
    end
  end

  def login(conn, %{"user" => %{"username" => username, "password" => password}}) do
    UserManager.authenticate_user(username, password)
    |> login_reply(conn)
  end

  def logout(conn, _) do
    conn
    |> Guardian.Plug.sign_out()
    |> redirect(to: "/login")
  end

  defp login_reply({:ok, user}, conn) do
    conn
    |> put_flash(:info, "Welcome back!")
    |> Guardian.Plug.sign_in(user)
    |> redirect(to: "/protected")
  end

  defp login_reply({:error, reason}, conn) do
    conn
    |> put_flash(:error, to_string(reason))
    |> new(%{})
  end
end
```

```elixir:lib/daily_report_web/views/session_view.ex
defmodule DailyReportWeb.SessionView do
  use DailyReportWeb, :view
end
```

```elixir:lib/daily_report_web/templates/session/new.html.eex
<h2>Login Page</h2>

<%= form_for @changeset, @action, fn f -> %>

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

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

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

```elixir:lib/daily_report_web/controllers/page_controller.ex

defmodule DailyReportWeb.PageController do
  use DailyReportWeb, :controller

  def protected(conn, _) do
    user = Guardian.Plug.current_resource(conn)
    render(conn, "protected.html", current_user: user)
  end
end
```

```elixir:lib/daily_report_web/templates/page/protected.html.eex
<h2>Protected Page</h2>
<p>You can only see this page if you are logged in</p>
<p>You're logged in as <%= @current_user.username %></p>
```

```elixir:router.ex
defmodule DailyReportWeb.Router do
  use DailyReportWeb, :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"]
  end

  pipeline :auth do
    plug DailyReport.UserManager.Pipeline
  end

  pipeline :ensure_auth do
    plug Guardian.Plug.EnsureAuthenticated
  end

  # Maybe logged in routes
  scope "/", DailyReportWeb do
    pipe_through [:browser, :auth]

    get "/", PageController, :index

    get "/login", SessionController, :new
    post "/login", SessionController, :login
    get "/logout", SessionController, :logout
  end

  scope "/", DailyReportWeb do
    pipe_through [:browser, :auth, :ensure_auth]
    get "/protected", PageController, :protected
  end
end
```

## テストユーザの作成

以上で一通りのコーディングが完了したので、テストユーザを作成し、ログイン・ログアウトを試みます。
ログイン成功時にリダイレクトされるprotected.htmlでcurrent_userのリソース(ユーザ名)を取得できれば成功です。

```bash
% mix ecto.migrate
% iex -S mix
% DailyReport.UserManager.create_user(%{username: "me", password: "secret"})
```

## 実際にログインしてみる

![ログイン画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144991/4fbe4fd7-9d1e-8d8f-24cc-259d5f9d786a.png)

## まとめ

ということでGuardianによるユーザログイン・ログアウトが完了しました。
テストコードについてはもう少しアプリの中身が出来てからにします。
それでは。

## 参考文献

* [https://github.com/ueberauth/guardian](https://github.com/ueberauth/guardian)
* [https://hexdocs.pm/guardian/introduction-overview.html#content](https://hexdocs.pm/guardian/introduction-overview.html#content)
* [https://tools.ietf.org/html/rfc7519](https://tools.ietf.org/html/rfc7519)
* [https://stackoverflow.com/questions/35735762/whats-the-difference-between-def-and-defp-in-the-phoenix-framework](https://stackoverflow.com/questions/35735762/whats-the-difference-between-def-and-defp-in-the-phoenix-framework)
* [https://hexdocs.pm/mix/Mix.Tasks.Deps.html](https://hexdocs.pm/mix/Mix.Tasks.Deps.html)
* [https://hexdocs.pm/guardian/tutorial-start.html#content](https://hexdocs.pm/guardian/tutorial-start.html#content)
* [https://tools.ietf.org/html/rfc7518](https://tools.ietf.org/html/rfc7518)
* [https://tools.ietf.org/html/rfc6234](https://tools.ietf.org/html/rfc6234)
* [https://github.com/riverrun/comeonin](https://github.com/riverrun/comeonin)
* [https://github.com/riverrun/argon2_elixir](https://github.com/riverrun/argon2_elixir)
* [https://hexdocs.pm/argon2_elixir/Argon2.html#verify_pass/2](https://hexdocs.pm/argon2_elixir/Argon2.html#verify_pass/2)
* [https://hexdocs.pm/phoenix/overview.html#content](https://hexdocs.pm/phoenix/overview.html#content)
* [https://www.tutorialsteacher.com/mvc/mvc-architecture](https://www.tutorialsteacher.com/mvc/mvc-architecture)
* [https://hexdocs.pm/phoenix/plug.html#content](https://hexdocs.pm/phoenix/plug.html#content)