スポンサーリンク

2015年7月17日

[Rails Tutorial for Phoenix]Sign-in and Sign-out

Goal

サインイン、サインアウト機能を実装する。

Wait a minute

セッションを使いサインイン、サインアウト機能を実装していきます。
また、シンプルな認証(Authentication)についても実装してみましょう。

Index

Preparation

作業前にブランチを切ります。
>cd path/to/sample_app
>git checkout -b sign_in_out

Create SessionController

サインインしている状態、していない状態を保存しておくために、
セッションを扱うためのコントローラを作成します。
セッションの使い方の説明は、後に行います。
まずはルーティングを追加していきます。
セッション用のルーティングを追加します。

File: web/router.ex

scope "/", SampleApp do
  ...
  get "/signin", SessionController, :new
  post "/session", SessionController, :create
  delete "/signout", SessionController, :delete
end
追加したルーティングは、それぞれ以下のようになっています。
  • new (/signin): サインインするための情報を入力するフォーム画面
  • create (/session): サインインの認証を行い、セッションに情報を格納するアクション
  • delete (/signout): サインアウトを行うアクション
追加されたルーティングを確認しましょう。

Example:

>mix phoenix.routes
...
session_path  GET     /signin         SampleApp.SessionController.new/2
session_path  POST    /session        SampleApp.SessionController.create/2
session_path  DELETE  /signout        SampleApp.SessionController.delete/2
コントローラを作成します。
Sessionコントローラを以下の通り、作成します。

File: web/controllers/session_controller.ex

defmodule SampleApp.SessionController do
  use SampleApp.Web, :controller

  def new(conn, _params) do
    render conn, "signin_form.html"
  end

  def create(conn, _params) do
    redirect(conn, to: static_pages_path(conn, :home))
  end

  def delete(conn, _params) do
    redirect(conn, to: static_pages_path(conn, :home))
  end
end
続いて、ビューと仮のテンプレートも作成します。

File: web/views/session_view.ex

defmodule SampleApp.SessionView do
  use SampleApp.Web, :view
end
Sessionのテンプレートを格納するディレクトリを作成します。
sessionと言うディレクトリを作成して下さい。

Directory: web/templates/session

サインインに必要な情報を入力するテンプレートを作成します。
入力フォームの部分はまだ作成しません。

File: web/templates/session/signin_form.html.eex

<div class="jumbotron">
  <h2>Sign in!!</h2>
</div>
Sessionコントローラにセッションやサインイン / サインアウトの処理を追加していきます。

Authentication

サインインとは切っても切り離せない認証を作成していきます。
ここで作成する認証処理は、ライブラリなどを利用しません。
実際はライブラリなどを使って、安全性の高い認証を行うべきでしょうが、
ここでは、DBのパスワードの値と入力されたパスワードが一致するか否かを
判定するだけの非常にシンプルな処理を作成します。
それでは、作成していきましょう。
まず作成しなければいけないのは、EmailでDBからデータ取得する部分です。
UserモデルへEmailからユーザ情報の取得を行う関数を作成します。

File: web/models/user.ex

defmodule SampleApp.User do
  ...

  def find_user_from_email(email) do
    SampleApp.Repo.get_by(SampleApp.User, email: email)
  end
end
認証を扱うモジュールを作成します。

File: lib/authentication.ex

defmodule SampleApp.Authentication do
  def authentication(user, password) do
    case user do
      nil -> false
        _ ->
          password == SampleApp.Encryption.decrypt(user.password_digest)
    end
  end
end

Sign-in

サインインを扱うモジュールも作成してしまいましょう。

File: lib/sign_in.ex

defmodule SampleApp.Signin do
  import SampleApp.Authentication

  def sign_in(email, password) do
    user = SampleApp.User.find_user_from_email(email)
    case authentication(user, password) do
      true -> {:ok, user}
         _ -> :error
    end
  end
end
先ほど、作成した認証のモジュールを使用しています。
認証に成功すればサインインができます。
Sessionコントローラのcreateアクションを以下のようにサインイン処理を行うように変更しましょう。

File: web/controllers/session_controller.ex

