LoginSignup
23
17

More than 3 years have passed since last update.

Ruby on Rails チュートリアル 第8章 ログイン機構(一時Session ログイン ログアウト)を解説

Last updated at Posted at 2019-01-02

近況報告

エンジニア転職成功しました。YouTubeもはじめました。

前回の続き

著者略歴
YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目

第8章 難易度 ★★ 3時間

挫折しないRailsチュートリアルの進め方を先にお読みください↓↓

Railsチュートリアルで挫折しない3つのポイント

基本的なログイン機構

この章では、ログインの基本的な仕組みを実装していく。
Authentification Systemを実装することで、ログイン/ログアウト機能を実現する。

この認証システムの基盤が出来上がったら、current userだけがアクセスできるページ、扱える機能などを制御していく。

なお、このようにcurrent userと一般ユーザーとで表示を切り替えたりする仕組みをAuthorization Modelと呼ぶので覚えておきたい。

このAuthentifaication System と Authorization Modelはサンプルアプリケーションの様々な機能の基盤となる仕組みで、この仕組みを元にログインユーザーだけがユーザー一覧ページにアクセスできたり、正当なユーザーだけが自分のプロフィール情報を編集できるようにしたり、管理者だけがユーザーを削除したりできるようになる。

current user を利用することで、ユーザーidと投稿データを関連付けたり、他のユーザーをフォローしたり、フィード一覧を表示させることが可能となる。

また、次章ではremember meといって、ユーザーが任意にログイン情報を覚えさせることができる機能を構築する。

具体的には、チェックボックスをログインフォームに設置し、ログイン時にユーザーがチェックボタンを押すことで、一度ブラウザを閉じてもログイン状態が継続させる仕組みを作る。

8章と9章では

1:ブラウザを閉じるとログインを破棄する(Session)
2:ユーザーのログイン情報を自動で保存する(Cookie)
3:ユーザーがチェックボックスをオンにした場合のみログインを保存する(Remember me)

このような流れで一般的なログイン機構を実装する。

8.1 セッション

HTTPはステートレスなプロトコルで、HTTPリクエストは独立したトランザクションとして扱われる。
つまり、HTTPはリクエストが終わればそれまでの情報を忘れるので、毎回最初からやり直す必要がある。

HTTPだと、ブラウザをあるページから別のページに移動した時に、ユーザーIDを保持しておく手段がないので、
HTTPの層(トランスポート)より上の層(セッション)を使い、半永続的な接続をコンピュータ間(PCとWebサーバー)に別途設定する。

セッションはHTTPの特性とは別に接続を確保できるので、接続を途切れずに残すことができる。

Railsでセッションを実装する方法として、Cookieがある。

アプリケーションはcookies内のデータを使って、例えばログイン中のユーザーが所有する情報をデータベースから取り出すことができる。

Railsにはsessionというメソッドがあり、一時セッションを作成して、そこに一時的なデータを保存、そのデータはブラウザを閉じると自動的に終了する。
(後にcookiesメソッドを使って、半永続的にテキストデータを保存する方法も詳解する)

セッションはRESTfulリソースとしてモデリングできると、理解が進むらしい。

①newで新しいセッションを出力
②ページでログインするとcreateでセッションを実際に作成して保存
③ログアウトするとdestroyでセッションを破棄

Usersリソースと異なるのは、UsersリソースではバックエンドでUserモデルを介してDB上の永続的データにアクセスするのに対し、Sessionリソースでは代わりにcookiesを保存場所として使う点。

ログインの仕組みの大半は、cookiesを使った認証メカニズムによって構築されている。

セッション機能を作成する準備として

  • Sessionコントローラ
  • ログイン用のフォーム
  • 両者に関連するコントローラのアクション

を作成する。

ここまでで簡単な前説は終わり、トピックブランチを作成する。

$ git checkout -b basic-login

8.1.1 Sessionsコントローラ

ログイン/ログアウト要素をSessionコントローラの特的のRESTアクションにそれぞれ対応づける。
具体的には、

ログインフォーム→newアクション
POSTリクエスト→createアクション(ログイン)
DELETEリクエスト→destroyアクション(ログアウト)

まずはSessionコントローラと、その中にnewアクションを生成する。

$ rails g controller Sessions new

このコマンドではnewビューも生成している点にも注目。

モックアップはこちら

image.png

出典:図 8.1: ログインフォームのモックアップ

Usersリソースの時はresourcesメソッドでRESTfulなルーティングを自動的にフルセットで利用できるようにしたが
Sessionリソースではフルセットはいらず、名前付きルーティングだけを使う。

この名前付きルーティングでは、

  • GET/POSTリクエストをloginルーティング
  • DELETEリクエストをlogoutルーティング

で扱う。

routes.rb
Rails.application.routes.draw do
  get 'sessions/new'

  get 'users/new'

  root 'static_pages#home'
  get     '/help',    to: 'static_pages#help'
  get     '/about',   to: 'static_pages#about'
  get     '/contact', to: 'static_pages#contact'
  get     '/signup',  to: 'users#new'
  get     '/login',   to: 'sessions#new'
  post    '/login',   to: 'sessions#create'
  delete  '/logout',  to: 'sessions#destroy'
  resources :users                            #usersリソースをRESTfullな構造にするためのコード。
end

ここでテストをパスさせるために、新しいログイン用の名前付きルートをテストで使う。

sessions_controller_test.rb
require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest
  test "should get new" do                                                      # newアクション(/login)に対してのテスト
    get login_path                                                              # /loginにgetリクエストを送る(取得)
    assert_response :success                                                    # レスポンスが成功したらtrue、失敗ならfalse
  end

