Elixir + Phoenix Frameworkにおけるユーザ認証 このエントリをはてなブックマークに登録

2018年11月28日

takurutakuru

初めに

こんにちは。クレイの浅海です。普段はRuby on Railsを書いていますが、最近はElixirのPhoenixも勉強しています。
なぜなら、弊社で開発、運営しているDocBaseは、主となる部分はRuby on Railsで作られていますが、同時編集に使うサーバにはPhoenixを使用しているためです。

Phoenix Frameworkとは

Phoenixは、Railsを意識して開発されているWeb Frameworkです。とはいっても、その設計思想には大きく違う部分があります。PhoenixはRailsのように暗黙的に様々なことをやってはくれません。いえ、やってはくれないというより、やりません。見えるコード以上のことをやってしまうと、明解さが損なわれるということのようです。たしかに、Railsはrailに乗りさえすればコードの量は最小になりますが、実際には何が起きているかというのは熟練者じゃないとわからないものが多いですね。

本記事の内容

さて、多くのサービス開発において、メールアドレスとパスワードを使ったユーザ認証というものが存在します。Railsで開発する場合は、Deviseやsorceryのようなgemを使ってサクっと実装しますが、Phoenixの場合はどうなるのでしょうか。
本記事ではElixirのユーザ認証関係のライブラリから、comeoninguardianという2つを組み合わせ、メールアドレスとパスワードによるログインをPhoenixで実装してみました。
comeoninはパスワードのハッシュ化を行うライブラリです。ハッシュアルゴリズムは選択可能で、今回はArgon2を使ったため 「argon2_elixir」も使用します。
guardianはログインセッション周りを担当するライブラリです。

バージョン情報

使用した言語、ライブラリのバージョン情報です。

  • Erlang/OTP 20
  # mix.exs
  defp deps do
    [
      {:phoenix, "~> 1.3.0"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_ecto, "~> 3.2"},
      {:mariaex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:guardian, "~> 1.0"},
      {:comeonin, "~> 4.0"},
      {:argon2_elixir, "~> 1.2"},
    ]
  end

アプリケーション仕様

なにはともあれ仕様を決めます。 認証に使うユーザモデルはAdminとし、各ページのURLは次のように決めました。

  • GET /
    • トップページ。ログインにかかわらず表示できる
    • ログインしている場合はその旨を表示
  • GET /admin
    • ログインしていないと表示できない画面
    • 未ログインの場合はログイン画面にリダイレクト
  • GET /admin/login
    • ログイン画面
    • ログイン済みの場合は /admins にリダイレクト
  • POST /admin/login
    • ログイン処理
    • ログイン済み、もしくはログイン成功の場合は /admins にリダイレクト
  • DELETE /admin/logout
    • ログアウトを行う
    • ログアウト後はログイン画面にリダイレクト

実装

Guardianモジュールの用意

guardianを扱うために、それ用のモジュールを用意します。
次の2つの関数はこのモジュールで実装しなければいけないと決められている関数です。

  • subject_for_token
    • ユーザを識別できる情報を返すようにします。
  • resource_from_claims
    • subject_for_tokenの情報がclaims[“sub”]で参照できるので、そこからユーザを取得します。
# lib/my_app/auth/guardian.ex
defmodule MyApp.Auth.Guardian do
  use Guardian, otp_app: :my_app
  alias MyApp.{Repo, Admin}
  def subject_for_token(resource, _claims) do
    sub = to_string(resource.id)
    {:ok, sub}
  end

  def resource_from_claims(claims) do
    id = claims["sub"]
    admin = Repo.get(Admin, id)
    {:ok, admin}
  end
end

各種パイプラインの用意

次の3つのパイプラインを用意します。

  • 認証情報を扱えるようにするパイプライン
  • ログインしていることを保証するパイプライン
  • ログインしていないことを保証するパイプライン 
# lib/my_app/auth/login_session_pipeline.ex
#認証情報を扱えるパイプライン
defmodule MyApp.Auth.LoginSessionPipeline do
  use Guardian.Plug.Pipeline, otp_app: :my_app
  plug Guardian.Plug.VerifySession, claims: %{"typ" => "access"}
  plug Guardian.Plug.LoadResource, allow_blank: true
end
# lib/my_app/auth/ensure_auth_pipeline.ex
#ログインしていることを保証するパイプライン
defmodule MyApp.Auth.EnsureAuthPipeline do
  use Guardian.Plug.Pipeline, otp_app: :my_app
  plug Guardian.Plug.EnsureAuthenticated
end
# lib/my_app/auth/ensurE_not_auth_pipeline.ex
#ログインしていないことを保証するパイプライン
defmodule MyApp.Auth.EnsureNotAuthPipeline do
  use Guardian.Plug.Pipeline, otp_app: :my_app
  plug Guardian.Plug.EnsureNotAuthenticated