defmodule SampleApp.SessionController do
  ...

  def create(conn, %{"signin_params" => %{"email" => email, "password" => password}}) do
    case sign_in(email, password) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User sign-in is success!!")
        |> redirect(to: static_pages_path(conn, :home))
      :error ->
        conn
        |> put_flash(:error, "User sign-in is failed!! email or password is incorrect.")
        |> redirect(to: session_path(conn, :new))
    end
  end

  ...
end

Sign-in Form

サインインを行うための入力フォームを作成します。
サインインのフォームは以下のようになります。

File: web/templates/session/signin_form.html.eex

<h1>Sign in!!</h1>

<%= form_for @conn, session_path(@conn, :create), [as: :signin_params], fn f -> %>
  <%= if f.errors != [] do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below:</p>
      <ul>
        <%= for {attr, message} <- f.errors do %>
          <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <label>Email</label>
    <%= email_input f, :email, class: "form-control" %>
  </div>

  <div class="form-group">
    <label>Password</label>
    <%= password_input f, :password, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= submit "Sign-in!", class: "btn btn-primary" %>
  </div>
<% end %>
どこかで見たことあるような内容だと思いませんか?
そう、サインアップの入力フォームと似ていますね。
フォームを使う時は、このような形になることが多いと思います。
是非、覚えておいて下さい。
レイアウトヘッダにあるサインインのリンクを修正します。

File: web/templates/layout/header.html.eex

<header class="navbar navbar-inverse">
  <div class="navbar-inner">
    <div class="container">
      <a class="logo" href="<%= page_path(@conn, :index) %>"></a>
      <nav>
        <ul class="nav nav-pills pull-right">
          <li><%= link "Home", to: static_pages_path(@conn, :home) %></li>
          <li><%= link "Help", to: static_pages_path(@conn, :help) %></li>
          <li><%= link "Sign-in", to: session_path(@conn, :new) %></li>
        </ul>
      </nav>
    </div> <!-- container -->
  </div> <!-- navbar-inner -->
</header>

How do session?

Phoenix-Frameworkでは、特に設定を行わなくてもセッションを使うことができます。
しかし、どこで設定しているか知るために、セッションの設定をしているソースコードを見てみます。
各クッキーに署名するために、secret_key_baseの値を利用しています。

File: config/config.exs

config :sample_app, SampleApp.Endpoint,
  url: [host: "localhost"],
  root: Path.dirname(__DIR__),
  secret_key_base: "****",
  render_errors: [accepts: ~w(html json)],
  pubsub: [name: SampleApp.PubSub,
           adapter: Phoenix.PubSub.PG2]
Endpointでデフォルトのセッションを設定しています。

File: lib/sample_app/endpoint.ex

defmodule SampleApp.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample_app

  ...

  plug Plug.Session,
    store: :cookie,
    key: "_sample_app_key",
    signing_salt: "abcwc8CM"

  ...
end
見ての通り、Phoenix-Frameworkでのセッションは、
Plug.Sessionを利用しています。
セッションを使った簡単な例。

Example:

defmodule SampleApp.PageController do
  use Phoenix.Controller

  def index(conn, _params) do
    conn = put_session(conn, :message, "hoge")
    message = get_session(conn, :message)

    text conn, message
  end
end
セッションへ値を出し入れする関数。
  • put_session/2: セッションへ値を格納する。
  • get_session/2: セッションの値を取り出す。

Use session

Sessionコントローラへセッションの処理を追加します。
先ほど作成した、サインインモジュールのインポートを追加します。
また、セッションに値を格納するための処理を追加します。

File: web/controllers/session_controller.ex

defmodule SampleApp.SessionController do
  use SampleApp.Web, :controller

  import SampleApp.Signin

  ...

  def create(conn, %{"signin_params" => %{"email" => email, "password" => password}}) do
    case sign_in(email, password) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User sign-in is success!!")
        |> put_session(:user_id, user.id)
        |> redirect(to: static_pages_path(conn, :home))
      :error ->
        conn
        |> put_flash(:error, "User sign-in is failed!! email or password is incorrect.")
        |> redirect(to: session_path(conn, :new))
    end
  end

  ...
end
追加はput_session/2の一行だけですが、これでセッションに値を格納できます。
ユーザを識別するためのIDを格納しています。
user_idと言うキー名で、ユーザIDを格納しています。

Continuation of Sign-in state

