LoginSignup
3

More than 3 years have passed since last update.

Rails Tutorial 第6版 学習まとめ 第8章

Last updated at Posted at 2020-06-17

概要

この記事は私の知識をより確実なものにするためにRailsチュートリアル解説記事を書くことで理解を深め
勉強の一環としています。稀にとんでもない内容や間違えた内容が書いてあるかもしれませんので
ご了承ください。
できればそれとなく教えてくれますと幸いです・・・

出典
Railsチュートリアル第6章

この章でやること

前章でユーザーを作成できるようにしたので作成したユーザーでログインする機構を作る。

セッション

HTTPはステートレス(状態がない)ため以前のリクエスト情報などを利用することができない。
別のページなどに移動した際、データを保持しておく手段がHTTPには存在しない。
代わりに「セッション」を用いることでユーザーの情報を保持しておく。

Railsでこのセッションを実装するためによく使われるのがcookies
cookiesはブラウザに保存されるテキストデータでページを移動しても消えないのでユーザー情報を保持することができる。

Sessionsコントローラ

SessionもRESTのアクションに直接結びつけて実装する。
具体的には

動作 アクション
ログインフォーム new
ログイン処理 create
ログアウト処理 destroy

ログイン→一時セッションの作成
ログアウト→一時セッションの削除

まずはセッション用コントローラを作成する。
rails g controller Sessions new

generateコマンドでコントローラを作成すると対応するビューも作成する。
今回ビューが必要なのはログインフォームのnewアクションだけなので
newだけgenerateコマンドで指定する。

routes.rbも更新する。newアクションへのルーティングが定義されているが
書き換える。

  get '/login', to:'sessions#new'
  post '/login', to:'sessions#create'
  delete '/logout', to:'sessions#destroy'

自動生成したテストも名前付きルートを使うよう変更する。

rails routesコマンドを使うとルーティングの一覧を確認できる。

演習
1.get login_pathはGETリクエストでログイン用フォームを受け取る。
post login_pathはログインフォームで入力した値をPOSTリクエストで送信する。

2.

$ rails routes | grep sessions
                                login GET    /login(.:format)                                                                         sessions#new
                                      POST   /login(.:format)                                                                         sessions#create
                               logout DELETE /logout(.:format)                                                                        sessions#destroy

ログインフォーム

フォームではform_withを使ってフォームを実装してきた。
ログインフォームはUsersリソースを作成するわけではなくSessionを作成するだけなので
モデルオブジェクトを指定することができない。
このような時は
<%= form_with(url: login_path, scope: :session, local: true) do |f| %>
とすることでlogin_pathにPostリクエストを送る
scope: :sessionとすることで
params[:session]に各種属性の値が格納されるようになる。
例えばpasswordなら
params[:session][:password]に格納される。

演習

1.
login_pathは2種類あるがform_withはデフォルトでPOSTリクエストに設定されているため
POSTリクエストをlogin_pathに発行する。つまりPOSTのlogin_pathでcreateアクションに到達する。
GETの場合はnewアクションに到達する。

ユーザーの検索と認証

今回はログインが失敗した場合の処理を作成する。

 def new
  end

  def create
     render 'new'
  end

  def destroy
  end

コントローラーをこのように定義すると
フォームを送信した際そのまままたnewページに転送されるようになる。

この状態でフォームを送信してみると
image.png

paramsハッシュにsessionキーが追加されて、その中にemailキーとpasswordキーが入っていることがわかる。

params = { session: { password: "foobar", email: "take.webengineer@gmail.com" }}

このような構造になっている。

これを用いて
paramsからユーザー認証情報を受け取り、find_byメソッドでユーザーを検索し
authenticateメソッドでパスワードを認証したら
sessionを作成するといった流れで処理を行う。

これを踏まえたcreateアクションの内容がこちら

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
    #ユーザーページにリダイレクトする処理(これから)
    else
   #エラーメッセージを作成する。
      render 'new'
    end
  end

&&は論理積の演算子でどちらも真の時だけ真という演算子
userが存在するかつユーザーのパスワードが認証されたときという条件になる。

演習

1.期待通りの結果が出る。