end

Sessionルーティングの表がコレ

HTTPリクエスト URL 名前付きルート アクション名 用途
GET /login login_path new 新しいセッションのページ (ログイン)
POST /login login_path create 新しいセッションの作成 (ログイン)
DELETE /logout logout_path destroy セッションの削除 (ログアウト)

出典:表 8.1: リスト 8.2のセッションルールによって提供されるルーティング

これまでに出てきた名前付きルーティングの全てを、rails routesコマンドで確認できる。

$ rails routes
      Prefix Verb   URI Pattern               Controller#Action
sessions_new GET    /sessions/new(.:format)   sessions#new
   users_new GET    /users/new(.:format)      users#new
        root GET    /                         static_pages#home
        help GET    /help(.:format)           static_pages#help
       about GET    /about(.:format)          static_pages#about
     contact GET    /contact(.:format)        static_pages#contact
      signup GET    /signup(.:format)         users#new
       login GET    /login(.:format)          sessions#new
             POST   /login(.:format)          sessions#create
      logout DELETE /logout(.:format)         sessions#destroy
       users GET    /users(.:format)          users#index
             POST   /users(.:format)          users#create
    new_user GET    /users/new(.:format)      users#new
   edit_user GET    /users/:id/edit(.:format) users#edit
        user GET    /users/:id(.:format)      users#show
             PATCH  /users/:id(.:format)      users#update
             PUT    /users/:id(.:format)      users#update
             DELETE /users/:id(.:format)      users#destroy

演習

1:GET login_pathPOST login_pathの違いについて説明

GETの方はクライアントがWebサーバーに対してlogin_pathを取得要求している。
POSTの方はクライアントがWebサーバーに対してlogin_pathを送信要求している。

2:rails routesでgrepコマンドを使い、Sessionsリソースに関する結果だけ表示させてみる。

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

8.1.2 ログインフォーム

ログインフォームはユーザー登録フォームと同じようなデザインにする。
ユーザー登録フォームにあったNameとConfirmationを無くし、EmailとPasswordのフィールドだけを残す。

ログインフォームで入力した情報に誤りがあった際は、ログインページをもう一度表示してエラーメッセージを出力する。

前章ではエラーメッセージの表示に専用パーシャルを使ったが、そのパーシャルではActive Recordによって自動生成されるメッセージを使っていた。

今回扱うセッションはActive Recordオブジェクトではないので、自動でエラーメッセージを表示してくれない。

今回は手動でエラーメッセージを作る必要がある。
その為にフラッシュという機能を使う。

モックアップはこれ

スクリーンショット 2018-12-29 23.20.14.png

出典:図 8.2: ログイン失敗時のモックアップ

早速ログインフォーム用のビューを作るが、
usersディレクトリのnewビューでは以下のようにform_forヘルパーを使って、ユーザーのインスタンス変数@userを引数に取っていたことを確認。

<%= form_for(@user) %>

<% end %>

しかし、セッションフォーム(ログインフォーム)では、このように@userと指定するだけでは送ることはできない。

参考:7.4.1 登録フォームの完成

何故かと言うと、先程説明した通り、セッションではSessionモデルを作らず、@userというインスタンス変数もないから。

セッションの場合はリソースの名前とそれに対応するURLを具体的に指定する。

form_for(:session, url: login_path)

実際に書いたログインフォーム用のコード

new.html.erb
<% provide(:title, 'Log in') %>
<h1>ログイン</h1>

<div class="row"></div>
  <div class="col-md-6 col-md-offset-3">

    <!-- formの送信先を指定 -->

    <%= form_for :session, url: login_path do |f| %>      <!-- 送信先をurlが/login、Sessionsコントローラのcreateアクションに@userを送り、fブロックに代入-->

    <!-- form作成-->

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

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

    <!-- 送信ボタン-->

      <%= f.submit "送信", class: "btn btn-primary" %>
    <% end %>

    <!-- 登録ページへのボタン -->

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

スクリーンショット 2018-12-31 0.17.12.png

生成されたHTMLフォームを確認

|     <!-- formの送信先を指定 --> |
|:--|
|      |
|     <form action="/login" accept-charset="UTF-8" method="post"><input name="utf8" type="hidden" value="&#x2713;" /><input type="hidden" name="authenticity_token" value="XWdpHBv8+TrgT+2P84B4ig+B7jcWGK8h9hrKnq81ELDlHjIWcm9KB/IyYzpHKFytXJwsR9vB2qITND13iNR9xg==" />      <!-- 送信先をurlが/login、Sessionsコントローラのcreateアクションに@userを送り、fブロックに代入--> |
|      |
|     <!-- form作成--> |
|        |
|       <label for="session_email">Email</label> |
|       <input class="form-control" type="email" name="session[email]" id="session_email" /> |
|        |
|       <label for="session_password">Password</label> |
|       <input class="form-control" type="password" name="session[password]" id="session_password" /> |
|        |
|     <!-- 送信ボタン--> |
|        |
|       <input type="submit" name="commit" value="送信" class="btn btn-primary" data-disable-with="送信" /> |
| </form>     |

フォーム送信後、メールアドレスとパスワードのフィールドが、params変数に置き換わることが推測できる。
具体的には

name="session[email]"
name="session[password]"

🔽

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

となる。

演習

1:form_forで送信したデータはSessionsコントローラのcreateアクションに到達する。Railsはこれをどのように実現しているか

