On Blahfe

連載 Rails2Phoenix 2 認証機能を実装する

blog/backendphoenix-frameworkelixirruby-on-railsrubywerckerherokuauthenticationguardian
thumbnail

連載「Rails2Phoenix」になります、前回は「UmbrellaプロジェクトをHerokuにデプロイする 」でした。今回は前回課題としてあがった認証機能の実装を試みたいと思います。

PROBLEM

  • サービスについて
    • 拡張にともない技術スタックがふえるのを抑えたい
    • スケーラビリティのためのコストを抑えたい
    • パフォーマンスをあげたい

SOLUTION

というわけで、現在つかっているRailsをPhoenixに変更することにしました。方針は以下の通りで、今回はRails/Deviseの認証機能をPhoenixで実装する流れを取り上げます。

方針

  • Railsから徐々にPhoenixに移行できるように
    • いままでとおなじPaaS(Heroku)
    • いままでとおなじレポジトリ
      • ブランチ戦略は phoenix/base をベースに
      • 気軽に参照できるようにRails関連ファイルは可能な限りのこしておく
    • いままでとおなじDB
      • 移行完了までDBマイグレーションをしない
  • Phoenixは今後の拡張性をかんがえてUmbrellaプロジェクトで

Guardianを実装する

まず、参考にしたのはBlackodeのguardian_authです。ただ、Guardianのバージョンがふるいので1.0へのマイグレーション記事をもとにアレンジしてあります。認証に関係しそうな構成は下記の通り。

ロジック

  • MyApp.Account
  • MyApp.Account.Registration
  • MyApp.Account.User
  • MyApp.Auth.Guardian
  • MyApp.Auth.ErrorHandler
  • MyApp.Auth.Pipeline
  • MyApp.Auth.AfterPipeline
  • MyApp.Auth.Session

コントローラ

  • MyAppWeb.RegistrationController
  • MyAppWeb.SessionController

シリアライザとエラーハンドラの設定

Guardian1.0から直接ではなくモジュールを介して参照するようになりました。下記のように各モジュールを用意してコンフィグに割り当てます。

elixir
# apps/my_app/lib/my_app/auth/guardian.ex

defmodule MyApp.Auth.Guardian do
  use Guardian, otp_app: :my_app
  alias MyApp.Account

  def subject_for_token(resource, _claims), do: {:ok, to_string(resource.id)}
  def subject_for_token(_, _), do: {:error, :reason_for_error}

  def resource_from_claims(claims), do: {:ok, Account.get_user!(claims["sub"])}
  def resource_from_claims(_claims), do: {:error, :reason_for_error}
end
elixir
# apps/my_app/lib/my_app/auth/error_handler.ex

defmodule MyApp.Auth.ErrorHandler do
  import Plug.Conn

  def auth_error(conn, {type, _reason}, _opts) do
    body = Poison.encode!(%{message: to_string(type)})
    send_resp(conn, 401, body)
  end
end
elixir
# apps/my_app/config/config.exs

config :my_app, MyApp.Auth.Guardian,
  issuer: "MyApp",
  ttl: {30, :days},
  allowed_drift: 2000,
  # optionals
  allowed_algos: ["HS512"],
  verify_module: MyApp.Auth.Guardian.JWT,
  verify_issuer: true,
  secret_key:
    System.get_env("GUARDIAN_SECRET") ||
      "secret_key"

ルーターの設定

認証のパイプラインは、認証中と認証後のものを用意しコンフィグとルーターに割り当てます。

ルータースコープ内のパイプラインくみあわせについて、ここでは未ログインスコープには認証前・認証中パイプライン、ログイン済スコープには認証前・認証中・認証後パイプラインを適用しています。こうすることでどのスコープにも認証リソースをロードすることができ、かつ、認証も担保することができるようになります。具体的にいうと、ルート / などの同一URLで未ログインスコープとログイン済スコープの切り替えができるようになります。

elixir
# apps/my_app/lib/my_app/auth/pipeline.ex

