noggy’s blog

自分用の備忘録です。。。

8.2 ログイン

有効な値の送信があった場合にログインできるようにする。
この節では、cookiesを使った一時セッションでユーザーをログインできるようにする。cookiesは、ブラウザを閉じると自動的に有効期限が切れるものを使う。(9.1では、ブラウザを閉じても保持されるセッションを追加する)

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

実は、Sessionsコントローラを生成した時点で既にセッション用ヘルパーモジュールも自動生成されていた。さらに、Railsのセッション用ヘルパーはビューにも自動的に読み込まれる。Railsの全コントローラの親クラスであるApplicationコントローラにこのモジュールを読みこませれば、どのコントローラでも使えるようになる。

application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper ←①追加                                                        
end

①:Applicationコントローラに読み込ませて、どのコントローラでも使えるようにする。

8.2.1 log_inメソッド

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

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

session[:user_id] = user.id

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

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

helpers/sessions_helper.rb ←①

module SessionsHelper

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

①:Sessionに関するコードなので、Sessionヘルパーに作成
②:初めてコードを見た人が分かるようなメソッドを作る

一時cookiesと永続的cookies

sessionメソッドで作成した一時cookiesは自動的に暗号化され、上のコードは保護される。
そしてここが重要なのだが、攻撃者がたとえこの情報をcookiesから盗み出すことができたとしても、それを使って本物のユーザーとしてログインすることはできない。

ただし、これらのことは、sessionメソッドで作成した「一時セッション」にしか該当しなし。cookiesメソッドで作成した「永続的セッション」ではそこまで断言できない。永続的なcookiesには、セッションハイジャックという攻撃を受ける可能性がつきまとうから。

log_inというヘルパーメソッドを定義できたので、ユーザーログインを行ってセッションのcreateアクションを完了し、ユーザーのプロフィールページにリダイレクトする準備ができた。

  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

  def destroy
  end

①では

  def log_in(user)                                                            
    session[:user_id] = user.id                                                 
  end

により、Session情報が保存
②:ユーザーのプロフィールページにリダイレクト

今はログインしても画面表示が何も変わらないので、ユーザーがログイン中かどうかは、ブラウザセッションを直接確認しない限り分からない。それでは困るので、ログインしていることが分かるようにする。

そこで次で、セッションに含まれるIDを利用して、DBから現在のユーザー名を取り出して画面で表示する。
さらに後で、アプリケーションのレイアウト上のリンクを変更する。リンクをクリックすると、現在ログインしているユーザーのプロフィールを表示できるようにする。

8.2.2 現在のユーザー

ユーザーIDを一時セッションの中に安全に置けるようになったので、今度はそのユーザーIDを別のページで取り出すようにする。
そのために、current_userメソッドを定義して、セッションIDに対応するユーザー名をDBから取り出せるようにする。

current_userメソッド

したいこと

このメソッドの目的は、

<%= current_user.name %>

このようなコードをかけ、現在のユーザー名が表示されるようにしたい。また、ユーザーのプロフィールページに簡単にリダイレクトできるようにもしたい。

redirect_to current_user
したいことを達成するためのアイデア

このとき現在のユーザーを検索する方法として思いつくのは、findメソッド

User.find(session[:user_id])

しかし既に経験済みのとおり、ユーザーIDが存在しない状態でfindを使うと例外が発生してしまう。
「ユーザーがログインしていない」などの状況が考えられる今回のケースでは、session[:user_id]の値はnilになりえる。

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

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

今度は、IDが無効な場合(ユーザーが存在しない場合)にもメソッドは例外を発生せず、nilを返してくれる。
この手法を使い、current_userに以下のように定義し直す。

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

これでも正常に動作するが、current_userメソッドが1リクエスト内で何度も呼び出されると、呼び出さえた回数と同じだけDBにも問い合わせをしてしまう。

そこで、Rubyの慣習に従って、User.find_byの実行結果をインスタンス変数に保存することにする。こうすることで、DBの読み出しは最初の一回だけになり、以後の呼び出しではインスタンス変数を返すようになる。Railsを高速化させるための重要なテクニック。

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

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

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

@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メソッドに適用した結果を示す。
helpers/sessions_helper.rb

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

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

8.2.3 レイアウトリンクの変更

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

ログイン時には、「ログアウト」、「ユーザー設定」、「ユーザー一覧」、「プロフィール表示」リンクを追加。
また、Bootstrapを使って、[Accountリンク]に「プロフィール」と「ユーザー設定」と「ログアウト」が表示されるようにする。

レイアウトのリンクを変更する方法として考えらえるのは、if-else文を使用して、条件に応じて表示するリンクを使いわけること。

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

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

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

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

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

①:「!」を先頭に付けることで、否定演算子(not)を使い、本来nilならtrueとなるところを、nilでないならtrueとなるようにしている。nilでないということは、ログインしている状態でtrueとなる。

ログイン時とそうでない時のレイアウトの変更

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

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

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

プロフィール用リンクも次のように変更。

<%= link_to "Profile", current_user %>

なお、上のコードは省略形で、次のように書くこともできる。

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

しかし、この状況ではcurrent_userを使う方が、Railsによってuser_path(current_user)に変換され、プロフィールへのリンクが自動的に生成されるので便利のようだ。

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

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