A.form_forのHTMLを見ると/loginアクション(createアクション)に対して、postメソッドを付随しているのがわかる。つまり、postリクエストを/loginに送っている、と言うこと。
(先程詳解したセッションルールによって提供される、名前付きルーティングによってlogin_pathが指定されている)

8.1.3 ユーザーの検索と認証

ログインでセッションを作成する場合の手順は

①入力が無効な場合の処理を作成
②ログイン失敗時のエラーメッセージの配置
③ログイン成功時の土台部分の作成

まず、①を行う前に、Sessionsコントローラのcreateアクションにnewビューが出力されるよう定義

sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    render 'new'                                                                # newビューの出力
  end

  def destroy

  end
end

ここでloginフォームからEmailとPasswordのデータを送ってみる。

デバッグ情報を確認

--- !ruby/object:ActionController::Parameters
parameters: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
  utf8: "✓"
  authenticity_token: Zwj6Lt+souZ38GerfI924cltqJ2UIapk7s23a153bpIdZ9k7UXnB2BUjnf77f2J8d0bEC8gePEfHo4yO5UlzUQ==
  session: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
    email: yuuki@gmail.com
    password: foobar
  commit: 送信
  controller: sessions
  action: create
permitted: false

sessionキーの下にemailとpasswordがある点に注目。

ユーザー登録の時と同様、これらのパラメータはネストしたハッシュになっている。

つまり、これらのパラメータをpostリクエストでcreateアクションに送信すると、
createアクションにて、paramsハッシュで以下のようにデータを受け取る。

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

これを一つにすると

params[:session]

ちなみに、上記のemailとpasswordの部分は細かく見ると

{ session: { params: "foobar", email: "user@example.com"} }

このようになっている。
つまり、ハッシュの中にハッシュがあるhash to hashを採用している。

createアクションの中では、データをparamsハッシュから簡単に取り出せる。

実際に、createアクションでデータを受け取るには、
find_byメソッドと
authenticateメソッド
を使う。

authenticateメソッドは、認証に失敗するとfalseを返すので、その点を踏まえてコントローラに処理を記述。

sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)               # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if user && user.authenticate(params[:session][:password])                   # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
    else
    render 'new'                                                                # newビューの出力
    end
  end

上記のコードでは、フォームから受け取ったメールアドレスのデータを持つユーザーをDBから探し、
そのユーザーが存在して、かつ送信されたメールアドレスとパスワードの値が同じであればtrueを返している。

もしユーザーがいない&メールアドレスとパスワードが一致しなければfalseを返している。

User Password a && b
存在しない 何でもよい (nil && [オブジェクト]) == false
有効なユーザー 誤ったパスワード (true && false) == false
有効なユーザー 正しいパスワード (true && true) == true

出典:表 8.2: user && user.authenticate(…)の結果の組み合わせ

演習

1;Railsコンソールを使って、上記の式があっているか確認する。

>> user = nil
=> nil
>> !!(user && user.authenticate('foobar'))
=> false
>> user = User.first
>> !!(user && user.authenticate('foobaz'))
=> false
>> !!(user && user.authenticate('foobar'))
=> true

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

ログイン失敗時のエラーメッセージを作成する。

セッションではActive Recordを使っていないため、エラーメッセージを手動で作成しなければならない。
エラーメッセージの表示には、ログイン成功時同様、フラッシュメッセージを使う。

sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)               # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if user && user.authenticate(params[:session][:password])                   # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
    else
      flash[:danger] = "Invalid email/password combination"                     
      render 'new'                                                              # newビューの出力
    end
  end

flash[:danger]にエラー文を代入することで、applicationビューで読み出し時に
dangerクラスを適用したエラー文を表示させることができる。

しかし、上記コードのflashは正しくない。

何故なら、フラッシュメッセージが画面を更新しても消えないから。
この理由として、renderでnewビューを再描画した際にリクエストと見なされない(flashからサーバーへの削除リクエストが行われない)ので、別ページに移動してもflashメッセージは削除されず、残り続けてしまう。

8.1.5

フラッシュメッセージが消えないのはアプリケーションの小さなバグなので、
エラーをキャッチするテストを先に書き、そのエラーが解決するようなコードを書くようにする。

今回はログインフォームの送信に関しての統合テストを作成し、回帰バグを防止する。

まずは簡単な統合テストから

$ rails g integration_test users_login
Running via Spring preloader in process 15448
      invoke  test_unit
      create    test/integration/users_login_test.rb

テスト作成の手順は以下

①:ログイン用のパスを開く
②:新しいセッションのフォームが正しく表示されたことを確認する
③:わざと無効なparamsハッシュを使ってセッション用パスにPOST
④:新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認
⑤:別のページに一旦移動する
⑥:移動先の@ページでフラッシュメッセージが表示されていないことを確認する

users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  # test "the truth" do
  #   assert true
  # end
  test "login with invalid information" do                                      # ログインフォームで空のデータを送り、エラーのフラッシュメッセージが描画され、別ページに飛んでflashが空であるかテスト
    get login_path                                                              # ログインURL(/login)のnewアクションを取得
    assert_template 'sessions/new'                                              # sessions/new(ログインフォームのビュー)が描画されていればtrue
    post login_path, params: { session: { email: "", password: "" } }           # ログインURL(/login)のcreateアクションへデータを送り、paramsでsessionハッシュを受け取る
    assert_template 'sessions/new'                                              # sessions/new(ログインフォームのビュー)が描画されていればtrue
    assert_not flash.empty?                                                     # flashが空ならfalse、あればtrue    
    get root_path                                                               # Homeページを取得
    assert flash.empty?                                                         # flashが空であればtrue
  end