>> user = nil
=> nil
>> user && user.authenticate("foobar")
=> nil
>> !!(user && user.authenticate("foobar"))                                                                                
=> false
>> user = User.first
   (1.4ms)  SELECT sqlite_version(*)
  User Load (0.6ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-14 02:57:10", password_digest: [FILTERED]>
>> !!(user && user.authenticate("foobar"))
=> true

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

ログインに失敗した時のフラッシュメッセージを作成する。
flash[:danger] = "Invalid email/password combination"

このままだとほかのページに遷移した時もメッセージが残ってしまう。
なぜならrenderでフォームを再表示しているから。
renderはリダイレクトではなくビューの再レンダリングなのでflashが消えない。
これからこの問題点を修正する。

↓他のページにまでフラッシュが表示されてしまう↓
image.png

フラッシュのテスト

ログイン回りの統合テストを作成する。今回はほかのページでもフラッシュが表示されてしまうという問題があるのでこのエラーを
検出するテストを先に作り、テストがパスするようにコードを書く。

今回のコードはこちら

  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

最後の2行でルートパスにアクセスし、フラッシュが消えていることをテストしている。

もちろんエラーとなる。
この問題を解決するためにflash.nowを使う。
flash.nowは次のアクションに移行した段階で消える。
↑参考サイト(https://qiita.com/taraontara/items/2db82e6a0b528b06b949)

全部flash.nowでよくね、と一瞬血迷ったがflash.nowの場合はredirect_toを使うとそもそもflashが一度も表示されずに
終わる。(redirect_toで次のアクションを読みに行くため)

さっそくflashflash.nowに変える。
この変更でテストはパスする。

演習

1.
ログイン失敗
image.png
別ページに遷移すると消える
image.png

ログイン

次はログイン情報が有効な際の処理を実装する。
処理の流れは
認証し、ユーザーが正しければcookiesを使った一時セッションを作成し、ログイン処理を完了させ、
該当のユーザーページに遷移させる。

セッションはログインページ以外でもログイン済みかそうでないかによって処理を分けるので
共通で使えるようにSessionsHelperをApplicationControllerにincludeしておく。
こうすることですべてのコントローラで共通で使えるようになる。

log_inメソッド

Railsで定義しているsessionメソッドを使ってログイン処理を実装する。
sessionメソッドを使うと一時cookiesにキーに紐づけた値が保存される。
この一時cookiesはブラウザが閉じた瞬間に有効期限が終了するため閉じるたびにログインが必要になる。

ログインは様々な場所で使うことが想定されるのでSessionsHelperにメソッドとして定義しておく。

sessions_helper.rb
module SessionsHelper
  def log_in(user)
    session[:user_id] = user.id
  end 
end

あとはcreateアクションに追加して、リダイレクトをかければ完成!

  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
演習

1.Chromeの場合だとデベロッパーツールのApplicationタブのCookiesで一覧が表示できる。
image.png
↑暗号化されたユーザーIDが格納されている(_sample_app_session)

2.Expires→期限が切れる時間が書いてある。一時セッションの場合はSessionとなっており、ブラウザを閉じたときに切れることがわかる。

現在のユーザー

別のページでもユーザーIDを取り出しユーザー情報を利用できるようにするためにcurrent_userメソッドを定義する。

セッションに保存されたIDからユーザーオブジェクトを検索するがfindを使うとユーザーが存在しなかった場合にエラーを発生してしまうので
find_byメソッドを使う。

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

さらにこの結果(Userオブジェクト)をインスタンス変数に保存することで
1リクエスト内でのデータベース参照を1度で済ませることができ、高速化につながる。

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

そして||演算子を使うことでこのif文を1行で書くことができる。
@current_user = @current_user || User.find_by(id: session[:user_id])

さらにこの行を短縮形で書くことで正しいコードになる。
@current_user ||= User.find_by(id: session[:user_id])

演習

1.

>> User.find_by(id:"123")
   (0.4ms)  SELECT sqlite_version(*)
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 123], ["LIMIT", 1]]
=> nil

2.
①nilを与えるとfind_byメソッドはnilを返し@current_userもnilになる。
@current_userが空の時にはデータベースから読み出すが中身があるときには自信を代入(何もしない)

>> session = {}
=> {}
>> session[:user_id] = nil
=> nil
>> @current_user ||= User.find_by(id:session[:user_id])
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ?  [["LIMIT", 1]]
=> nil
>> session[:user_id] = User.first.id
  User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> 1
>> @current_user ||= User.find_by(id: session[:user_id])
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-14 02:57:10", password_digest: [FILTERED]>
>> @current_user ||= User.find_by(id: session[:user_id])
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-14 02:57:10", password_digest: [FILTERED]>
>> 

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

ログインしているときとしていないときで表示するページを動的に変化させる。
ログインしているかしていないかでERB内でif分岐させ、リンクを変化させる。
これに論理値を返すlogged_in?メソッドが必要になるため定義する。

  def logged_in?
    !current_user.nil?
  end

current_userが空ではないことを確かめたいので!で論理値を逆転させている。

次にヘッダーのレイアウトを変更していく。

_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>

bootstrapのクラス(dropdownなど)を使ってドロップダウンメニューを追加している。
また
<%= link_to "Profile", current_user %>の行では
省略形でリンクを指定しており
リンク先はshowページになる。ユーザーオブジェクトを渡すとshowページへのリンクへ変換してくれる機能である。

ドロップダウンメニューはbootstrap内のjQueryを読み込まなければならないため、
application.jsを通して読み込む。

まずはyarnでjQueryとbootstrapのパッケージを読み込む。
yarn add jquery@3.4.1 bootstrap@3.4.1

次にWebpackの環境ファイルにjQueryの設定を追加する。

config/webpack/environment.js
const { environment } = require('@rails/webpacker')
const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)
module.exports = environment

