Help us understand the problem. What is going on with this article?

【Rails】基本的なログイン機構【Rails Tutorial 8章まとめ】

ログイン機能

HTTPリクエスト(POST、GETなど)は送信後、リクエストを送信したという記録が残らないため、情報を保存することができない。
ログイン機能を実装するために、sessionメソッドと専用のコントローラを用意する。

Sessionsコントローラ

Sessionsコントローラを作成する。

$ rails generate controller Sessions new

newアクションとビューはログイン画面で使用する。

SessionsコントローラにはUsersコントローラと同様、RESTfulなルーティングを設定する。
resourcesメソッドを使ってもよいが、editやshowなどのアクションは不要なので、必要なルーティングだけを手動で設定する。

config/routes.rb
Rails.application.routes.draw do
  .
  .
  .
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
end

ルーティングは次のような状態になっている。
スクリーンショット 2019-11-29 2.19.27.jpg
Sessionsコントローラ内に、空のcreateアクションとdestroyアクションを作っておく。

また、Sessionsコントローラ用のテストを名前付きルートで書き直しておく。

test/controllers/sessions_controller_test.rb
class SessionsControllerTest < ActionDispatch::IntegrationTest
  test "should get new" do
    get login_path
    assert_response :success
  end
end

ログインフォーム

新規登録フォームと同様に、ログインフォームを作成する。
フォームフィールドはメールアドレスとパスワード用だけでよい。

app/views/sessions/new.html
<% 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>

form_forメソッドの第一引数は:sessionとなっている(次項)。

ログイン失敗時の処理

ログイン失敗時の処理を書いていく。
ログインフォームから送信された情報は、新規登録の時と同様に、params変数にハッシュとして入っている。

params = { session: { email: "foo@bar.com", password: "foobar" } }

よって、キーを使うことでデータにアクセスできる。

params[:session][:email]
params[:session][:password]

これを使ってcreateアクションを作ると次のようになる。

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

まず、find_byメソッドを使ってフォームに入力されたメールアドレスと一致するユーザーを探す。
ここで取得したUserオブジェクトは他のところでは使わないので、@付きのインスタンス変数にする必要はない。
また、メールアドレスは全て小文字で保存されているので、downcaseメソッドを使うことを忘れないようにする。

次に、ユーザーが見つかれば、has_secure_passwordのauthenticateメソッドを使って、送信されたパスワードが正しいかどうかチェックする。
ユーザーが存在し、かつパスワードが合っていれば、ログインしてユーザー情報ページにリダイレクトする。
ユーザーが存在しない、またはパスワードが間違っていれば、エラーメッセージを表示して、ログインページに戻る。

エラーメッセージ

ユーザーの新規登録時には、エラーメッセージは@user.errors.full_messagesに配列として入っており、それをeachメソッドを使って取り出して表示していた。
ログインの場合にはUserオブジェクトのようなものが無いので、flashメソッドを使って表示する。

app/controllers/sessions_controller.rb
  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

このフラッシュによるエラーメッセージにはバグがある。
フラッシュはページを移動したり再読み込みすると消えるのだが、この場合は直後に再レンダリングしていることが原因で、メッセージが表示されたままになってしまう。
これをテスト駆動開発で解決していく。

ログイン失敗時のテスト

ログイン機能用のテストを作成する。

$ rails generate integration_test sessions_users_login

Sessionsコントローラ用のテストなのに、users_loginという名前は紛らわしいと思ったので、チュートリアルから少し変えている。

フラッシュがちゃんと消えているかも含め、ログイン失敗時のテストを書く。

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

手順は次のよう。
①ログインページに移動する。
②ログインページが表示されていることを確認する。
③無効なユーザー情報を持つparamsハッシュを送信する。
④ログインに失敗したために、ログインページが再レンダリングされたことを確認する。
⑤フラッシュによるエラーメッセージの存在を確認する。
⑥適当にページを移動する(今回はルートURL)。
⑦フラッシュによるエラーメッセージが消えていることを確認する。

最後の⑦のところで実際はフラッシュが消えずに残っているので、テストはREDになる。

これを解決するために、flashをflash.nowに書き換える。

app/controllers/sessions_controller.rb
flash.now[:danger] = 'Invalid email/password combination'

flash.nowを使うと、再レンダリングしたページでのみフラッシュが表示されるようになる。

テストがGREENとなることを確認しておく。

ログイン成功時の処理

sessionsヘルパーメソッド

ログイン機能に必要なメソッドは、Sessionsコントローラを作成した際に自動で生成される。
これをApplicationコントローラで読み込んで、どのコントローラでも使えるようにしておく。

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

log_inメソッド

sessionメソッドの中にハッシュとしてUserオブジェクトのidを入れることで、ログイン機能を実装できる。

session[:user_id] = user.id

これは様々なところで使うので、Sessionsコントローラのヘルパーメソッドとして作成する。

app/helpers/sessions_helper.rb
module SessionsHelper

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

これを使ってcreateアクションを書き換えると、次のようになる。

app/controllers/sessions_controller.rb
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

なお、redirect_to userはredirect_to user_url(user)としてもよい。

現在ログインしているユーザー

現在ログインしているユーザー、つまりsession[:user_id]に入っているidを持つユーザーを取得するために、current_userを定義する。

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
end

ログインしている場合、session[:user_id]とidが一致するユーザーをfind_byメソッドで探して返す。
ここでは||=(or equals)という代入演算子が用いられている。
以下の二文は同じであり