今のままではサインイン後、別のページに移動するとサインインした状態が継続されません。
サインインを継続させるために状態維持の機能を実装します。
どうやって実現するかですが、自作のプラグを作成して各コントローラで実行するようにします。
プラグを指定しておけば、そのコントローラでアクションが動作する前に動作してくれます。
また、特定のアクションにのみプラグが動作するような設定もできます。
まずは、プラグのファイルを格納するためのディレクトリを作成しましょう。
plugsと言う名前でディレクトリを作成して下さい。

Directory: lib/plugs

認証されているかを確認するためのプラグ(モジュール)を作成します。

File: lib/plugs/check_authentication.ex

defmodule SampleApp.Plugs.CheckAuthentication do
  import Plug.Conn

  def init(options) do
    options
  end

  def call(conn, _) do
    user_id = get_session(conn, :user_id)
    if session_present?(user_id) do
      assign(conn, :current_user, SampleApp.Repo.get(SampleApp.User, user_id))
    else
      conn
    end
  end

  defp session_present?(user_id) do
    case user_id do
      nil -> false
      _   -> true
    end
  end
end
セッションからユーザIDを取得し、IDが存在すればConnのassignにユーザデータを格納しています。
このユーザIDがセッションに存在するか否かでサインインの状態を判断しています。
また後にやりますが、現在のサインインしているユーザを取得する際にも利用します。
このプラグは全コントローラで利用を考えているプラグになります。
なので、web.exのcontroller/0関数へプラグを追加します。

File: web/web.ex

def controller do
  quote do
    ...

    plug SampleApp.Plugs.CheckAuthentication
  end
end
これで全てのコントローラで作成したプラグが動作します。

Note:

セッションに格納しているデータですが、
ここではユーザIDを生のまま格納しています。

分かりやすくするために生のまま格納していますが、
本来であれば暗号化された別の値を格納すべきです。

ユーザID(ただの番号)を格納していることが分かってしまえば、
Cookieの値を改ざんして、別のユーザでログインしているように成りすますことができてしまいます。

公開するWebサイトを運営するのであれば、
セッションに格納する値は別の暗号化されて値を格納するようにしましょう。
(公開して後悔しないために...)

Current user

サインインしている現在のユーザを取得してデバッグ表示に追加しましょう。
ビューをサポートするためのヘルパーモジュールを作成します。
先ほど、Connのassignに値を格納していたと思う。
その値をここで取り出して利用する。

File: lib/helpers/view_helper.ex

defmodule SampleApp.Helpers.ViewHelper do
  def current_user(conn) do
    conn.assigns[:current_user]
  end
end
上記のヘルパーモジュールを全てのビューで利用できるように、
web.exのview/0関数へimportを追加します。

File: web/web.ex

def view do
  quote do
    ...

    import SampleApp.Helpers.ViewHelper
  end
end
デバッグ用のテンプレートへユーザ名とIDの表示を追加する。

File: web/templates/layout/debug.html.eex

<div class="debug_dump">
  <p>Controller: <%= get_controller_name @conn %></p>
  <p>Action: <%= get_action_name @conn %></p>
  <%= if current_user(@conn) do %>
    <p>User (ID): <%= current_user(@conn).name %> (<%= current_user(@conn).id %>)</p>
  <% end %>
</div>
現在のユーザが存在するか否かで処理を切り替えている。
これにより、サインインしていない状態だとユーザは表示されない。
サインインした状態としていない状態で表示されるテンプレートの内容を切り替えます。
サインインをしたのに、サインインのボタンやリンクが表示されているのはおかしいですからね。
皆さん予想が付いている気がしますが、if記述を使って処理を分けます。

Example:

<%= if current_user(@conn) do %>
  ログインしている時の処理...
<% else %>
  ログインしていない時の処理...
<% end %>
少し動的な表示を行ってみます。
bootstrapのドロップダウンを使います。
それでは実装しましょう!

File: web/templates/layout/header.html.eex