最後にapplication.jsにjQueryをrequireして、Bootstrapをimportする。

この時点でドロップダウンメニューが有効になり、
有効なユーザーでのログインができるようになっているので
これまでのコードを効率よくテストできる。

またブラウザを閉じると期待通り一時セッションが削除され、再ログインが必要になる。

演習

1.ChromeのCookie削除画面
削除したあとログインリンクが表示される。
image.png

2.省略。ブラウザを再起動するとログインを求められる。(一時セッションが消える。)

レイアウトの変更をテストする

動的に変化するヘッダーリンクの動作を統合テストでテストしていく。
動作は
1. ログイン用パスにアクセス
2. ログイン情報をpost
3. ログインリンクが非表示になっていることをテスト
4. ログアウトリンクが表示されていることをテスト
5. プロフィール用リンクが表示されていることのテスト

これらをテストするためにまず登録されているユーザでログインする必要があるが
テスト用データベースにユーザーが登録されている必要がある。
Railsではfixtureを使うことでテストデータを作成することができる。

ユーザーのデータを登録するがパスワードはpassword_digestとしてハッシュ化されて保存されるので
fixtureのデータもハッシュ化したpasswordをpassword_digestに保存する必要がある

passwordのハッシュ化文字列を返すdigestメソッドを定義するが
fixture向けに使うため、ユーザーオブジェクトに対して使うことはない。
そのためクラスメソッドとして定義する。

また軽量化のためにテスト時はハッシュ化のコストを最小にし、本番環境ではセキュリティのためにコストを高めるようにする。
定義したメソッドがこちら

  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine::cost
    BCrypt::Password.create(string, cost: cost)
  end

digestメソッドの用意ができたのでフィクスチャを作成する。

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

フィクスチャではerbが利用できるためpassword_digestにdigestメソッドを使って"password"をハッシュ化した文字列を代入している。
これでフィクスチャからユーザーを参照できるようになった。

さっそくテストを作成していく。

  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

このテストでは
assert_redirected_toでリダイレクト先が@userになっているかチェックしている。
follow_redirect!で実際にリダイレクト先に移動する。
あとはassert_templateでshowページ(ビュー)が表示されているか。
assert_selectでログインリンクがないこと。
ログアウトリンクがあること。showページリンクがあることをテストしている。

演習

1.post行をこのように修正する。
post login_path , params:{ session: { email: @user.email,password:"" }}
これでメールアドレスが有効でパスワードが無効というケースをテストできる。

2.user && user.authenticate(params[:session][:password])

user&.authenticate(params[:session][:password])
この二つは等価になる

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

ユーザー登録をした後ログインしていないと紛らわしく、ユーザーの混乱を招くので登録と同時にログインするようにする。
ログイン処理はlog_inメソッドで定義したためUsersControllerのCreateアクションにlog_inメソッドを
追加すればいい。

  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

ここで追加したユーザー作成時にログインしているかどうかを判定するために
ヘルパーメソッドに追加したlogged_in?メソッドを使いたいが
テストではヘルパーメソッドを呼び出せないためtest_helper.rbにテスト用ヘルパーメソッドとして
新規に登録する。
名前の取り違えを防ぐため、こちらはis_logged_in?メソッドとして定義する。

  def is_logged_in?
    !session[:user_id].nil?
  end
演習

1. is_logged_in?メソッドでログインしているか確認しに行っているためREDになる。
2. コメントアウトは対象の行を選択してWindowsキー+/でできる。

ログアウト

ログアウト機能を実装していく。
リンクはあるのでアクションを定義していく。
処理の内容はlog_inメソッドの逆でセッションを削除すればいい。

  def log_out
    session.delete(:user_id)
    @current_user = nil
  end

定義したlog_outメソッドを利用してdestroyアクションも作成する。

  def destroy
    log_out
    redirect_to root_url
  end

アクセスしたらログアウトしてルートURLに飛ぶ仕様。

ここでテストも更新する。

user_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "login with valid email/invalid information" do
    get login_path
    assert_template 'sessions/new'
    post login_path , params:{ session: { email: @user.email,password:"" }}
    assert_not is_logged_in?
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  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)
    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

ここではログアウト処理が行われているか、リダイレクトは正常に行われているか、表示されているリンクは正しいかなどを
テストしている。

またis_logged_in?メソッドを活用して"login with valid email/invalid password"の中身を書き換えている。

演習

1.セッションが削除され、ルートURLに飛ぶ。ページ内のリンクもログイン前のものに変わる。
動作確認は行っているので省略。

2.Chromeを使っていると消えないが、ログアウト処理は行われている。
debuggerなどを使って直接確認してもわかる。

[12, 21] in /home/ubuntu/environment/sample_app/app/controllers/sessions_controller.rb
   12:       render 'new'
   13:     end
   14:   end
   15:   
   16:   def destroy
   17:     log_out
   18:     redirect_to root_url
   19:     debugger
=> 20:   end
   21: end
(byebug) logged_in?
false

前の章へ

次の章へ

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
What you can do with signing up
3