Phoenix で作成したアプリケーションに、権利のあるユーザにのみアクセスを許可したいばあいに Guardian という Elixir のパッケージを利用して実現する方法の覚書です。
基盤にしている JWT などの技術に今のところ明るくないこともあり、ここでは手順だけ記述します。
ドキュメントにも手順が書かれているのですが、記述が別れていたり微妙にわかりにくい部分があっりしたので自分で試したものを書き下してみました。
認証機能を実装したい Phoenix app を用意します。
ここでは my_app という名で作成し、以降もこの名前で説明をしてゆきます。
$ mix phx.new my_app
アカウントを管理する仕組みを用意する
ユーザがアプリケーションにログインすることを想定して、認証されるアカウントを管理する仕組みを用意します。
実際にはユーザ情報をデータベースに格納するところですが、この記事では簡単に特定のユーザ名とパスワードの組を受け付ける仕組みで代用します。
lib/my_app/accounts/user.ex
ユーザデータを格納する構造体です。
defmodule MyApp.Accounts.User do
defstruct [:username, :password]
end
lib/my_app/accounts.ex
ユーザ名とパスワードの組で認証するモジュールです。
実装はユーザ名、パスワードとも固定にしています。
defmodule MyApp.Accounts do
alias MyApp.Accounts.User
@username "foobar"
@password "FooBar"
@doc """
usename をキーにユーザを取得する
ユーザが存在する場合は、ユーザデータを返します。
ユーザが存在しない場合は、 `nil` を返します。
## Example
iex> MyApp.Accounts.get_user_by_username("foobar")
%MyApp.Accounts.User{username: "foobar", password: "FooBar"}
iex> MyApp.Accounts.get_user_by_username("hoge")
nil
"""
def get_user_by_username(username) do
case username do
@username -> %User{username: @username, password: @password}
_ -> nil
end
end
@doc """
ユーザ名とパスワードで認証する
認証に成功した場合は、 `:ok` とユーザデータのタプルを返します。
認証に失敗した場合は、 `:error` と失敗理由のタプルを返します。
## Example
iex> MyApp.Accounts.authenticate_user("foobar", "FooBar")
{:ok, %MyApp.Accounts.User{username: "foobar", password: "FooBar"}}
iex> MyApp.Accounts.authenticate_user("hoge", "Hoge")
{:error, :unauthorized}
"""
def authenticate_user(username, password) do
case {username, password} do
{@username, @password} -> {:ok, %User{username: @username, password: @password}}
_ -> {:error, :unauthorized}
end
end
end
依存パッケージに Guardian を追加する
mix.exs ファイルを編集して deps/0
に記述される依存パッケージに Guardian を追加します。
mix hex.info
コマンドで確認すると、この記事を書いた時点の最新バージョンは 2.0 でしたので、mix.exs でもこのバージョンを指定することにします。
$ mix hex.info guardian
Elixir Authentication framework
Config: {:guardian, "~> 2.0"}
Releases: 2.0.0, 1.2.1, 1.2.0 (retired), 1.1.1, 1.1.0, 1.0.1, 1.0.0, 1.0.0-beta.1, ...
Licenses: MIT
Links:
Github: https://github.com/ueberauth/guardian
mix.exs を編集して {:guardian, "~> 2.0"}
の記述を追加します。
--- a/mix.exs
+++ b/mix.exs
@@ -38,7 +38,8 @@ defmodule MyApp.MixProject do
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
- {:plug_cowboy, "~> 2.0"}
+ {:plug_cowboy, "~> 2.0"},
+ {:guardian, "~> 2.0"}
]
end
end
パッケージを取得します。
$ mix deps.get
Guardian モジュールを記述する
Guardian パッケージを利用したモジュールを 3 つ定義します。
パッケージのドキュメントにあるサンプルでは lib/my_app/
にモジュールを定義しているのですが、認証は Web サービスの領分と思うのでここでは lib/my_app_web/
に定義することにしました(この点、ご意見いただけるとさいわいです)。
lib/my_app_web/auth/
├── error_handler.ex
├── guardian.ex
└── pipeline.ex
lib/my_app_web/auth/guardian.ex
Guardian の実装です。
ここで resource はユーザデータに当たり、token はユーザ名 (username) に当たります。
defmodule MyAppWeb.Auth.Guardian do
use Guardian, otp_app: :my_app
alias MyApp.Accounts
def subject_for_token(resouce, ) do
{:ok, resouce.username}
end
def resource_from_claims(%{"sub" => username}) do
case Accounts.get_user_by_username(username) do
nil -> {:error, :resource_not_found}
user -> {:ok, user}
end
end
end
lib/my_app_web/auth/error_handler.ex
認証に失敗したときに実行されるエラーハンドラです。 Guardian.Plug.ErrorHander
を実装します。
defmodule MyAppWeb.Auth.ErrorHandler do
@behaviour Guardian.Plug.ErrorHandler
import Plug.Conn
def auth_error(conn, {type, }, ) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(401, to_string(type))
end
end
lib/my_app_web/auth/pipeline.ex
上で実装したモジュールを使ってパイプラインを定義します。
defmodule MyAppWeb.Auth.Pipeline do
use Guardian.Plug.Pipeline,
otp_app: :my_app,
error_handler: MyAppWeb.Auth.ErrorHandler,
module: MyAppWeb.Auth.Guardian
plug Guardian.Plug.VerifySession
plug Guardian.Plug.VerifyHeader
plug Guardian.Plug.LoadResource, allow_blank: true
end
router にパイプラインを追加する
lib/my_app_web/router.ex
を編集してパイプラインを追加します。
pipeline :auth do
plug MyAppWeb.Auth.Pipeline
end
pipeline :ensure_auth do
plug Guardian.Plug.EnsureAuthenticated
end
セッションを管理する仕組みを用意する
ユーザ名とパスワードを入力するフォームと、入力された情報をハンドリングするコントローラを実装します。
lib/my_app_web/templates/session/new.html.eex
<%= form_for :user, Routes.session_path(@conn, :create), fn f -> %>
<%= label f, :username %>
<%= text_input f, :username %>
<%= label f, :password %>
<%= password_input f, :password %>
<%= submit "login" %>
<% end %>
lib/my_app_web/views/session_view.ex
defmodule MyAppWeb.SessionView do
use MyAppWeb, :view
end
lib/my_app_web/controllers/session_controller.ex
コントローラでは、入力されたユーザ名とパスワードを MyApp.Accounts.authenticate_user/2
で認証にかけます。
成功したばあい、Guardian.Plug.sign_in/5
でユーザのトークンをセッションに記憶します。
ユーザ情報からのトークンの抽出は、先に定義したモジュール MyAppWeb.Auth.Guardian
の関数が利用されます。
トークンはログアウトの際の Guardian.Plug.sign_out/3
でセッションから削除されます。
defmodule MyAppWeb.SessionController do
use MyAppWeb, :controller
def new(conn, ) do
conn
|> render("new.html")
end
def create(conn, %{"user" => %{"username" => username, "password" => password}}) do
case MyApp.Accounts.authenticate_user(username, password) do
{:ok, user} ->
conn
|> put_flash(:info, "Welcome back!")
|> Guardian.Plug.sign_in(MyAppWeb.Auth.Guardian, user)
|> redirect(to: Routes.page_path(conn, :index))
{:error, reason} ->
conn
|> put_flash(:error, to_string(reason))
|> new(%{})
end
end
def destroy(conn, ) do
conn
|> Guardian.Plug.sign_out(MyAppWeb.Auth.Guardian, [])
|> redirect(to: Routes.session_path(conn, :new))
end
end
routing を設定する
ルーティングに先ほど定義したパイプラインを設定していきます。
アプリケーションを作成した時点ではルーティングはこのようになっています。
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :index
end
これを次のように変更します。
scope "/", MyAppWeb do
pipe_through [:browser, :auth]
get "/login", SessionController, :new
post "/session", SessionController, :create
end
scope "/", MyAppWeb do
pipe_through [:browser, :auth, :ensure_auth]
get "/", PageController, :index
delete "/session", SessionController, :destroy
end
これでパイプラインの :ensure_auth
を利用するスコープは認証がすんでいないとアクセスすることができなくなりました。
config を設定する
Guardian の設定として、アプリケーションと秘密鍵を config で設定します。
ここでは秘密鍵を環境変数から取得するようにしています。
config :my_app, MyAppWeb.Auth.Guardian,
issuer: "my_app",
secret_key: System.get_env("GUARDIAN_SECRET_KEY")
mix guardian.gen.secret
を利用して秘密鍵を生成し、環境変数に設定します。
$ export GUARDIAN_SECRET_KEY=`mix guardian.gen.secret`
護られていることを確認する
アプリケーションを起動します。
$ mix phx.server
この状態で http://localhost:4000 にアクセスすると、認証がすんでいないので unauthenticated と表示されます。
次にログインしてみます。
http://localhost:4000/login にアクセスします。
フォームが表示されるので用意しておいたユーザ名とパスワード foobar
と FooBar
を入力します。
入力が正しければ先ほど拒否されていた http://localhost:4000 にリダイレクトされ Welcome back!
の文字が表示されるはずです。
ログアウトする
ログアウト機能を追加します。
lib/my_app_web/templates/layout/app.html.eex
lib/my_app_web/templates/layout/app.html.eex
を開き、Get started
と記述されている行の下にログアウトのリンクを作成します。
Guardinan.Plug.curent_resource/2
関数でログイン時のリソースが取得できます。
具体的には今回はユーザのレコードをリソースとしているのでこの関数でユーザのレコードを得ることができます。
これを利用してログインしているばあいだけログアウトのリンクを表示するようにします。
<%= if Guardian.Plug.current_resource(@conn) do %>
<li><%= link "logout", to: Routes.session_path(@conn, :destroy), method: :delete %></li>
<% end %>
ログインを促す
認証前に認証が必要なページにアクセスしたばあい今は unauthenticated と表示するだけですが、これをログインページにリダイレクトするようにします。
Guardian.Plug.ErrorHandler.auth_error/3
を次のように変更します。
フラッシュにメッセージを設定する関数 put_flash/3
とリダイレクトの関数 redirect/2
の二つを利用していますが、扱いやすくするため定義されているモジュール Phoenix.Controller
を import して利用しています。
defmodule MyAppWeb.Auth.ErrorHandler do
@behaviour Guardian.Plug.ErrorHandler
import Plug.Conn
import Phoenix.Controller, only: [put_flash: 3, redirect: 2]
alias MyAppWeb.Router.Helpers, as: Routes
def auth_error(conn, {type, }, ) do
conn
|> put_flash(:error, "unauthorized")
|> redirect(to: Routes.session_path(conn, :new))
end
end
これでログインしてない状態で http://localhost:4000 にアクセスしたとき、ログインページにリダイレクトされるようになりました。
いつか読むはずっと読まない:習慣の集まり