0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rails Tutorial 8章 自分なりまとめ&解釈

Posted at

RAILS TUTORIAL 8章まとめ

8章ではログインやログアウトの仕組みを実装していきます。
8章でのログインの仕組みはブラウザを閉じたらログイン状態を破棄する仕組みです。

8.1

  • HTTPはそのリクエスト1つ1つが独立している。
  • ユーザー情報を保持するには別途持続する接続が必要である。
  • sessionは半永続的な接続である。

sessionをRailsのsessionメソッドを使い作成していく。

$ rails g controller Sessions new

sessionメソッドを使用するログイン処理はsessionsコントローラに用いる。

今回は無駄なビューを生成しないためにnewアクションのみ生成している。
また、

config/routes.rb
resources :users

のようにresourcesメソッドを用いてRESTfulなルーティングをフルセットで利用する必要はないので、手動でルーティングを設定していく。

config/routes.rb
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'

newアクションを通じてテンプレートが描画されているかをテストします。

test/controllers/session_controller_test.rb
require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest

  test "should get new" do
    get login_path
    assert_response :success
  end
end

テストを書くときに名前付きルーティングなどのルーティングを確認したいときに、

$ rails routes

コマンドを利用しましょう。

8.1.2 ログインフォーム

ログインフォームで必要なフィールドは
[Email],[Password]の2つです。
ユーザー登録ビューでパラメータを受け取るために
form_forヘルパーを使います。

form_for(:session,url:login_path)

ここではsessionにモデルクラスが存在しないので、手動でリソースとurlを指定しています。
ここで躓いた方はこちらを参考にしてみてください。
Rails_tutorial 8章で出てきたform_forについて

app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

生成されたHTMLフォームは以下

<form accept-charset="UTF-8" action="/login" method="post">
  <input name="utf8" type="hidden" value="&#x2713;" />
  <input name="authenticity_token" type="hidden"
         value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />
  <label for="session_email">Email</label>
  <input class="form-control" id="session_email"
         name="session[email]" type="text" />
  <label for="session_password">Password</label>
  <input id="session_password" name="session[password]"
         type="password" />
  <input class="btn btn-primary" name="commit" type="submit"
       value="Log in" />
</form>

name属性でsession[email],session[password]となっており、
params[:session][:email]とparams[:session][:password]のようにそれぞれの情報を取り出せる。

8.1.3 ユーザーの検索と認証

ここで行う内容は以下です

  • 入力が無効な場合の処理
  • ログイン失敗時のエラーメッセージの配置
  • ログインが送信された時のパスワードとメールアドレスの組み合わせが有効かどうかの判定。

入力が無効な場合の処理及び、ログアウトアクション作成
createアクションにpostしたときにログインに失敗にした場合は
ログイン画面を再描画するrenderメソッドを用いる。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new'
  end

  def destroy
  end
end

また、form_forヘルパーで送信された時、
paramsには :sessionにネストしたユーザーの認証に必要な情報が含まれており、createアクションの中で取り出せるようになっている。

params
=>{session:{email:'user@example.com',password:'foobar'}}
params[:session]
=>{email:'user@example.com',password:'foobar'}
params[:session][:email]
=>'user@example.com'
params[:session][:password]
=>'foobar'

これらの情報を認証に必要なメソッドを用いて認証していく。
必要なメソッドは

  • Active RecordのUser.find_byメソッド
  • has_secure_passwordのauthenticateメソッド

順序としては

  1. sessionハッシュのemailをUserモデルから検索する。
  2. 検索されたuserが存在しかつパスワードを照合する。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      # エラーメッセージを作成する
      render 'new'
    end
  end

  def destroy
  end
end

上ではまずemailをdowncaseですべて小文字にした状態で検索しています。
また、authenticateメソッドを用いて引数で受け取ったパスワードをハッシュ化して、データベースのハッシュ化したパスワードと照合しています。

8.1.4 フラッシュメッセージを表示する

ユーザーの新規登録の時は、Active Recordオブジェクトのエラーメッセージをerrors.full_messagesで表現できましたが、
sessionにはそのような関連付けがないので、
フラッシュメッセージを表示します。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      flash[:danger] = 'Invalid email/password combination' # 本当は正しくない
      render 'new'
    end
  end

  def destroy
  end
end

この場合、フラッシュを表示した後、レンダリングをしているが、画面にはフラッシュが表示され続けている。
そのためこのバグをテストでキャッチして、
RED→GREENにする。

統合テストを作成する。

$ rails g integration_test users_login

やることは

  1. ログイン用のパスを開く
  2. 新しいセッションのフォームが正しく表示されたことを確認する
  3. わざと無効なparamsハッシュを使ってセッション用パスにPOSTする
  4. 新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認する
  5. 別のページ (Homeページなど) にいったん移動する
  6. 移動先のページでフラッシュメッセージが表示されていないことを確認する

[RED]

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  test "login with invalid information" do
    get login_path
    assert_template 'sessions/new'
    post login_path, params: { session: { email: "", password: "" } }
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
end

これをGREENにするために、flashをflash.nowにして再びリクエストが来たときに、消滅するようにする。

[GREEN]

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      # ユーザーログイン後にユーザー情報のページにリダイレクトする
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

テストする
[GREEN]