end

EnsureAuthPipeline と EnsureNotAuthPipeline に関しては、それぞれエラー時の挙動を定義するErrorHandlerモジュールを用意します。
次のようになります。

# lib/my_app/auth/ensure_auth_error_handler.ex
defmodule MyApp.Auth.EnsureAuthErrorHandler do
  import Phoenix.Controller, only: [put_flash: 3, redirect: 2]
  import MyAppWeb.Router.Helpers, only: [admin_login_path: 2]

  def auth_error(conn, {type, _reason}, _opts) do
    # ログインしていなかった場合はログイン画面にリダイレクトさせる
    conn
    |> put_flash(:error, to_string(type))
    |> redirect(to: admin_login_path(conn, :new))
  end
end
# lib/my_app/auth/ensure_not_auth_error_handler.ex
defmodule MyApp.Auth.EnsureNotAuthErrorHandler do
  import Phoenix.Controller, only: [put_flash: 3, redirect: 2]
  import MyAppWeb.Router.Helpers, only: [admin_root_path: 2]

  def auth_error(conn, {type, _reason}, _opts) do
    # ログインしていた場合は管理画面トップにリダイレクトさせる
    conn
    |> put_flash(:error, to_string(type))
    |> redirect(to: admin_root_path(conn, :new))
  end
end

config.exs にて、PiepelineとGuardian、それからErrorHandlerを関連付けます。

# config/config.exs
config :my_app, MyApp.Auth.LoginSessionPipeline,
  module: MyApp.Auth.Guardian

config :my_app, MyApp.Auth.EnsureAuthPipeline,
  module: MyApp.Auth.Guardian,
  error_handler: MyApp.Auth.EnsureAuthErrorHandler

config :my_app, MyApp.Auth.EnsureNotAuthPipeline,
  module: MyApp.Auth.Guardian,
  error_handler: MyApp.Auth.EnsureNotAuthErrorHandler

シークレットキーの設定

開発環境用のシークレットキーを設定します。
ローカル環境なので適当な文字列を設定しました。

# config/dev.exs
config :my_app, MyApp.Auth.Guardian,
  verify_module: Guardian.JWT,
  issuer: "my_app",
  secret_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

ルーティングで適切なパイプラインを設定

各ページで必要なパイプラインを設定します。

  # lib/my_app_web/router.ex
  pipeline :login_session do
    plug MyApp.Auth.LoginSessionPipeline
  end

  pipeline :ensure_auth do
    plug MyApp.Auth.EnsureAuthPipeline
  end

  pipeline :ensure_not_auth do
    plug MyApp.Auth.EnsureNotAuthPipeline
  end

  scope "/admin", MyAppWeb.Admin, as: :admin do
    # ログインしている場合のみ表示できるページなので :ensure_auth を含む
    pipe_through [:browser, :login_session, :ensure_auth]
    get "/", ArticleController, :index, as: :root
    delete "/logout", SessionController, :delete, as: :logout
  end

  scope "/admin", MyAppWeb.Admin, as: :admin do
    # ログインしていない場合のみ表示できるページなので :ensure_not_auth を含む
    pipe_through [:browser, :login_session, :ensure_not_auth]
    get "/login", SessionController, :new, as: :login
    post "/login", SessionController, :create, as: :login
  end

  scope "/", MyAppWeb do
    # ログインしていればログインしているユーザ情報を扱いたいので :login_session を含む
    pipe_through [:browser, :login_session]
    resources "/", ArticleController, only: [:index]
  end

Adminモデル作成

ここからはモデルを作っていきます。
「mix ecto.gen.migration」を使ってAdminモデルのマイグレーションを作成します。
必要なカラムは、ログインに使うemailと、ハッシュ化したパスワードを格納するpassword_hashの2つです。

# priv/repo/migraitons/000_create_admins.exs
defmodule MyApp.Repo.Migrations.CreateAdmins do
  use Ecto.Migration

  def change do
    create table(:admins) do
      add :email, :string
      add :password_hash, :string
      timestamps()
    end
  end
end

次はAdminモデルのモジュールです。
passwordというカラムはDBに無いため、「virtual: true」を使用します。
put_password_hash 関数で、入力されたパスワードをArgon2によりハッシュ化にしています。

# lib/my_app/admin.ex
defmodule MyApp.Admins.Admin do
  use Ecto.Schema
  import Ecto.Changeset

  schema "admins" do
    field :email, :string
    field :password_hash, :string
    field :password, :string, virtual: true
    timestamps()
  end

  def changeset(admin, attrs) do
    admin
    |> cast(attrs, [:email, :password])
    |> validate_required([:email, :password])
    |> put_password_hash()
  end

  defp put_password_hash(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{password: pass}} ->
        # パスワードの変更があればArgon2でハッシュ化
        changeset
        |> put_change(:password_hash, Comeonin.Argon2.hashpwsalt(pass))
      _ ->
        changeset
    end
  end
