Phoenix で作成したアプリケーションに、権利のあるユーザにのみアクセスを許可したいばあいに Guardian という Elixir のパッケージを利用して実現する方法の覚書です。
基盤にしている JWT などの技術に今のところ明るくないこともあり、ここでは手順だけ記述します。
- guardian - Elixir Authentication framework | Hex
- ueberauth/guardian: Elixir Authentication | GitHub
- Guardian | Hexdocs
ドキュメントにも手順が書かれているのですが、記述が別れていたり微妙にわかりにくい部分があっりしたので自分で試したものを書き下してみました。
Phoenix app を用意する
認証機能を実装したい 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, _claims) 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, _reason}, _opts) 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, _reason}, _opts) do conn |> put_flash(:error, "unauthorized") |> redirect(to: Routes.session_path(conn, :new)) end end
これでログインしてない状態で http://localhost:4000 にアクセスしたとき、ログインページにリダイレクトされるようになりました。
いつか読むはずっと読まない:習慣の集まり
- 作者:チャールズ デュヒッグ
- 出版社/メーカー: 早川書房
- 発売日: 2019/07/04
- メディア: 文庫