$ rails test test/integration/users_login_test.rb
$ rails test

8.2ログイン

無効な送信を正しく処理できるので、有効な値の送信を正しく扱っていく。

まずはメソッドをまとめてパッケージ化するためにセッション用ヘルパーモジュールをapplicationコントローラーに読み込み、すべてのコントローラーで利用できるようにする。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

ログインのためのメソッドを作る。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
end

session[:user_id]は値を入れるときはcookiesに暗号化済みのIDを作成する。
また、取り出すときは、元通りに取り出すことができる。

これをsessions_controllerのログイン成功時のアクションに導入する。

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

redirect_userはredirect_to user_url(user)
の略である。

ログインしているユーザーの情報を取得するヘルパーを導入する。

def current_user
  if session[:user_id]
    @current_user||= User.find_by(id:session[:user_id])
  end
end

これにより
@current_userはいつでも使うことができ、
@current_userが存在する場合はなんどもデータベースからユーザー情報を取得する必要がなくなり、データベースへの負荷を抑えることができる。

8.2.3レイアウトリンクを変更する

ログイン時とログアウト時でのビューに表示されるリンクを変更するために

<% if logged_in? %>
  # ログインユーザー用のリンク
<% else %>
  # ログインしていないユーザー用のリンク
<% end %>

として、ログイン中とログインしていないときを分けたい。

そのため、ログインしているかどうかの論理値を返すメソッドを定義する。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # 現在ログイン中のユーザーを返す (いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  end
end

logged_in?メソッドを用いてヘッダーパーシャルのリンクを整えると

app/view/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", '#' %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

ログインは重要な機能なので、回帰バグをキャッチするためにも統合テストを書いておくのがよい。
手順は以下の通りである。

  1. ログイン用のパスを開く
  2. セッション用パスに有効な情報をpostする
  3. ログイン用リンクが表示されなくなったことを確認する
  4. ログアウト用リンクが表示されていることを確認する
  5. プロフィール用リンクが表示されていることを確認する

fixutureにテスト用のユーザー情報を追加する必要があります。
ここでデータベースにあるパスワードの属性は
password_digestです。つまりfixutureのpassword_digest属性にハッシュ化された渡されたパスワードの文字列を加える必要があるので、Userr モデルにdigestメソッドを独自に定義していきます。

app/models/user.rb
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end
end

Bcryptでハッシュ化パスワードを生成しており、
costには計算コストを指定して代入している。
テスト用データでは計算コストを大きくする必要はない為、最小コストで計算している。

また、User.digestとしている点は、インスタンスメソッドとして、Userモデルのインスタンス毎にdigestメソッドを呼び起こす必要はないので、クラスメソッドで定義している。
詳しくはこちらのページがとても参考になりました!
インスタンスメソッド、クラスメソッドとは?

fixtureのユーザーを参照するためには次のようにする

user=users(:michael)
  • usersはfixtureのファイル名
  • :michaelというシンボルはユーザーのキー

これでレイアウトのリンクをテストできる。
以下のようにテストコードを書く。

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with valid information" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
  end
end
assert_redirected_to @user

↑リダイレクト先が正しいかチェック

follow_redirect!

↑そのページに移動

assert_select "a[href=?]", login_path, count: 0

↑'a[href=?]'要素でlogin_path へのリンクが
count:0 個の意味

ここまでのテストを実行すると、間違いがなければテストはGREENになる。

ユーザー登録時にログイン

新規ユーザー登録をしたときに、そのままログインできるようにする。

app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end

createアクションの
log_in @userの部分である。

[補足]

  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

この部分ではparamsに:userリソースがあり
:userキーにネストされたuserの属性が含まれている。
ここでpermitとしているのは、
悪質なユーザーがこの属性以外に他の属性も付与し、管理権限を乗っ取らないようにするためなどである。


ユーザーが新規登録時にログインしているかをテストするときに、is_logged_in?メソッドをテストヘルパーに導入しておく。

test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end
test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
end

以上で新規登録時のログインのテストはできた。

8.3 ログアウト

ログアウトではlog_inでsessionでユーザーの情報を加えたのの反対で、sessionのユーザー情報を破棄する。
このログアウトの流れをSessionヘルパーモジュールに配置する。

app/helpers/sessions_helper.rb
..(省略)
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end

これをsessions_controllerでdestroyアクションに実装すると。

app/controllers/sessions_controller.rb
..(省略)
  def destroy
    log_out
    redirect_to root_url
  end

ログアウトの機構をテストすると。
[GREEN]

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "login with valid information followed by logout" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in?
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end
end

ログアウト後、ヘッダーのリンクも正しく描画されているか
assert_select 'a[href=?]'で確認しています。

まとめ

  • Railsのsessionメソッドを使うと、あるページから別のページに移動するときの状態を保持できる。 一時的な状態の保存にはcookiesも使える
  • ログインフォームでは、ユーザーがログインするための新しいセッションが作成できる
  • flash.nowメソッドを使うと、描画済みのページにもフラッシュメッセージを表示できる
  • テスト駆動開発は、回帰バグを防ぐときに便利
  • sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できる
  • ログインの状態に応じて、ページ内で表示するリンクを切り替えることができる
  • 統合テストでは、ルーティング、データベースの更新、レイアウトの変更が正しく行われているかを確認できる
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?