はじめに
皆様、こんばんは。今回の記事もPhonenixによるWeb開発の続きの記事を書いていきます。
Phonenixを利用してWeb開発を行うモチベーションやコンテクストについては以下の記事をご参照下さい。
https://qiita.com/himrock922/items/80f33046239a2d178e7e
今回はGuardianと呼ばれるElixir用の認証ライブラリを用いてユーザログイン・ログアウトの機能を実装します。
認証ライブラリ・Guardianについて
今回は、Guardianと言うトークンベースの認証ライブラリを使います。
https://github.com/ueberauth/guardian
Guardianは、トークンベースの認証をサポートするライブラリです。デフォルトの設定ではJson Web Token(JWT)経由で認証を行います。
一先ず、ユーザログインに関してはこのライブラリを利用することにします。
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つの機能を含めたモジュールのサンプル例は以下のようなものになります。なお、このサンプルコードは公式サイトに書かれているものを整理したものです。
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 :daily_report, DailyReport.UserManager.Guardian,
issuer: "daily_report",
secret_key: "***"
パスワードのハッシュ化
ユーザのパスワードに対してもハッシュ化できるように設定を行います。パスワードハッシュ化のライブラリとしてComeonin
、またハッシュアルゴリズムとしてArgon2
を採用することとしました。
defp deps do
[
{:argon2_elixir, "~> 2.0"},
{:comeonin, "~> 5.1.0"},
ユーザ新規作成時にパスワードのハッシュを行えるようにします。
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
メソッドで保存したパスワードのハッシュと入力されたハッシュ値を比較し、パスワードの正当性を検証します。
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は以下のようなコードで記述します。
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
ユーザ認証に失敗した際のエラーハンドリングもここで記述します。
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のコーディングを行っていきます。
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
defmodule DailyReportWeb.SessionView do
use DailyReportWeb, :view
end
<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 %>
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
<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>
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"})
実際にログインしてみる
まとめ
ということでGuardianによるユーザログイン・ログアウトが完了しました。
テストコードについてはもう少しアプリの中身が出来てからにします。
それでは。
参考文献
- https://github.com/ueberauth/guardian
- https://hexdocs.pm/guardian/introduction-overview.html#content
- https://tools.ietf.org/html/rfc7519
- 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/guardian/tutorial-start.html#content
- https://tools.ietf.org/html/rfc7518
- https://tools.ietf.org/html/rfc6234
- https://github.com/riverrun/comeonin
- https://github.com/riverrun/argon2_elixir
- https://hexdocs.pm/argon2_elixir/Argon2.html#verify_pass/2
- https://hexdocs.pm/phoenix/overview.html#content
- https://www.tutorialsteacher.com/mvc/mvc-architecture
- https://hexdocs.pm/phoenix/plug.html#content