エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

Phoenixの認証をGuardianに護らせる

Phoenix で作成したアプリケーションに、権利のあるユーザにのみアクセスを許可したいばあいに Guardian という Elixir のパッケージを利用して実現する方法の覚書です。

基盤にしている JWT などの技術に今のところ明るくないこともあり、ここでは手順だけ記述します。

ドキュメントにも手順が書かれているのですが、記述が別れていたり微妙にわかりにくい部分があっりしたので自分で試したものを書き下してみました。

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 にアクセスします。 フォームが表示されるので用意しておいたユーザ名とパスワード foobarFooBar を入力します。 入力が正しければ先ほど拒否されていた 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 にアクセスしたとき、ログインページにリダイレクトされるようになりました。

いつか読むはずっと読まない:習慣の集まり

メリットの法則 行動分析学・実践編 (集英社新書)

メリットの法則 行動分析学・実践編 (集英社新書)