@foo = @foo || "bar"
@foo ||= "bar"

これは「変数の値がnilなら変数に代入するが、nilでなければ代入しない (変数の値を変えない)」という操作である。

  >> @foo
  => nil
  >> @foo = @foo || "bar"
  => "bar"
  >> @foo = @foo || "baz"
  => "bar"

変数@fooに何も入っていない場合、右辺の値が代入される。
何か入っている場合、何も代入されない。

このcurrent_userが呼び出されるたびに、ユーザーを探して@current_userに代入する、という無駄な処理を省く。

ログイン時のレイアウト

ログインしている場合と、ログインしていない場合とで、レイアウトに表示するものを変更する。
例えば、すでログインしている場合は新規登録やログインページへのリンクは不要である。
また、ログインユーザーのみがアクセスできるページへのリンクを表示する。

これは埋め込みRubyとif文を使って次のように書ける。

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

logged_in?メソッド

ここで、ログインしているならばtrueを返し、そうでないならばfalseを返すlogged_in?ヘルパーメソッドを作成する。

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

変更したヘッダーのレイアウトは以下のよう。

app/views/layouts/_header.html
<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>

ユーザーのプロフィールページへのリンクは以下のようであり、link_toメソッドの第二引数のcurrent_userは、やはりuser_path(current_user)とも書ける。

<li><%= link_to "Profile", current_user %></li>

ログアウト用のリンクは以下のようであり、第三引数にmethod: :deleteを渡すことで、DELETEリクエストを送信できるようにしている。

<%= link_to "Log out", logout_path, method: :delete %>

ログイン成功時のテスト

digestメソッドとテスト用ユーザー

ログイン機能をテストするために、有効なユーザー情報を持つテスト用のユーザーをfixtureファイルに用意する。
実際のアプリケーションでは、パスワードはbcryptによってハッシュ化されてUserモデルのpassword_digestカラムに保存される。
テストでこれを再現するために、文字列をbcryptと同じ方法でハッシュ化するdigestメソッドを作成する。

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  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

内容の理解は不要。
digestメソッドは個々のUserオブジェクトに対して使うものではないので、インスタンスメソッドではなくクラスメソッドで定義している。

テスト用のfixtureファイルは以下のようになる。

test/fixtures/users.yml
  michael:
    name: Michael Example
    email: michael@example.com
    password_digest: <%= User.digest('password') %>

fixtureファイルは全ての行を一段インデントしておかないと謎のエラーを吐くので注意。
また、埋め込みRubyが使用できる。

テスト

有効なユーザー情報でログインし、成功するテストを書く。

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
    assert_template 'sessions/new'
    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

setupでfixtureファイルのユーザーを@userに入れておく。
fixtureファイルのユーザーはusers(ユーザー名のキー)で取得できる。
このusersはusers.ymlのusersであり、Userモデルは関係ない。
また、users[:michael]と書くのは間違いなので注意する。

テストの手順は以下のよう。
①ログインページに移動し、表示されているか確認する。
②有効なユーザー情報を持つparamsハッシュを送信する。
③ログインが成功し、ユーザーのプロフィールページにリダイレクトすることを確認する(assert_redirected_toを使う)。
④リダイレクト先に移動する。
⑤ユーザーのプロフィールページが表示されていることを確認する。
⑥ログインページへのリンクが消えていることを確認する。
⑦ログアウトページとプロフィールページへのリンクが表示されていることを確認する。

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

ユーザーが新規登録をした場合に自動でログインするようにする。
Usersコントローラのcreateアクションで、新規登録に成功した場合の処理にlog_inメソッドを追加する。

app/controllers/users_controller.rb
 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

ユーザー登録時のログインのテスト

is_logged_in?メソッド

ユーザーの新規登録後に自動でログインできているかのテストを書く。
ここで、ログインしているかどうかを論理値で返すis_logged_in?ヘルパーメソッドを作成する。

test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all
  # Add more helper methods to be used by all tests here...
  include ApplicationHelper

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

このis_logged_in?ヘルパーはSessionsコントローラのlogged_in?ヘルパーメソッドと動作は同じであるが、名前が違う。
アプリケーション用のヘルパーとテスト用のヘルパーは同じ機能でも名前を変えておかないとトラブルの原因になることがあるらしい。
ところで、logged_in?メソッドではcurrent_userを使ってログインを確かめていたが、こちらではcurrent_userが使えないので、sessionを使っている。
(むこうでsessionを使ってもよいと思う)

テスト自体は、ユーザー登録用のテストに一行追加するだけで済む。

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?
    assert_not flash.empty?
  end
end

ログアウト機能

log_outメソッド

ログアウトするためのlog_outヘルパーメソッドを定義する。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

sessionの値を削除するためには、session.delete(:user_id)とする。
session[:user_id] = nilでもよい(多分)。
current_userも忘れずにnilにしておく。

Sessionsコントローラのdestroyアクションでlog_outメソッドを使用する。

app/controllers/sessions_controller.rb
def destroy
  log_out
  redirect_to root_url
end

ログアウト後はルートURLにリダイレクトする。

ログアウト機能のテスト

ログイン成功時のテストに加筆する形で、ログアウト機能のテストを書く。

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

ログアウトのassert_selectは、ログインの逆になっている。

kagamiya9
web系エンジニア目指してプログラミング独学中の大学院生 現在はRailsチュートリアルで学習中🐋
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした