5
2

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によるWeb開発 - Guardianによるログイン認証の実装まで -

Posted at

はじめに

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

https://qiita.com/himrock922/items/80f33046239a2d178e7e

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

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

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

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

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

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

% mix deps.get

Guardianによる認証実装

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

  • Token type
  • Configuration
  • Encoding/Decoding
  • Callbacks
% mix phx.gen.context UserManager User users username:string password:string

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

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によるハッシュ関数は

% mix guardian.gen.secret

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

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

パスワードのハッシュ化

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

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

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

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メソッドで保存したパスワードのハッシュと入力されたハッシュ値を比較し、パスワードの正当性を検証します。

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は以下のようなコードで記述します。

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

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

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のコーディングを行っていきます。

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
lib/daily_report_web/views/session_view.ex
defmodule DailyReportWeb.SessionView do
  use DailyReportWeb, :view
end
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 %>
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
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>
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のリソース(ユーザ名)を取得できれば成功です。

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

実際にログインしてみる

ログイン画面.png

まとめ

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

参考文献

5
2
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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?