以上を踏まえると、

views/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>  ←追加。10章で実装                                
          <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

レイアウトに新しいリンクを追加したので、Bootstrapのドロップダウン機能を適用できるようになった。具体的には、BootstrapにふくまれるCSSのdropdownクラスやdropdown-menuなどを使っている。これらのドロップダウン機能を有効にするため、Railsのapplication.jsファイルを通して、Bootstrapに同梱されているJavaScriptライブラリとjQueryを読み込むようアセットパイプラインに指示する。

app/assets/javascripts/application.js

//= require rails-ujs
//= require jquery ←追加
//= require bootstrap ←追加
//= require turbolinks
//= require_tree .

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

8.2.4 レイアウトの変更のテスト

アプリでのログイン成功を手動で確認したので、統合テストを書いて動作をテストで表し、回帰バグの発生をキャッチできるようにする。

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

上の変更を確認するためには、テスト時に登録済みユーザーとしてログインしておく必要がある。
当然ながら、DBにそのためのユーザーが登録されていなければなりません。Railsでは、このようなテスト用データをfixture(フィクスチヤ)で作成でき、fixtureを使って、テストに必要なデータをtestデータベースに読み込んでおくことができる!うーん、便利!

fixture

現時点では、ユーザーは1人で十分。そのユーザーには有効な名前と有効なメールアドレスを設定しておく。

さらに、テスト中にそのユーザーとして自動ログインするために、有効なパスワードも用意しておき、Sessionsコントローラのcreateアクションに送信されたパスワードと比較できるようにする必要がある。
さらに、password_digest属性をユーザーのfixtureに追加する必要がある。そのため、digestメソッドを独自に定義する。

digestメソッドの作成とsecure_passwordのコード

has_secure_passwordにより、bcryptパスワードが作成された。同じ方法で、fixture用のパスワードを作成する。
Railsのsecure_passwordのソースコードより、次の部分でパスワードを生成している。

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

string:ハッシュ化する文字列
cost:コストパラメータ
とのこと。コストパラメータでは、ハッシュを算出するための計算コストを指定する。コストパラメータの値を高くすえば、ハッシュからのオリジナルのパスワードを計算で推測するのが困難になるため、本番環境ではセキュリティー上重要。

しかし、テスト中はコストを高くする必要はないので、digestメソッドの計算はなるべく軽くしたい。この点についても、secure_passwordのソースコードが参考になる。

cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                             BCrypt::Engine.cost

上記ではコストパラメータをテスト中は最小にしている。

digestメソッドは、いろいろな場面で活用するため、Userモデル(user.rb)におく。
models/user.rb

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

①:この計算はユーザーごとに行う必要はなく、インスタンスメソッドで定義する必要はない。そこで、Userクラス自身に配置してクラスメソッドとする。

digestメソッドができたので、有効なユーザーを表すfixtureを作成する。
test/fixture/users.yml

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

①:fixtureではERBを利用できる。ここではテストユーザー用の有効なパスワードを作成している。

fixtureではハッシュ化されていない生のパスワードは参照できない。さらに、password属性を追加すると、そのようなカラムはDBに存在しない、というエラーが発生する。そこで、テスト用のfixtureでは全員同じパスワード「password」を使うことで対処する。

test/integration/users_login_test.rb

  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

①:usersはfixtureのファイル名users.ymlのこと。シンボル:michaelはユーザーを参照するための「キー」
②:/signupのnewアクションを取得
③:セッションハッシュを/createにPOST
④:リダイレクト先が@user(fixtureのmichael)のルーティングであればtrue
⑤:リダイレクト先が正しいかチェック
⑥:「assert_select "a[href=?]", 各URL」で、a[href=?]があるかどうかのテスト(?部分に直後の各URLが入る)
つまり、このテストはlogin_pathにgetアクセスしたときに、login_path(/login)がhref=/loginというソースコードで存在しなければtrue(0だから)

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

以上で認証システムが動作するようになったが、今のままでは、登録したのにまたログインさせることになり、ユーザーがとまどう可能性がある。登録したら、ログインもまとめてしたい。登録中にログインするには、Usersコントローラのcreateアクションにlog_inを追加するだけでok。ここで、log_inは

def log_in(user)
  session[:user_id]=user.id
end

であった。

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ヘルパーをメソッドを定義しておく。このヘルパーメソッドは、テストのセッションにユーザーがあればtrueを返し、それ以外の場合はfalseを返す。

ただ、ヘルパーメソッドはテストから呼び出せないので、current_userを呼び出せない。しかし、sesssionメソッドはテストでも利用できるので、これを使う。ここでは、取り違えを防ぐため、logged_in?の代わりにis_logged_in?を使って、ヘルパーメソッド名がテストヘルパーとSessionヘルパーで同じにならないようにしておく。

テスト中のログインステータスを論理値で返すメソッド

test/test_helper.rb

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

test/integration/users_signup_test.rb

test "valid signup information" do                                           
    get signup_path                                                             
    assert_difference 'User.count', 1 do                                      
      post users_path, params: { user: { name:                  
                                         email:                 "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!                                                           
    assert_template 'users/show'                                              
    assert_not   flash.blank?                                                   
    assert is_logged_in? ←追加                                                        
  end