<header class="navbar navbar-inverse">
  <div class="navbar-inner">
    <div class="container">
      <a class="logo" href="<%= page_path(@conn, :index) %>"></a>
      <nav>
        <ul class="nav nav-pills pull-right">
          <li><%= link "Home", to: static_pages_path(@conn, :home) %></li>
        <%= if current_user(@conn) do %>
          <li class="dropdown">
            <!-- Dropdown Menu -->
            <a href="#" class="dropdown-toggle" id="account" data-toggle="dropdown">
              User Menu
              <span class="caret"></span>
            </a>
            <!-- Dropdown List -->
            <ul class="dropdown-menu" aria-labelledby="account">
              <li><%= link "Profile", to: user_path(@conn, :show, current_user(@conn)) %><li>
              <li><%= link "Help", to: static_pages_path(@conn, :help) %></li>
              <li class="divider"></li>
              <li class="dropdown-delete-li"><%= link "Sign-out", to: session_path(@conn, :delete), method: :delete, class: "dropdown-delete-link" %></li>
            </ul>
          </li>
        <% else %>
          <li><%= link "Sign-in", to: session_path(@conn, :new) %></li>
        <% end %>
      </ul>
      </nav>
    </div> <!-- container -->
  </div> <!-- navbar-inner -->
</header>

File: priv/static/css/custom.css

/* dropdown delete method link */
.dropdown-delete-link {
  color: #000000;
  margin-left: 20px;
}
.dropdown-delete-li {
  color: #000000;
}
.dropdown-delete-li:hover{
  background-color: #f5f5f5;
}

Cution:

Windowsで実施されている方へ。
linkタグのdeleteメソッドを動作させるには少し修正が必要です。
Linux、Macではこの問題は発生しません。
但し、ドロップダウンのデザインは崩れると思いますのでCSSは適応して下さい。
brunch-config.jsでapp.jsを読み込んでいるのですが、
Windowsだとパスの指定方法が少し異なるようです。
そのため、以下のように修正して下さい。

File: brunch-config.js

modules: {
  autoRequire: {
    "js\\app.js": ["web/static/js/app"]
  }
},
この問題は2015/10/31に確認したのが最後です。
今後のアップグレードで修正されている可能性があります。

Sign-out

ようやっとサインイン機能と対になる、サインアウト機能を実装します。
サインインほど、難しい処理はしません。
Sessionコントローラのdeleteアクションを以下のように変更します。

File: web/controllers/session_controller.ex

defmodule SampleApp.SessionController do
  ...

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "Sign-out now! See you again!!")
    |> delete_session(:user_id)
    |> redirect(to: static_pages_path(conn, :home))
  end
end
サインアウトの旨を知らせるメッセージの表示。
それと、セッションの削除を行っています。
サインアウトを行うまで、サインインの状態は維持されます。

After registration, sign-in

サインアップ後、サインイン処理を行うようにサインアップ時の処理を修正します。
Userコントローラのcreateアクションを以下のように修正して下さい。

File: web/controllers/user_controller.ex

defmodule SampleApp.UserController do
  ...

  def create(conn, %{"user" => user_params}) do
    changeset = SampleApp.User.changeset(%SampleApp.User{}, user_params)

    if changeset.valid? do
      case Repo.insert(changeset) do
        {:ok, result_user} ->
          conn
          |> put_flash(:info, "User registration successfully!!")
          |> put_session(:user_id, result_user.id)
          |> redirect(to: static_pages_path(conn, :home))
        {:error, result} ->
          render(conn, "new.html", changeset: result)
      end
    else
      render(conn, "new.html", changeset: changeset)
    end
  end
end
Repo.insert/2の戻り値は、{:ok, model}か{:error, changeset}です。
なので、データの挿入が成功時はサインインの処理を行い、失敗時は再度入力を促すようにしています。
サインアップでユーザ登録を行えば、
成功後にセッションへ値を格納し、サインイン状態になります。

Before the end

ソースコードをマージします。

Example:

>git add .
>git commit -am "Finish sign_in_out."
>git checkout master
>git merge sign_in_out

Speaking to oneself

お疲れ様でした。これで第八章は終わりです。
サインイン、サインアウトの実装はどうでしたか?
色々なことをやったので少し苦労したかもしれません。
次の章は、このチュートリアルにおける最初の山場です。
ページネーションや認可、残りのユーザの処理を実装します。
大変でしょうけど、Webサイトには必須の内容なので一緒に頑張りましょう!!

Bibliography

人気の投稿