defmodule MyApp.Auth.Pipeline do
  use Guardian.Plug.Pipeline, otp_app: :my_app

  plug(Guardian.Plug.VerifySession, claims: %{"typ" => "access"})
  plug(Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"})
  plug(Guardian.Plug.LoadResource, allow_blank: true)
end
elixir
# apps/my_app/lib/my_app/auth/after_pipeline.ex

defmodule MyApp.Auth.AfterPipeline do
  use Guardian.Plug.Pipeline, otp_app: :my_app

  plug(Guardian.Plug.EnsureAuthenticated)
end
elixir
# apps/my_app/lib/my_app_web/router.ex

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug(:accepts, ["html"])
    plug(:fetch_session)
    plug(:fetch_flash)
    plug(:protect_from_forgery)
    plug(:put_secure_browser_headers)
  end

  pipeline :browser_auth do
    plug(MyApp.Auth.Pipeline)
  end

  pipeline :browser_auth_after do
    plug(MyApp.Auth.AfterPipeline)
  end

  scope "/", MyAppWeb do
    pipe_through([:browser, :browser_auth])

    post("/registration", RegistrationController, :create)
    get("/login", SessionController, :new)
    post("/login", SessionController, :create)
    get("/logout", SessionController, :delete)
  end

  scope "/", MyAppWeb do
    pipe_through([:browser, :browser_auth, :browser_auth_after])

    get("/edit", RegistrationController, :edit)
    put("/edit", RegistrationController, :update)
    get("/users", UserController, :index)
    resources "/", UserController, only: [:show, :delete], param: "username"
  end
end
elixir
# apps/my_app/config/config.exs

config :MyApp, MyApp.Auth.Pipeline,
  module: MyApp.Auth.Guardian,
  error_handler: MyApp.Auth.ErrorHandler

config :MyApp, MyApp.Auth.AferPipeline,
  module: MyApp.Auth.Guardian,
  error_handler: MyApp.Auth.ErrorHandler

登録

登録は登録用のロジック(ユーザーモデルと登録サービス)とコントローラを用意します。

このあたりはDevise/Railsとあまり変わりません。他のアクション「新規パスワード発行」「メールアドレス確認」等も同様の構成をとろうと思っています。

elixir
# apps/my_app/lib/my_app_web/controller/registration_controller.ex

def create(conn, user_params) do
  changeset = User.registration_changeset(%User{}, user_params)

  case Registration.create(changeset, Repo) do
    {:ok, user} ->
      conn
      |> MyApp.Auth.login(user)
      |> put_flash(:info, "Your account was created successfully")
      |> redirect(to: page_path(conn, :home))

   {:error, changeset} ->
      conn
      |> put_flash(:error, "Unable to create account: Try again")
      |> render(MyAppWeb.PageView, "home.html", changeset: changeset)
  end
end
elixir
# apps/my_app/lib/my_app/auth/auth.ex

def login(conn, %User{} = user) do
  conn
  |> Guardian.Plug.sign_in(user)
  |> assign(:current_user, user)
end
elixir
# apps/my_app/lib/my_app/account/registration.ex

def create(changeset, repo) do
  changeset
  |> repo.insert()
end

ログイン・ログアウト

ログイン・ログアウトはセッション用のサービスとコントローラで実装します。

elixir
# apps/my_app/lib/my_app_web/controller/session_controller.ex

@doc "Logged in [POST /login]"
def create(conn, %{"email" => email, "password" => password}) do
  case Session.authenticate_user(email, password) do
    {:ok, user} ->
      conn
      |> Session.login(user)
      |> put_flash(:info, "Logged in successfully")
      |> redirect(to: page_path(conn, :home))

    {:error, _reason} ->
      conn
      |> put_flash(:error, "Wrong username/password")
      |> render("new.html")
  end
end

@doc "Logged out [DELETE /logout]"
def delete(conn, _params) do
  conn
  |> Session.logout()
  |> put_flash(:info, "Logged out successfully.")
  |> redirect(to: "/")
end
elixir
# apps/my_app/lib/my_app/auth/session.ex

defmodule MyApp.Auth.Session do
  import Ecto.Query
  import Plug.Conn
  import Comeonin.Bcrypt, only: [checkpw: 2, dummy_checkpw: 0]
  alias MyApp.Repo
  alias MyApp.Auth.Guardian
  alias MyApp.Account.User

  def login(conn, %User{} = user) do
    conn
    |> Guardian.Plug.sign_in(user)
    |> assign(:current_user, user)
  end

  def logout(conn), do: Guardian.Plug.sign_out(conn)

  def authenticate_user(email, given_password) do
    query = Ecto.Query.from(u in User, where: u.email == ^email)

    Repo.one(query)
    |> check_password(given_password)
  end

  def current_user(conn), do: Guardian.Plug.current_resource(conn, [])

  def logged_in?(conn), do: Guardian.Plug.authenticated?(conn, [])

  defp check_password(nil, _), do: {:error, "Incorrect username or password"}

  defp check_password(user, given_password) do
    case Comeonin.Bcrypt.checkpw(given_password, user.encrypted_password) do
      true -> {:ok, user}
      false -> {:error, "Incorrect email or password"}
    end
  end
end

Devise/Railsのビューヘルパーはビューマクロで適用します。

elixir
# apps/my_app/lib/my_app_web.ex

def view do
  quote do
    # ..
    import Okuribi.Auth.Session, only: [current_user: 1, logged_in?: 1]
  end
end

あるいは、put_assigns関数をはやしてコントローラマクロに適用します。

elixir
# apps/my_app/lib/my_app/auth/session.ex

def put_assigns(%{private: %{phoenix_action: action}} = conn, settings) do
  current_resource = Guardian.Plug.current_resource(conn)

  settings =
    if current_resource,
      do: settings[:sign_in][action] || [],
      else: settings[:sign_out][action] || []

  conn
  |> assign(:current_user, current_resource)
  |> assign(:page_title, settings[:page_title])
  |> assign(:page_description, settings[:page_description])
end
elixir
# apps/my_app/lib/my_app_web.ex

def controller do
  quote do
    # ..
    import Okuribi.Auth, only: [put_assigns: 2]
  end
end

assignsひとつでアクセスできるので、下記のようにコントローラでまとめて指定することでRailsのActionView::Helpers::CaptureHelper#provideの代わりに使えます。

elixir
# apps/my_app/lib/my_app_web/controller/*_controller.ex

@page %{
  sign_in: %{
    new: %{
      page_title: dgettext("views", "pages.home.signed_in.page_title"),
      page_description: ""
    }
  },
  sign_out: %{
    new: %{
      page_title: dgettext("views", "pages.home.signed_out.page_title"),
      page_description: ""
    }
  }
}
plug(:put_assigns, @page when action in [:home])

その他

RailsのビューをPhoenixのテンプレートに移植するには下記の変換を地道に行っていきます。

  • Rails
    • ActionView::Helpers::FormHelper#form_for(record, options={}, &block)
    • ActionView::Helpers::FormHelper#text_field(object_name, method, options={})
    • ActionView::Helpers::FormHelper#file_field(object_name, method, options={})
    • ActionView::Helpers::FormHelper#hidden_field(object_name, method, options={})
    • ActionView::Helpers::FormHelper#password_field(object_name, method, options={})
    • ActionView::Helpers::FormHelper#radio_button(object_name, method, tag_value, options={})
    • ActionView::Helpers::FormBuilder#submit(value=nil, options={})
    • ActionView::Helpers::TranslationHelper#t
  • Phoenix
    • Phoenix.HTML.Form.form_for(form_data, action, options \\ [], fun)
    • Phoenix.HTML.Form.text_input(form, field, opts \\ [])
    • Phoenix.HTML.Form.file_input(form, field, opts \\ [])
    • Phoenix.HTML.Form.hidden_input(form, field, opts \\ [])
    • Phoenix.HTML.Form.password_input(form, field, opts \\ [])
    • Phoenix.HTML.Form.radio_button(form, field, value, opts \\ [])
    • Phoenix.HTML.Form.submit(opts, opts \\ [])
    • Gettext.dgettext(backend, domain, msgid, bindings \\ %{})

WRAPUP

前回もそうですが、コードのマイグレーションはまあ地味な作業ですよね。とまれ、認証機能を実装できたので良しとしましょう。

nabinno
Emacsianでアート好き、ランニング好きな@nabinnoが書いています
GitHub / X / LinkedIn / ネクイノ
blog/market

今後の成長分野:新たなテクノロジーの展望

テクノロジーの進化は、絶え間ない変化の中で私たちの日常を塗り替えてきました。時には経済的な危機が、新たな可能性を切り拓く契機となることもあります。そこで、過去のリセッション期に生まれたテクノロジーの足

market-trendrecession
blog/organization

ATKerneyの課題解決パターンの魅力的な探求

ATKerneyの課題解決パターン は、課題の本質を見極め、効果的な戦略的構造化を通じて解決策を導き出す手法にフォーカスしています。この冒険の旅は、解決者と協力者たちが心を一つにし、課題に立ち向かう様

problem-solvingatkerney
blog/market

就職氷河期とは何だったのか

私はいわゆる就職氷河期世代です。周囲から時折漏れ聞こえる不平のような言葉がありますが、それを単なる不平として片付けるのはもったいない気がします。できれば、その中に新しい視点を見つけ、次のチャンスへ繋げ

labor-economicsrecessionemployment-ice-age