end

認証モジュール用意

コントローラで使用するための認証モジュールです。
ログイン、ログアウト、パスワードチェックなどを実装します。

# lib/my_app/auth/auth.ex
defmodule MyApp.Auth do
  alias MyApp.{Repo, Admin}
  alias MyApp.Auth.Guardian

  # 入力されたメールアドレスでAdminを探して、パスワードで認証する
  def authenticate_admin(email, password) do
    Repo.get_by(Admin, email: email)
    |> check_password(password)
  end

  # adminでログイン状態にする
  def login(conn, admin) do
    conn
    |> Guardian.Plug.sign_in(admin)
    |> Guardian.Plug.remember_me(admin) # ブラウザを閉じてもクッキーからセッションを復元する
    |> Plug.Conn.assign(:current_admin, admin)
  end

  # ログアウトする
  def logout(conn) do
    conn
    |> Guardian.Plug.sign_out()
  end

  # 現在ログイン中のAdminを返す
  def load_current_admin(conn, _) do
    conn
    |> Plug.Conn.assign(:current_admin, Guardian.Plug.current_resource(conn))
  end

  defp check_password(nil, _) do
    {:error, "メールアドレスが違います"}
  end

  defp check_password(admin, password) do
    case Comeonin.Argon2.checkpw(password, admin.password_hash) do
      true -> {:ok, admin}
      false -> {:error, "パスワードが違います"}
    end
  end
end

ログイン、ログアウト用のコントローラ

ログイン、ログアウトを行うSessionControllerです。
先程実装したAuthモジュールを使用しています。

# lib/my_app_web/controllers/admin/session_controller.ex
defmodule MyAppWeb.Admin.SessionController do
  use MyAppWeb, :controller
  alias MyApp.Admin

  # ログイン画面
  def new(conn, _params) do
    conn
    |> render("new.html", changeset: Admin.changeset(%Admin{}, {})
  end

  # メールアドレスとパスワードが正しければログインする
  def create(conn, %{"admin" => %{"email" => email, "password" => password}}) do
    case MyApp.Auth.authenticate_admin(email, password) do
      {:ok, admin} ->
        conn
        |> MyApp.Auth.login(admin)
        |> put_flash(:info, "ログインしました")
        |> redirect(to: admin_root_path(conn, :index))
      {:error, reason} ->
        conn
        |> put_flash(:error, reason)
        |> render("new.html", changeset: Admin.changeset(%Admin{email: email}, {}))
    end
  end

  # ログアウトするアクション
  def delete(conn, _) do
    conn
    |> MyApp.Auth.logout()
    |> put_flash(:info, "ログアウトしました")
    |> redirect(to: admin_login_path(conn, :new))
  end
end

ログインフォーム

ログインフォームは次のようにしました。
メールアドレスとパスワードを入力できます。

lib/my_app_web/templates/admin/session/new.html.eex

<h2>ログイン</h2>
<%= form_for @changeset, admin_login_path(@conn, :create), fn f -> %>
  <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" %>
    <%= password_input f, :password, class: "form-control" %>
    <%= error_tag f, :password %>
  </div>

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

ログイン中のAdminを表示

ログイン中のAdminを表示するには、コントローラで次のPlugを指定します。

  import PhxBlog.Auth, only: [load_current_admin: 2]
  plug :load_current_admin when action in [:index] # indexアクションのみで使うため

これにより、「conn.assigns[:current_admin]」で現在ログイン中のAdminを取得することができます。

ログインのテスト

何はともあれAdminを1つ作成しなければなりません。
作成したら、サーバを起動してから http://localhost:4000/admin にアクセスしてみるとログイン画面が出るはずです。

終わりに

以上で基本的なユーザ認証が実装できるようになります。
Rails+deviseで実装するユーザ認証と比べると、Phoenix+Guardianの場合は書くことが多くなります。ただ、その分deviseと違って細かいカスタマイズがやり易いため、その点は良いと思います。

参考

https://itnext.io/user-authentication-with-guardian-for-phoenix-1-3-web-apps-e2064cac0ec1

  1. メモからはじめる情報共有 DocBase 無料トライアルを開始
  2. DocBase 資料をダウンロード

「いいね!」で応援よろしくお願いします!

このエントリーに対するコメント

コメントはまだありません。

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)


トラックバック

we use!!Ruby on RailsAmazon Web Services

このページの先頭へ