連載「Rails2Phoenix」になります、前回は「UmbrellaプロジェクトをHerokuにデプロイする 」でした。今回は前回課題としてあがった認証機能の実装を試みたいと思います。
というわけで、現在つかっているRailsをPhoenixに変更することにしました。方針は以下の通りで、今回はRails/Deviseの認証機能をPhoenixで実装する流れを取り上げます。
方針
phoenix/base
をベースにまず、参考にしたのはBlackodeのguardian_authです。ただ、Guardianのバージョンがふるいので1.0へのマイグレーション記事をもとにアレンジしてあります。認証に関係しそうな構成は下記の通り。
ロジック
コントローラ
Guardian1.0から直接ではなくモジュールを介して参照するようになりました。下記のように各モジュールを用意してコンフィグに割り当てます。
# 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
# 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
# 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で未ログインスコープとログイン済スコープの切り替えができるようになります。
# 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
# 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
# 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
# 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とあまり変わりません。他のアクション「新規パスワード発行」「メールアドレス確認」等も同様の構成をとろうと思っています。
# 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
# 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
# apps/my_app/lib/my_app/account/registration.ex
def create(changeset, repo) do
changeset
|> repo.insert()
end
ログイン・ログアウトはセッション用のサービスとコントローラで実装します。
# 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
# 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のビューヘルパーはビューマクロで適用します。
# 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
関数をはやしてコントローラマクロに適用します。
# 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
# 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
の代わりに使えます。
# 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のテンプレートに移植するには下記の変換を地道に行っていきます。
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.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 \\ %{})
前回もそうですが、コードのマイグレーションはまあ地味な作業ですよね。とまれ、認証機能を実装できたので良しとしましょう。
テクノロジーの進化は、絶え間ない変化の中で私たちの日常を塗り替えてきました。時には経済的な危機が、新たな可能性を切り拓く契機となることもあります。そこで、過去のリセッション期に生まれたテクノロジーの足
ATKerneyの課題解決パターン は、課題の本質を見極め、効果的な戦略的構造化を通じて解決策を導き出す手法にフォーカスしています。この冒険の旅は、解決者と協力者たちが心を一つにし、課題に立ち向かう様
私はいわゆる就職氷河期世代です。周囲から時折漏れ聞こえる不平のような言葉がありますが、それを単なる不平として片付けるのはもったいない気がします。できれば、その中に新しい視点を見つけ、次のチャンスへ繋げ