初めに
こんにちは。クレイの浅海です。普段は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のユーザ認証関係のライブラリから、comeoninとguardianという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
このエントリーに対するコメント
日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)
- トラックバック
「いいね!」で応援よろしくお願いします!