end

この時点では、flashメッセージは表示されているので、最後のassertで失敗する。

$ rails test test/integration/users_login_test.rb
        Expected false to be truthy.
        test/integration/users_login_test.rb:13:in `block in <class:UsersLoginTest>'

  1/1: [==================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.18158s
1 tests, 3 assertions, 1 failures, 0 errors, 0 skips

テストをパスさせるには、sessions_controllerのflashをflash.nowに置き換える。
nowメソッドはレンダリングが終わっているページで特別にフラッシュメッセージを表示できる

flashメッセージとは異なり、flash.nowのメッセージはその後リクエストが発生した時に消える。

sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)               # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if user && user.authenticate(params[:session][:password])                   # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
    else
      flash.now[:danger] = "Invalid email/password combination"                 # flashメッセージを表示し、新しいリクエストが発生した時に消す
      render 'new'                                                              # newビューの出力
    end
  end

演習

1:flashが上手く機能しているか、確認

別ページ移動でflashが消えたのでOK

8.2 ログイン

cookiesを使って一時セッションでユーザーをログインできるようにし、ログイン状態を保持したままデータを送信できるようにする。

今回はブラウザを閉じるとcookiesの有効期限が自動的に切れるようにするが、後にブラウザを閉じても保持されるセッションを追加する。

以前はセッション実装する際、様々なコントローラやビューで多くの数のメソッドを定義する必要があったが
現在はRailsのモジュール機能を使うだけでそうしたメソッドを一箇所にパッケージ化できる

Module Helper#メソッドの集合(ヘルパー)を定義

Sessionsコントローラ生成時には既にセッション用のヘルパーモジュールも自動生成されていて、Railsのセッション用ヘルパーはビューにも自動的に読み込まれる。

つまり、Railsの全コントローラの親クラスであるApplicationコントローラにこのモジュール(ヘルパー)を読み込ませることで、どのコントローラでも使えるようになる。

application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper                                                         #SessionsHelper(メソッドの集合体)を全コントローラに適用

  def home
    render html: "こんにちは世界"
  end
end

8.2.1 log_inメソッド

定義済みのsessionメソッドを使って、単純なログインを行えるようにする。
(sessionメソッドはSessionsコントローラとは無関係なので注意。)

sessionメソッドはハッシュのように扱える。

session[:user_id] = user.id

上記のコードでは、ユーザーのブラウザ内の一時cookiesに暗号化済みのユーザーidが自動生成される。
この後のページで、session[:user_id]を使って、ユーザーIDを元通りに取り出すことができる。

後に詳解するcookiesメソッドとは対照的に、sessionメソッドで作られた一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了する。

同じログイン手法を様々な場所で使い回せるようにする為に、Sessionsヘルパーにlog_inという名前のメソッドを定義しておく。

sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)                                                              # login_inメソッドにuser(ログイン時にユーザーが送ったメールとパス)を引数として渡す
    session[:user_id] = user.id                                                 # ユーザーidをsessionのuser_idに代入(ログインidの保持)
  end

end

sessionメソッドで作成した一時cookiesは自動的に暗号化され、上記のコードは保護される。

ここが重要だが、攻撃者がたとえこの情報をcookiesから盗みだすことができたとしても、それを使って本物のユーザーとしてログインすることはできない。

ただし、それはsessionメソッドで作成した「一時セッション」にしか該当せず、cookiesメソッドで作成した永続的セッションでは断言できない。
(ブラウザ閉じて消えるsessionメソッドなら大丈夫だが、ブラウザ閉じても消えないcookiesメソッドだと危ないってこと)
何故なら、cookiesの場合セッションハイジャックという攻撃を受ける可能性があるから。

これは後に詳解する。

log_inというヘルパーメソッドができたので、ユーザーログインを行ってcreateアクションの中身を完成させる。

sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)               # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if user && user.authenticate(params[:session][:password])                   # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
      log_in user                                                               # sessions_helperのlog_inメソッドを実行し、sessionメソッドのuser_id(ブラウザに一時cookiesとして保存)にidを送る
      redirect_to user                                                          # ログインしたユーザーのページにリダイレクト
    else
      flash.now[:danger] = 'Invalid email/password combination'                 # flashメッセージを表示し、新しいリクエストが発生した時に消す
      render 'new'                                                              # newビューの出力
    end
  end

ここのredirect_toでも第7章で説明した省略的な書き方
を使っている。

user_url(user)を省略しているので、分かりにくいがログインできたユーザー(id)のページへ移動している。

今の状態でもログインできるが、ログインしたかどうかのメッセージが表示されないので、このままだとユーザーからしたら本当にログインできたのかがわからない。(ブラウザセッションを直接確認すればわかるが)

なので、後にセッションに含まれるIDを利用して、データベースから現在のユーザー名を取り出して、画面で表示する。
さらに、アプリケーションのレイアウト上のリンクに、現在ログインしているユーザー(自分)のプロフィールを表示できるようにもする。

演習

1-2:有効なページで実際にログインし、ブラウザからcookiesの情報を調べてみる。
そしてsession&Expires(有効期限)の値を確認。

スクリーンショット 2018-12-31 18.46.07.png

コンテンツが暗号化されていて不可逆な値となっていることが分かる。
有効期限がブラウザセッションの終了時となっており、一時的なクッキーであることが分かる。

8.2.2 現在のユーザー

ユーザーIDを一時セッションの中に安全に置けるようになったので、
今度はそのユーザーIDを別のページで取り出す。

その為に、current_userメソッドを定義して、セッションIDに対応するユーザー名をDBから取り出せるようにする。

<%= current_user.name %>

これで、現在のユーザー名が表示されるようにする。

また、ユーザープロフィールページへのリダイレクトでは

redirect_to current_user

を使いたい。
この時、ユーザーを検索する方法としては

User.find(session[:user_id])

が思いつくが、ユーザーIDが存在しない状態でfindを使うとエラーが発生してしまう。
たとえばユーザーがログインしていない場合、sessionハッシュにidがない(NULL)ので、
結果的にidが存在しないユーザーを探してしまうのでエラーになってしまう。

この状態を修正すべく、createメソッド内でメールアドレスの検索に使ったのと同じfind_byメソッドを使う。

User.find_by(id: session[:user_id])

この方法だと、IDが無効(ユーザーが存在しない)の場合でもメソッドはエラーを発生せずにnilを返してくれる。
(Usersテーブルから目的のidの値を持ったユーザーを探している為)

この手法を使い、current_userに以下のように定義

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

if文を使うことで、ユーザーが存在しない場合はnilを返して終わり。いない場合でも何回もDBへ問い合わせしていないので早い。非常に便利。

いる場合は、ログインユーザーのidとDBのidが同じユーザーを返している。

さらに、Rubyの慣習に従って、User.find_byの実行結果をインスタンス変数に代入する。

こうすることで、1リクエスト内におけるDBへの問い合わせは最初の一回だけになり、以後の呼び出しではインスタンス変数の結果を再利用するだけになる。
これが、Webサービスを高速化させる重要なテクニック。

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

こうすると、既に@current_user(ログインユーザー)がいればユーザーを表示し、
いなければユーザーを@current_userに代入できる。

さらに、or演算子||を使えれば、先ほどのメモ化コードが次のように一行でかける。

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

Userオブジェクトそのものの論理値は常にtrueな為、@current_userに何も代入されていない時だけ、find_byが読み出される。つまり、DBへの無駄な読み出しが行われなくなる。

Rubyではさらに短縮形で書く。

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

||=は、手前の@current_userがあれば@current_userに代入、なければUser.find〜を代入、という意味となる。

この概念をor equalsと呼ぶ。

上記コードと近い例をConsoleで行うと

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

このようになる。

1回目の代入では、@fooがnilなので、またはのbarを代入している。
2回目の代入では、@fooに値があるので、またはの値は変えずにそのままbarを返している。

or演算子なので、
1 or 1 = true
1 or 0 = true
0 or 1 = true
0 or 0 = false

となる。

nilの論理値はfalseなので、
1回目の代入は0(nil) or 1(bar) = trueで、最初にtrueとなったbarが代入される。
2回目の代入は1(bar) or 0(baz) = trueで、最初にtrueとなったbarが代入される。

このような評価法を短絡評価(short-circuit evaluation)

この記法をcurrent_userで使うと

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

となる。

これをsessions_helperに組み込む。

sessions_helper.rb
  # 現在ログイン中のユーザーを返す(いる場合)
  def current_user
    if session[:user_id]                                                        # ログインユーザーがいればtrue処理
      @current_user ||= User.find_by(id: session[:user_id])                     # ログインユーザーがいればそのまま、いなければcookiesのユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    end
  end

演習

1:Railsコンソールを使ってUser.find_by(id:) ...で対応するユーザーが検索していなかった時にnilを返すことを確かめる。

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

nilを返している。つまり、find_by(id: "id")は該当ユーザーが見つからなければnilを返すので、先ほどのコードでエラーが起きない。

2::user_idキーを持つsessionハッシュを作成し、||=演算子がうまく動くか確認

>> session = {}
=> {}
>> session[:user_id] = nil
=> nil
>> @current_user ||= User.find_by(id: session[:user_id])
  User Load (0.9ms)  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.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "YUUKI", email: "yuukitetsuyanet@gmail.com", created_at: "2018-12-29 11:56:17", updated_at: "2018-12-29 11:56:17", password_digest: "$2a$10$SN6Plpt.OfT083vHx5IkR.1qxNjD1gVOGLArYothggk...">
>> @current_user ||= User.find_by(id: session[:user_id])
=> #<User id: 1, name: "YUUKI", email: "yuukitetsuyanet@gmail.com", created_at: "2018-12-29 11:56:17", updated_at: "2018-12-29 11:56:17", password_digest: "$2a$10$SN6Plpt.OfT083vHx5IkR.1qxNjD1gVOGLArYothggk...">
>> 

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

ユーザーがログインしている時と、ログインしていない時でレイアウトを変更してみる。

ログイン時には

  • ユーザー一覧
  • プロフィール表示
  • ユーザー設定
  • ログアウト

リンクを追加する。

また、Bootstrapを使って、
Accountリンクに触れると、プロフィールとユーザー設定、ログアウトリンクが出てくるようにします。

image.png

出典図 8.7: ログイン成功後のユーザープロフィール画面のモックアップ

このようにレイアウトのリンクを変更するには、if-else文を使用してログインユーザー用のリンクと、ログインしていないユーザー用のリンクの記述を行う。

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

このコードを書くためには、論理値を返すlogged_in?メソッドが必要なので、sessions_helperでそれを定義する。

sessions_helper.rb
  # 渡されたユーザーでログインする
  def log_in(user)                                                              # login_inメソッドにuser(ログイン時にユーザーが送ったメールとパス)を引数として渡す
    session[:user_id] = user.id                                                 # ユーザーidをsessionのuser_idに代入(ログインidの保持)
  end

  # 現在ログイン中のユーザーを返す(いる場合)
  def current_user
    if session[:user_id]                                                        # ログインユーザーがいればtrue処理
      @current_user ||= User.find_by(id: session[:user_id])                     # ログインユーザーがいればそのまま、いなければcookiesのユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    end
  end

  # ユーザーがログインしていればtrue、それ以外ならfalse
  def logged_in?
    !current_user.nil?                                                          # current_user(ログインユーザー)がnilじゃないならtrue、それ以外ならfalseを返す
  end

ここで!を先頭に付けることによって、否定演算子(not)を使い、本来ならnilならtrueの所をnilじゃないならtrueにしている。

nilじゃないということは、ログインしている(current_userに値が入っている)状態でtrueとなる。

これでユーザーログイン用のレイアウトを使えるようになった。

なお、新しく作るリンクは4つだが、そのうちUsersSettingsの二つは後で実装する。

ログアウト用リンクでは、以前定義したログアウト用パスを使う。

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

第三引数にmethod: :deleteを使うことにより、メソッドの指定を行なっている。

また、上記のコードではログアウト用リンクの引数としてハッシュを渡している。
これはHTTP のDELETEリクエストを使うよう指示している。

プロフィール用リンクは

<%= link_to "Profile", current_user %>

なお、例によって

<%= link_to "Profile", user_path(current_user) %>

と記述できるが、省略した書き方の方が、Railsによって自動でリンクを生成されるので便利。

次に、ユーザーがログインしていない場合は、ログイン用パスを使って以下のようにフォームをログインフォームへのリンクを作成する。

<%= link_to "Log in", login_path %>

これをヘッダーのパーシャル部分に書く。

_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>                        <!-- ログインURLを自動生成-->
          <% end %>
      </ul>
    </nav>
  </div>
</header>

ここで、Account文字にBootstrapのdropdown-menuを使っている点に注目。
レイアウトに新しいリンクを追加したので、このような階層型のドロップダウン属性dropdowndata-toggleに指定することで、ドロップダウンメニューを実現している。

ただし、これらの機能を有効にするためにはRailsのapplication.jsファイルを通して、Bootstrapに同梱されているJavaScriptライブラリとjQueryを読み込むようアセットパイプラインに指示する。

application.js
//= require rails-ujs
//= require jquery
//= require bootstrap
//= require turbolinks
//= require_tree .

この時点で有効なユーザーとしてログインできるようになっているので、効率よくテストできるようになった。

とりあえず、コードが有効化されているか、ログインしてみる。

ログイン状態

スクリーンショット 2019-01-01 19.05.26.png

ログアウト(cookies削除)

スクリーンショット 2019-01-01 19.09.35.png

演習

1:確認済み

2:もう一度ログインし、ヘッダーのレイアウトが変わったことを確認。
その後、ブラウザを再起動し、再び非ログイン状態に戻ったことを確認。

確認済み
ブラウザを再起動しても消えなかった。再起動時のセッション情報も破棄したが直らず。Chromeの問題?
一応クッキーの有効期限はブラウザ終了時となっているからいいか。

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

ログイン成功を手動で確認したので、ここで統合テストを書く。
実際に動作をテストで再現し、今回の回帰バグの発生をキャッチできるようにする。

テスト手順は以下

①ログイン用のパスを開く。
②セッション用パスに有効な情報をpostする
③ログイン用リンクが表示されなくなったことを確認する
④ログアウト用リンクが表示されていることを確認する
⑤プロフィール用リンクが表示されていることを確認する

クッキーを使うので、上の変更を確認する為にはテスト時に登録済みユーザーとしてログインしておく必要がある。

要はDBにログイン済みユーザーが登録されていなければならないが、それはテスト内だけではできない。
Railsでは、このようなテスト用データをfixtureで作成できる。

6章でfixtureを削除したので、今度は自分で空のfixtureファイルを作成してデータを追加する。

fixtureには有効な名前とメールアドレスを持ったユーザーを一人用意する。
さらに、Sessionsコントローラのcreateアクションに送信されたパスワードと比較できるよう、
password_digest属性も追加する。
その為に、digestメソッドを独自に定義する。

has_secure_passwordを使うとbcryptパスワードが作成されるので、
bcryptパスワードハッシュ化→fixture用のパスワード にする。

なお、Railsでは次のコードでパスワードをハッシュ化している。

BCrypt::Password.create(string, cost: cost)

string=ハッシュ化する文字列
cost=コストパラメータ

costパラメータはハッシュを算出する為の計算コストを指定する。
コストパラメータ値が高いほど復号化が困難になる為、本番環境ではコストパラメータを設定する。

なお、テスト中は計算コストを下げて軽くしたい。
それには、secure_passwordのソースコードが参考になる。

ダイジェストメソッド
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                             BCrypt::Engine.cost

上記ではMIN_COSTでコストパラメータを最小にし、
costでしっかりとしたコストパラメータを渡している。

上記のdigestメソッドは今後活用するので、Userモデル(User.rb)に置いておく。
この計算はユーザーごとに行う必要はなく、インスタンスメソッドで定義する必要はない。

そのため、digestメソッドはUserクラス自身に配置してクラスメソッドとする。

user.rb
  #fixture用に、password_digestの文字列をハッシュ化して、ハッシュ値として返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine::cost
    BCrypt::Password.create(string, cost: cost)
  end

このdigestメソッドを用いて有効なユーザーを表すfixtureを作成

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

fixtureではERBを利用して、先ほど定義したdigestメソッドを使用している。
(password)をstringで文字列に変換してハッシュ化している)

fixtureではハッシュ化されていない生のパスワードは参照できない。
理由として、password属性が存在しない点が挙げられる。
(has_secure_passwordの機能を使って、passwordとpasswordconfirmationの値をデータベースにpassword_digestの値として追加していたから)

つまり、passwordという文字列をテスト用のfixtureのpassword_digestに与えることで、
ハッシュ化前のパスワード(password)を読み出すことができる。
(password_digestの平文がpassword)

有効なユーザーをfixtureで作成出来たので、テストでfixtureのデータを参照する。

   test "login with valid information" do
    get login_path                                                              # ログインURL(/login)のnewアクションを取得
    post login_path, params: { session: { email:     @user.email,               # ログインURL(/login)のcreateアクションへデータを送り、paramsでセッションハッシュのemailにmichaelの(有効な)email
                                          password: 'password' } }              # passwordに'password'を渡す 要はfixtureで定義したmichaelでログインするということ
    assert_redirected_to @user                                                  # rediret先が@user(fixtureのmichaelのid)正しければtrue
    follow_redirect!                                                            # @userのurlに移動
    assert_template 'users/show'                                                # users/showで描画されていればtrue
    assert_select 'a[href=?]', login_path, count: 0                             # login_path(/login)がhref=/loginというソースコードで存在しなければtrue(0だから)
    assert_select 'a[href=?]', logout_path                                      # logout_path(/logout)が存在すればtrue
    assert_select 'a[href=?]', user_path(@user)                                 # michaelのidを/user/:idとして受け取った値が存在すればtrue
  end

テストはパスする。

演習

1:Sessionヘルパーのlogged_in?メソッドから!を削除し、テストが失敗することを確認。

確認済み。

2:!を消してテストはパスするか

確認済み。

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

ユーザーアカウントの新規作成後に、直接自動でログインされるような仕組みを作る。

アカウント作成時にログインするには、Usersコントローラのcreateアクション(新規作成のメソッド)にlog_in(sessions_helperで定義した)を追加する。

users_controller.rb
  def create
    @user = User.new(user_params)                                               # newビューにて送ったformの中身(nameやemailの値)をuser_paramsで受け取り、ユーザーオブジェクトを生成、@userに代入
    if @user.save
      log_in @user                                                              # log_inメソッド(ログイン)の引数として@user(ユーザーオブジェクト)を渡す。要はセッションに渡すってこと
      flash[:success] = "ようこそYUUKIのサイトへ"                                    # flashの:successシンボルに成功時のメッセージを代入
      redirect_to @user                                                         #(user_url(@user) つまり/users/idへ飛ばす(https://qiita.com/Kawanji01/items/96fff507ed2f75403ecb)を参考
    else
      render 'new'
    end
  end

上記の動作をテストするため、sessions_helperに対して、is_logged_inヘルパーをメソッドを定義する。
このヘルパーメソッドはテストのセッションにユーザーがあればtrueを、それ以外の場合はfalseを返す。

ただ、ヘルパーメソッドはテストから呼び出せない。なので、ここでは呼び出せるsessionメソッドを代わりに使う。

ヘルパーメソッド→テストで呼び出せない
メソッド→テストで呼び出せる

test_helper.rb
  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?                                                     # セッションが空ならfalse、空じゃない(ログインしていれば)true
  end

これのsessionメソッドの部分のみusers_signup_test.rbで呼び出す。

users_signup_test.rb
  test "valid signup information" do                                            # 新規登録が成功(フォーム送信)したかのテスト
    get signup_path                                                             # signup_path(/signup)ユーザー登録ページにアクセス
    assert_difference 'User.count', 1 do                                        # User.countでユーザー数をカウント、1とし、ユーザー数が変わったらtrue、変わってなければfalse
      post users_path, params: { user: { name:                  "Example User", # signup_path(/signup)からusers_path(/users)へparamsハッシュのuserハッシュの値を送れるか検証
                                         email:                 "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!                                                            # 指定されたリダイレクト先(users/show)へ飛べるか検証
    assert_template 'users/show'                                                # users/showが描画されているか確認
    assert_not   flash.blank?                                                   # flashが空ならfalse,空じゃなければtrue
    assert is_logged_in?                                                        # 新規登録時にセッションが空じゃなければtrue
  end

テストがパスする。

演習

1:users_controllerのlog_inの行をコメントアウトするとテストは失敗するか?

A.失敗する。なぜなら、新規登録時にセッションにユーザーオブジェクトを渡さないと自動ログインされず、先ほどのテストに引っ掛かるから

2:users_controllerの行を全部コメントアウトするには?またコメントアウトするとテストが失敗、元に戻すと成功することも確認する。

A.目的の行をコメントアウトするには、全行をドロップ&ドラッグしてcommand + /でコメントアウトできる。

8.3 ログアウト

ログアウト機能は、既にリンクは作ってあるので、ユーザーセッションを破棄するための有効なアクションをコントローラで作成するだけで済む。

これまでsessionsコントローラのアクションはRESTfulルールに従っており、例えばnewでログインページを表示し、createでログイン完了、といった具合で行っていた。

セッションを破棄する際もdestroyアクションを使えばいいのだが、ログインの場合とは異なり、
ログアウト処理は一ヶ所で行える

具体的には、log_inメソッドの実行結果を取り消して、セッションからユーザーIDを削除するだけ。

session.delete(:user_id)

user_idを引数に取り、sessionにdeleteメソッドを渡すだけ。

後にログイン済みでない場合でも即座にルートURLにリダイレクトするよう設定するので、このコードは特に問題にならない。

次にSessionヘルパーモジュールに配置するlog_outメソッドとして以下のように定義する。

sessions_helper.rb
  # ユーザーをログアウトする

  def log_out
    session.delete(:user_id)                                                    # セッションのuser_idを削除する
    @current_user = nil                                                         # 現在のログインユーザー(一時的なcookies)をnil(空に)する
  end

このヘルパーメソッドを使うために、sessions_controllerにてdestroyメソッドを定義し、log_outを使う。

sessions_controller.rb
  def destroy
    log_out                                                                     # ログアウトする
    redirect_to root_url                                                        # homeへ移動
  end

さらにログアウト機能をテストする。
まずはユーザーログインの統合テストに以下のような記述を追加する。

users_login_test.rb
  test "login with valid information followed by logout" do                     # ログインとログアウトのテストを行う
    # ログイン用
    get login_path                                                              # ログインURL(/login)のnewアクションを取得
    post login_path, params: { session: { email:     @user.email,               # ログインURL(/login)のcreateアクションへデータを送り、paramsでセッションハッシュのemailにmichaelの(有効な)email
                                          password: 'password' } }              # passwordに'password'を渡す 要はfixtureで定義したmichaelでログインするということ
    assert is_logged_in?                                                        # テストユーザーがログイン中ならtrue
    assert_redirected_to @user                                                  # rediret先が@user(fixtureのmichaelのid)正しければtrue
    follow_redirect!                                                            # @userのurlに移動
    assert_template 'users/show'                                                # users/showで描画されていればtrue
    assert_select 'a[href=?]', login_path, count: 0                             # login_path(/login)がhref=/loginというソースコードで存在しなければtrue(0だから)
    assert_select 'a[href=?]', logout_path                                      # logout_path(/logout)が存在すればtrue
    assert_select 'a[href=?]', user_path(@user)                                 # michaelのidを/user/:idとして受け取った値が存在すればtrue

    #ログアウト用
    delete logout_path                                                          # ログアウトリンクが消えたらtrue
    assert_not is_logged_in?                                                    # テストユーザーのセッションが空、ログインしていなければ(ログアウトできたら)true
    assert_redirected_to root_url                                               # Homeへ飛べたらtrue
    follow_redirect!                                                            # リダイレクト先(root_url)にPOSTリクエストが送信ができたらtrue
    assert_select "a[href=?]", login_path                                       # login_path(/login)がhref=/loginというソースコードで存在していればtrue
    assert_select "a[href=?]", logout_path,      count: 0                       # href="/logout"が存在しなければ(0なら)true
    assert_select "a[href=?]", user_path(@user), count: 0                       # michaelのidを/user/:idとして受け取った値が存在しなければtrue
  end

test_helperでis_logged_in?ヘルパーメソッドを定義し、利用できるようにしたおかげで、
is_logged_in?を書くだけで、sessionが空ならfalse(これをassert_notで空ならtrueにした)というテストを実現できた。

(本来なら使えないヘルパーメソッドを、test_helper(テスト用のヘルパーに書くことで)経由で使えたということ)

演習

1:ブラウザからLog outリンクをクリックし、どんな変化が起こるか、またテスト通りに動いているか

A.確認済み

2:cookiesの内容を調べてみて、ログアウト後にはsessionが正常に削除されていることを確認

消えてないけどChromeとの互換性の問題だし大丈夫。ログアウトできてるので

8.4 最後に

いつも通り、masterブランチにマージ

rails t
git add -A
git commit -m "Implement basic login"
git checkout master
git merge basic-login

テストしてgitにpush、herokuにデプロイ

rails t
git push
git push heroku

第9章へ

単語集

  • Authentification System

ブラウザがログインしている状態を保持し、ユーザーがブラウザを閉じたらログイン状態を破棄する(ログアウト)認証システムのこと。

  • current user

ログイン済みのユーザーのこと。Railsでは、このcurrent userのみがアクセスできるページ、扱える機能などを作っていく。

  • Authorization Model

current userなどを組み合わせ、制御や制限の仕組みを実現したモデルのこと。
例えば、ログイン済みかどうかでヘッダー部分の表示を切り替える仕組みも、 Authorization Modelである。

  • ステートレスプロトコル

あるリクエストをした際、必ず結果が同じになるプロトコル。それまでのリクエストやレスポンスは関係なく、今来たリクエストをその通りに受け取り、返す。
主なプロトコルに、HTTP,UDP,IP,DNSがある。

  • ステートフルプロトコル

状況によって、あるリクエストをしたらレスポンスが変化するプロトコル。
主なプロトコルに、FTPTCPBGPOSPFEIGRPSMTPSSHがある。

  • Cookies

クッキーとは、ユーザーのブラウザに保存される小さなテキストデータのこと。
クッキーはあるページから別のページに移動しても破棄されないので、ユーザーidなどの情報を保存できる。

  • grep

Linuxコマンド。grepはファイル中の文字列に対して、正規表現を使って検索し表示する。

使い方

$ rails routes | grep sessions
  • フラッシュ

テキストメッセージを一時的に格納しておく変数。1回リクエストを受けたら消える性質を持っている。

  • or equals

演算子を省略して書く記法。たとえば

x = x * 1

x += 1

と省略できる。

  • fixture

テスト用のデータベースを作成できるファイルのこと

  • delete

指定したオブジェクトの引数の文字列をnilにできるメソッド。

23
17
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
23
17