Ruby on Rails チュートリアル

プロダクト開発の0→1を学ぼう

古い過去アーカイブ版を表示しています。史料として残してありますが、既に更新が止まっておりセキュリティ上の問題もあるため、学習で使うときは最新版をご利用ください。

第3版 目次

第8章 ログイン、ログアウト

7でWebサイトでの新規ユーザー登録が行えるようになりましたので、今度はユーザーがログインやログアウトを行えるようにしましょう。ここでは、Webのログインやログアウトで一般的に実装される、以下の3種類の動作をすべて実装することにします。1: ブラウザを閉じるとログインを破棄する (8.18.2)。2: ユーザーのログインを自動で保存する (8.4)。3: ユーザーが「パスワードを保存する (remember me)」チェックボックスをオンにした場合のみログインを保存する (8.4.5)1

本章で開発する認証 (authentication) システムによって、サイトをカスタマイズして現在のユーザーの「ログインステータス」と「ID」に基づいた認可 (authorization) モデルを実装することができます。たとえば、本章ではサイトヘッダーのログイン/ログアウトリンクやプロフィールリンクを改造します。次の9で使用するセキュリティモデルでは、ログインしたユーザーだけが自分のindexページに移動できるようにしたり、正当なユーザーだけが自分のページのプロフィール情報を編集できるようにしたり、管理者だけが他のユーザーをデータベースから削除できるようにしたりします。最終的に、11でマイクロポスト作成時にログイン済みユーザーのIDを使用してマイクロポストとユーザーを関連付け、12で現在のユーザーが他のユーザーをアプリケーション上でフォローできるようにし、それによって相手のマイクロポストのフィードを自分のページに表示できるようにします。

本章では、アプリケーション全体で共通するログインシステムの細かい部分を多数扱うので、その分他の章に比べて長く、難易度も高くなっています。細部にとらわれると苦しくなるばかりなので、この章を完了するためにも、完璧に理解しようとするよりも、とにかく辛抱強く節をひとつずつ終わらせることを優先してください。なお、多くの読者が「この章を2回通して完了すると学習効果が非常に高まった」との報告を寄せてくれています。皆さんも、可能であればこの章を2回通して行うことをおすすめいたします。

8.1 セッション

HTTPステートレスなプロトコルです。文字通り「ステート (state)」が「ない (less)」ので、HTTPのリクエストひとつひとつは、それより前のリクエストの情報をまったく利用できない、独立したトランザクションとして扱われます。HTTPは言ってみれば、リクエストが終わると何もかも忘れて次回最初からやり直す健忘症的なプロトコルであり、過去を捨てた旅から旅の流れ者的なプロトコルです (しかし、だからこそこのプロトコルは非常に頑丈なのです)。この本質的な特性のため、ブラウザのあるページから別のページに移動したときに、ユーザーのIDを保持しておく手段がHTTPプロトコル内「には」まったくありません。ユーザーログインの必要なWebアプリケーションでは、セッションと呼ばれる半永続的な接続をコンピュータ間 (ユーザーのパソコンのWebブラウザとRailsサーバーなど) に別途設定します。セッションはHTTPプロトコルと階層が異なる (上の階層にある) ので、HTTPの特性とは別に (若干影響は受けるものの) 接続を確保できます。(訳注: 昔は離れた相手と一手ずつ葉書をやりとりしてのんびりと将棋を指す酔狂な人がときどきいましたが、将棋の対戦をひとつのセッションと考えれば、その下の郵便システムはHTTP同様ステートレスであり、対戦者同士が盤の状態を保持していれば、郵便システムや郵便配達夫が対戦の進行や内容に一切かかわらなくてもゲームは成立します)。

Railsでセッションを実装する方法として最も一般的なのは、cookiesを使用する方法です。cookiesとは、ユーザーのブラウザに保存される小さなテキストデータです。cookiesは、あるページから別のページに移動した時にも破棄されないので、ここにユーザーIDなどの情報を保存できます。アプリケーションはcookies内のデータを使用して、たとえばログイン中のユーザーが所有する情報をデータベースから取り出すことができます。本節および8.2では、その名もsessionというRailsのメソッドを使用して一時セッションを作成します。この一時セッションは、ブラウザを閉じると自動的に終了します2。続いて8.4では、Railsの別のcookiesメソッドを使用して、もう少し長続きするセッションを追加します。

セッションをRESTfulなリソースとしてモデリングできると、他のRESTfulリソースと統一的に理解できて便利です。具体的には、ログインページではnewで新しいセッションを作成するためのフォームを描画します。その後、そのフォームから情報を送信するとcreateでセッションを実際に作成し、保存します。最後に、ログアウトするとdestroyでセッションを破棄する、といった具合です。ただしUsersリソースと異なるのは、UsersリソースではバックエンドでUserモデルを介してデータベース上の永続的データにアクセスするのに対し、Sessionリソースでは代わりにcookiesを保存場所として使用する点です。ログインのしくみの大半は、cookiesを使用した認証メカニズムによって構築されています。本節と次の節では、セッション機能を作成する準備として、Sessionコントローラ、ログイン用のフォーム、両者に関連するコントローラのアクションを作成します。8.2では、セッションを操作するために必要なコードをいくつか追加し、ユーザーログインを完成させる予定です。

前章同様に、トピックブランチで作業してから、最後に更新をマージします。

$ git checkout master
$ git checkout -b log-in-log-out

8.1.1 Sessionsコントローラ

ログインとログアウトの要素を、Sessionsコントローラの特定のRESTアクションにそれぞれ対応付けることにします。ログインのフォームは、この節で扱うnewアクションで処理します。createアクションにPOSTリクエストを送信すると、実際にログインします (8.2)。destroyアクションにDELETEリクエストを送信すると、ログアウトします (8.3) (7.1のHTTPメソッドとRESTアクションの関連付けを思い出しましょう)。

最初に、Sessionsコントローラとnewアクションを生成します。

$ rails generate controller Sessions new

newアクションを生成すると、それに対応するビューも生成されます。createdestroyには対応するビューがない (=不要) なので、無駄なビューを作成しないためにここではnewだけを指定しています。7.2のユーザー登録ページのときと同様に、8.1モックアップを元にセッション新規開始用のログインフォームを作成します。

images/figures/login_mockup
図8.1: ログインフォームのモックアップ

Usersリソースのときは専用のresourcesメソッドを使用してRESTfulなルーティングを自動的にフルセットで利用できるようにしました (リスト7.3) が、Sessionリソースではフルセットはいらないので、「名前付きルーティング」だけを使用します。この名前付きルーティングでは、GETリクエストやPOSTリクエストをloginルーティングで、DELETEリクエストをlogoutルーティングで扱います。このルーティングを反映したものをリスト8.1に示します。なお、rails generate controllerで生成された不要なルートは、このリストから削除してあります。

リスト8.1: リソースを追加して標準的なRESTfulアクションをgetできるようにする config/routes.rb
Rails.application.routes.draw do
  root                'static_pages#home'
  get    'help'    => 'static_pages#help'
  get    'about'   => 'static_pages#about'
  get    'contact' => 'static_pages#contact'
  get    'signup'  => 'users#new'
  get    'login'   => 'sessions#new'
  post   'login'   => 'sessions#create'
  delete 'logout'  => 'sessions#destroy'
  resources :users
end

リスト8.1で定義したルーティングのURLやアクション (8.1) は、ユーザー用のURLやアクション (7.1) とだいたい似ています。

HTTPリクエスト URL 名前付きルート アクション 用途
GET /login login_path new 新しいセッションのページ (ログイン)
POST /login login_path create 新しいセッションの作成 (ログイン)
DELETE /logout logout_path destroy セッションの削除 (ログアウト)
表8.1 リスト8.1のセッションルールによって提供されるルーティング

これまでに名前付きルーティングをだいぶ追加してきたので、ここでアプリケーションの全ルーティングを表示できると便利です。rake routesコマンドを実行すればいつでもルーティングのリストを表示できます。

$ bundle exec rake routes
 Prefix Verb   URI Pattern               Controller#Action
     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

今はこのルーティングを完全に理解できる必要はありません。それでもこのリストを何となく眺めてみれば、アプリケーションでサポートされている全アクションがこのリストにあることに気付くと思います。

8.1.2 ログインフォーム

コントローラとルーティングを定義したので、今度は新しいセッションで使用するビュー、つまりログインフォームを整えましょう。8.17.11を比較してみると、ログインフォームとユーザー登録フォームにはほとんど違いがないことがわかります。違いは、4つあったフィールドが [Email] と [Password] の2つに減っていることだけです。

ログインフォームで入力した情報に誤りがあったときは、ログインページをもう一度表示してエラーメッセージを出力します (8.2)。7.3.3では、エラーメッセージの表示にエラーメッセージ用パーシャル (部分テンプレート) を使用しましたが、そのエラーメッセージはActive Recordによって自動的に表示されていたことを思い出しましょう。セッションはActive Recordオブジェクトではないので、上のようにActive Recordがよしなにエラーメッセージを表示してくれるということは期待できません。そこで、ここではフラッシュメッセージでエラーを表示します。

images/figures/login_failure_mockup
図8.2 ログイン失敗時のモックアップ

リスト7.13のときは、以下のようにユーザー登録フォームでform_forヘルパーを使用し、ユーザーのインスタンス変数@userを引数にとっていました。

<%= form_for(@user) do |f| %>
  .
  .
  .
<% end %>

セッションフォームとユーザー登録フォームの最大の違いは、セッションにはSessionモデルというものがなく、そのため@userのようなインスタンス変数に相当するものもない点です。従って、新しいセッションフォームを作成するときには、form_forヘルパーに追加の情報を独自に渡さなければなりません。

form_for(@user)

Railsでは上のように書くだけで、「フォームのactionは/usersというURLへのPOSTである」と自動的に判定しますが、セッションの場合はリソースの名前とそれに対応するURLを具体的に指定する必要があります3

form_for(:session, url: login_path)

適切なform_forを使用することで、リスト7.13のユーザー登録フォームを参考にして、リスト8.2に示したようなモックアップに従ったログインフォームを簡単に作成できます (8.1)。

リスト8.2: ログインフォームのコード 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>

ユーザーがすぐクリックできるように、ユーザー登録ページのリンクを追加してあることにご注目ください。リスト8.2のコードを使用すると、8.3のようにログインフォームが表示されます ([Log in] リンクがまだ効かないので、自分でブラウザのアドレスバーに「/login」とURLを直接入力してください。ログインリンクは8.2.3で動くようにします)。

images/figures/login_form
図8.3: ログインフォーム

生成されたHTMLフォームをリスト8.3に示します。

リスト8.3: リスト8.2で生成したログインフォームの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>

リスト8.3リスト7.15を比較することで、フォーム送信後にparamsハッシュに入る値が、メールアドレスとパスワードのフィールドにそれぞれ対応したparams[:session][:email]params[:session][:password]になることが推測できると思います。

8.1.3 ユーザーの検索と認証

ユーザー登録では最初にユーザーを作成しましたが、ログインでセッションを作成する場合に最初に行うのは、入力が無効な場合の処理です。最初に、フォームが送信されたときの動作を順を追って理解します。次に、ログインが失敗した場合に表示されるエラーメッセージを配置します (モックアップを8.2に示します)。次に、ログインに成功した場合 (8.2) に使用する土台部分を作成します。ここでは、ログインが送信されるたびに、パスワードとメールアドレスの組み合わせが有効かどうかを判定します。

それでは、最初に最小限のcreateアクションをSessionsコントローラで定義し、空のnewアクションとdestroyアクションもついでに作成しておきましょう (リスト8.4)。リスト8.4createアクションの中では何も行われませんが、アクションを実行するとnewビューが出力されるのでこれで十分です。/sessions/newフォームを送信すると8.4のようになります。

リスト8.4: Sessionsコントローラのcreateアクション (暫定版) app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new'
  end

  def destroy
  end
end
images/figures/initial_failed_login_3rd_edition
図8.4: リスト8.4createで最初に失敗したログイン

8.4に表示されているデバッグ情報にご注目ください。8.1.2の終わりでも簡単に触れましたが、paramsハッシュでは、以下のようにsessionキーの下にメールアドレスとパスワードがあります。

---
session:
  email: 'user@example.com'
  password: 'foobar'
commit: Log in
action: create
controller: sessions

ユーザー登録の場合 (7.15) と同様、これらのパラメータはリスト4.10に示したようにネストした (入れ子になった) ハッシュになっていました。特に、paramsは以下のような入れ子ハッシュになっています。ハッシュの中にハッシュがある構造です。

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

これはつまり、

params[:session]

上自体もハッシュであり、以下の要素を含んでいます。

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

その結果、

params[:session][:email]

上はフォームから送信されたメールアドレスであり、

params[:session][:password]

上はフォームから送信されたパスワードです。

要するにcreateアクションの中では、ユーザーの認証に必要なあらゆる情報をparamsハッシュから簡単に取り出せるということです。そして、認証に必要なすべてのメソッドもここまでに学んであります (そうなるように本書を書いたのです)。ここでは、Active Recordが提供するUser.find_byメソッド (6.1.4) と、has_secure_passwordが提供するauthenticateメソッド (6.3.4) を使用します。authenticateメソッドは認証に失敗したときにfalseを返す (6.3.4) ことを思い出しましょう。以上をまとめてユーザーログインを実装したものをリスト8.5に示します。

リスト8.5: ユーザーをデータベースから見つけて検証する 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

ハイライト部分の最初の行 (リスト8.5) では、送信されたメールアドレスを使用して、データベースからユーザーを取り出しています (6.2.5ではメールアドレスをすべて小文字で保存していたことを思い出しましょう。そこでここではdowncaseメソッドを使用して、有効なメールアドレスが入力されたときに確実にマッチするようにしています)。次の行は少しわかりにくいかもしれませんが、Railsプログラミングでは定番の手法です。

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

&& (論理積) は、取得したユーザーが有効かどうかを決定するのに使用します。Rubyではnilfalse以外のすべてのオブジェクトは、真偽値ではtrueになる (4.2.3) という性質を考慮すると、&&の前後の値の組み合わせは8.2のようになります。8.2を見ると、入力されたメールアドレスを持つユーザーがデータベースに存在し、かつ入力されたパスワードがそのユーザーのパスワードである場合のみ、if文がtrueになることがわかります。言葉でまとめると「ユーザーがデータベースにあり、かつ、認証に成功した場合にのみ」となります。

ユーザー パスワード a && b
存在しない 何でもよい (nil && [anything]) == false
有効なユーザー 誤ったパスワード (true && false) == false
有効なユーザー 正しいパスワード (true && true) == true
表8.2 user && user.authenticate(…)の結果の組み合わせ

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

7.3.2では、ユーザー登録のエラーメッセージ表示にUserモデルのエラーメッセージをうまく利用したことを思い出しましょう。ユーザー登録の場合、エラーメッセージは特定のActive Recordオブジェクトに関連付けられていたのでその手が使えました。しかしセッションではActive Recordのモデルを使用していないため、その手が通用しません。そこで、ログインに失敗したときには代わりにフラッシュメッセージを表示することにします。最初のコードをリスト8.6に示します (このコードはわざと少し間違えてあります)。

リスト8.6: ログイン失敗時の処理を扱う (誤りあり) 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

フラッシュメッセージはWebサイトのレイアウトに表示される (リスト7.25) ので、flash[:danger]で設定したメッセージは自動的に表示されます。Bootstrap CSSのおかげで適切なスタイルも与えられます (8.5)。

images/figures/failed_login_flash_3rd_edition
図8.5: ログインに失敗したときのフラッシュメッセージ

本文およびリスト8.6のコメントで述べたように、このコードには誤りがあります。ページにはちゃんとエラーメッセージが表示されていますが、どこが問題なのでしょうか。実は上のコードのままでは、リクエストのフラッシュメッセージが一度表示されると消えずに残ってしまいます。リスト7.24でリダイレクトを使用したときとは異なり、表示したテンプレートをrenderメソッドで強制的に再レンダリングしてもリクエストと見なされないため、リクエストのメッセージが消えません。たとえば、わざと無効な情報を入力して送信してエラーメッセージを表示してから、Homeページをクリックして移動すると、そこでもフラッシュメッセージが表示されたままになっています (8.6)。この問題は8.1.5で修正します。

images/figures/flash_persistence_3rd_edition
図8.6 フラッシュメッセージが消えずに残っている例

8.1.5 フラッシュのテスト

フラッシュメッセージが消えない問題は、このアプリケーションの小さなバグです。コラム3.3で解説したテストガイドラインの教えに従えば、これはまさに「エラーをキャッチするテストを先に書いて、そのエラーが解決するようにコードを書く」に該当する状況です。さっそく、ログインフォームの送信について簡単な統合テストを作成することから始めましょう。この統合テストは、そのままバグのドキュメントにもなり、今後の回帰バグ発生を防止する効能もあります。さらに、今後この統合テストを土台として、より本格的な統合テストを作成するときにも便利です。

アプリケーションのログインの挙動をテストするために、最初に統合テストを生成します。

$ rails generate integration_test users_login
      invoke  test_unit
      create    test/integration/users_login_test.rb

次に、8.58.6の手順をテストコードで再現する必要があります。基本的な流れを以下に示します。

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

上のテスト手順の実装をリスト8.7に示します。

リスト8.7: フラッシュメッセージの残留をキャッチするテスト 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, session: { email: "", password: "" }
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
end

リスト8.7のテストを追加すると、このログインテストはREDになるはずです。

リスト8.8: RED
$ bundle exec rake test TEST=test/integration/users_login_test.rb

上のように、引数でTESTにテストファイルのフルパスを与えると、そのテストファイルだけを実行できます。

リスト8.7の失敗するテストをパスさせるには、本編のコードでflashflash.nowに置き換えます。後者は、レンダリングが終わっているページで特別にフラッシュメッセージを表示することができます。flashのメッセージとは異なり、flash.nowのメッセージはその後リクエストが発生したときに消滅します (リスト8.7ではまさにその手順を再現しています)。置き換えの終わった正しいアプリケーションコードをリスト8.9に示します。

リスト8.9: ログイン失敗時の正しい処理 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になることを確認できます。

リスト8.10: GREEN
$ bundle exec rake test TEST=test/integration/users_login_test.rb
$ bundle exec rake test

8.2 ログイン

無効な値の送信をログインフォームで正しく処理できるようになったので、次は、実際にログイン中の状態での有効な値の送信を、フォームで正しく扱えるようにします。この節では、cookiesを使用する一時セッションでユーザーをログインできるようにします。このcookiesは、ブラウザを閉じると自動的に有効期限が切れるものを使用します。8.4では、ブラウザを閉じても保持されるセッションを追加します。

セッションを実装するには、様々なコントローラやビューでおびただしい数の関数を定義する必要があります。Rubyのモジュールという機能を使用すると、そうした関数を一箇所にパッケージ化できることを4.2.5で学びました。ありがたいことに、Sessionsコントローラ (8.1.1) を生成した時点で既にセッション用ヘルパーモジュールも (密かに) 自動生成されています。さらに、Railsのセッション用ヘルパーはビューにも自動でインクルードされます。Railsの全コントローラのベースクラス (=Application コントローラ) にこのモジュールをインクルードすれば、このアプリケーションのコントローラでも使えるようになります (リスト8.11)。

リスト8.11: ApplicationコントローラにSessionヘルパーモジュールをインクルードする app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

設定が完了したら、いよいよユーザーログインのコードを書き始めましょう。

8.2.1 log_inメソッド

Railsで事前定義済みのsessionメソッドを使用して、単純なログインを行えるようにします (なお、これは8.1.1で生成したSessionsコントローラとは無関係ですのでご注意ください)。このsessionメソッドはハッシュのように扱えるので、以下のように代入します。

session[:user_id] = user.id

上のコードを実行すると、ユーザーのブラウザ内の一時cookiesに暗号化済みのユーザーIDが自動で作成されます。この後のページで、session[:user_id]を使用してユーザーIDを元通りに取り出すことができます。一方、cookiesメソッド (8.4) とは対照的に、sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了します。

同じログイン手法を様々な場所で使い回せるようにするために、Sessionsヘルパーにlog_inという名前のメソッドを定義することにします (リスト8.12)

リスト8.12: log_in関数 app/helpers/sessions_helper.rb
module SessionsHelper

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

sessionメソッドで作成した一時cookiesは自動的に暗号化され、リスト8.12のコードは保護されます。ただし今回使ったsessionメソッドで作成する「一時セッション」と比べて、次章で取り扱うcookiesメソッドで作成する「永続的セッション」ではより注意深くなる必要があります。cookiesを使った動作には、セッションハイジャックという攻撃を受ける可能性が常につきまとうからです。ユーザーのブラウザ上に保存される情報については、8.4でもう少し注意深く扱うことにします。

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

リスト8.13: ユーザーにログインする 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_to user

7.4.1でも使用しました。Railsでは、自動的に上のコードを変換して、以下のようなユーザープロフィールページへのルーティングします。

user_url(user)

リスト8.13createアクションを定義できたので、8.2で定義したログインフォームも正常に動作するようになったはずです。今はログインしても画面表示が何も変わらないので、ユーザーがログイン中かどうかは、ブラウザセッションを直接確認しない限りわかりません。このままでは困るので、ログインしていることがはっきりわかるようにします。そこで8.2.2では、セッションに含まれるIDを利用して、データベースから現在のユーザー名を取り出して画面で表示する予定です。8.2.3では、アプリケーションのレイアウト上のリンクを変更する予定です。このリンクをクリックすると、現在ログオンしているユーザーのプロフィールが表示されます。

8.2.2 現在のユーザー

ユーザーIDを一時セッションの中に安全に置けるようになったので、今度はそのユーザーIDを別のページで取り出すことにしましょう。そのためには、current_userメソッドを定義して、セッションIDに対応するユーザー名をデータベースから取り出せるようにします。current_userメソッドの目的は、以下のようなコードを書けるようにすることです。

<%= current_user.name %>

以下も同様です。

redirect_to current_user

現在のユーザーを検索する方法のひとつとして思い付くのは、ユーザープロフィールページ (リスト7.5) と同様に、以下のfindメソッドを使用することです。

User.find(session[:user_id])

しかし6.1.4で既に経験済みのとおり、ユーザーIDが存在しない状態でfindを使用すると例外が発生してしまいます。findのこの動作は、ユーザープロフィールページでは完全に適切です。IDが無効の場合は例外を発生してくれなければ困るからです。しかし、ユーザーがログインしていないなど多くの状況では、session[:user_id]の値はnilになります。この状態を修正するために、createメソッド内でメールアドレスの検索に使ったのと同じfind_byメソッドを使うことにします。ただし今度はemailではなくidで検索します。

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

今度はIDが無効な場合 (=ユーザーが存在しない場合) にもメソッドは例外を発生せず、nilを返します。

今度はcurrent_userを以下のように定義し直します。

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

これは正常に動作します。しかし少し残念なのは、current_userがページ内で複数使用されていると、同じ回数だけデータベースも呼び出されてしまうことです。そこで、Rubyの慣習に従って、User.find_byの実行結果をインスタンス変数に保存することにします。こうすることで、データベースの読み出しは最初の一回だけになり、以後の呼び出しではインスタンス変数を返すようになります5。地味なようですが、Railsの高速化のために重要なテクニックです。

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

or演算子「||」(4.2.3) を使用すれば、上の「メモ化」コードを以下のようにたった1行で書けます。

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

ここで重要なのは、Userオブジェクトそのものの論理値は常にtrueになることです。そのおかげで、@current_userに何も代入されていないときだけfind_by呼び出しが実行され、無駄なデータベース読み出しが行われなくなります。

上のコードはひとまず動作しますが、実はまだ「Ruby的に」正しいコードではありません。@current_userへの代入は、Rubyでは以下のような短縮形で書くのが王道です。

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

初めてこの記法を見た方はとまどうかもしれません。しかしRubyではこの「||=」記法は広く使用されています (コラム8.1)。

コラム8.1 ||=」とは何なのか

この「||=」(or equals) という代入演算子はRubyで広く使用されているイディオムであり、Ruby開発者を志すならこの演算子に習熟することが重要です。or equalsという概念は一見神妙不可思議に見えますが、他のものになぞらえて考えれば難しくありません。

多くのコンピュータプログラムでは、以下のような記法で変数の値を1つ増やすことができます。

x = x + 1

そして、Ruby (およびC、C++、Perl、Python、Javaなどの多くのプログラミング言語) では、上の演算を以下のような短縮形で表記することもできます。

x += 1

他の演算子についても同様の短縮形が利用できます。

  $ rails console
  >> x = 1
  => 1
  >> x += 1
  => 2
  >> x *= 3
  => 6
  >> x -= 8
  => -2
  >> x /= 2
  => -1

いずれの場合も、という演算子があるときの「x = x ● y」と「x ●= y」の動作は同じです。

Rubyでは、「変数の値がnilなら変数に代入するが、nilでなければ代入しない (変数の値を変えない)」という操作が非常によく使われます。4.2.3で説明したor演算子||を使用すれば、以下のように書くことができます。

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

nilの論理値はfalseになるので、@fooへの最初の代入「nil || "bar"」の評価値は"bar"になります。同様に、2つ目の代入「@foo || "baz"」("bar" || "baz"など) の評価値は"bar"になります。Rubyでは、nilfalseを除いて、あらゆるオブジェクトの論理値がtrueになるように設計されています。さらにRubyでは、||演算子をいくつも連続して式の中で使用する場合、項を左から順に評価し、最初にtrueになった時点で処理を終えるように設計されています (なお、このように||式を左から右に評価し、演算子の左の値が最初にtrueになった時点で処理を終了するという評価法を短絡評価 (short-circuit evaluation) と呼びます。論理積の&&演算子も似たような設計になっていますが、項を左から評価して、最初にfalseになった時点で処理を終了する点が異なります)。

上記の演算子をコンソールセッション上で実際に実行して比較してみると、@foo = @foo || "bar"x = x O yに該当し、O||に置き換わっただけであることがわかります。

  x    =   x   +   1      ->     x     +=   1
  x    =   x   *   3      ->     x     *=   3
  x    =   x   -   8      ->     x     -=   8
  x    =   x   /   2      ->     x     /=   2
  @foo = @foo || "bar"    ->     @foo ||= "bar"

これで、「@foo = @foo || "bar"」は「@foo ||= "bar"」と完全に等しいことが理解できました。この記法を現在のユーザーのコンテキストで使用すると以下のように簡潔なコードで表現できるようになります。

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

お試しあれ。

追記: @foo || @foo = "bar"と書いた場合 (||が左辺にある点に注意)、Rubyの内部では実際にすべての項が評価されます。これは、@foonilfalseの場合に無駄な代入を避ける必要があるためです。しかしこの式の動作では||=記法の動作と同じにならず、説明上不都合なので、上の解説では@foo = @foo || "bar" (||が右辺にある点に注意) という式を用いて説明しました。

前述の簡潔な記法をcurrent_userメソッドに適用した結果をリスト8.14に示します。

リスト8.14: セッションに含まれる現在のユーザーを検索する app/helpers/sessions_helper.rb
module SessionsHelper

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

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

リスト8.14current_userメソッドが動作するようになったので、ユーザーがログインしているかどうかに応じてアプリケーションの動作を変更するための準備が整いました。

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

アプリケーションでのログイン成功を手動で確認したので、先に進む前に統合テストを書いてこの動作をテストで表現し、今後の回帰バグの発生をキャッチできるようにしましょう。リスト8.7を元にテストを作成し、以下の操作手順をテストで記述して確認できるようにします。

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

上の変更を確認するためには、テスト時に登録済みユーザーとしてログインしておく必要があります。当然ながら、データベースにそのためのユーザーが登録されていなければなりません。Railsでは、このようなテスト用データをフィクスチャで作成できます。フィクスチャを使用して、テストに必要なデータをtestデータベースに読み込んでおくことができます。6.2.5では、メールの一意性テスト (リスト6.30) がパスするためにデフォルトのフィクスチャを削除する必要がありました。今度は自分で空のフィクスチャファイルを作成してデータを追加しましょう。

現時点のテストでは、ユーザーはひとりいれば十分です。そのユーザーには有効な名前と有効なメールアドレスを設定しておきます。テスト中にそのユーザーとして自動ログインするために、そのユーザーの有効なパスワードも用意して、Sessionsコントローラのcreateアクションに送信されたパスワードと比較できるようにする必要があります。6.8のデータモデルをもう一度見てみると、password_digest属性をユーザーのフィクスチャに追加すればよいことがわかります。そのために、digestメソッドを独自に定義することにします。

6.3.1で説明したように、has_secure_passwordでbcryptパスワードが作成されるので、同じ方法でフィクスチャ用パスワードを作成します。Railsのsecure_passwordのソースコードを調べてみると、以下のメソッドがあります。

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

stringはハッシュ化する文字列、costコストパラメータと呼ばれる値です。コストパラメータでは、ハッシュを算出するための計算コストを指定します。コストパラメータの値を高くすれば、ハッシュからオリジナルのパスワードを計算で推測することが困難になりますので、production環境ではセキュリティ上重要です。しかしテスト中はコストを高くする意味はないので、digestメソッドの計算はなるべく軽くしておきます。secure_passwordのソースコードには以下の行があります。

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

少々込み入っていますが、コストパラメータをテスト中は最小にし、production環境ではnormal (high) にする方法がわかれば十分です。「?」〜「:」という記法については8.4.5で解説します。

digestメソッドは他にも様々な場所で使用できます。8.4.1ではdigestをUserモデルで再利用します。そこでは、digestメソッドをuser.rbに置くことをすすめています。ダイジェストの計算はユーザーごとに行わなければならないものではないので、フィクスチャファイルなどでわざわざユーザーオブジェクトにアクセスする必然性はありません。そこで、digestメソッドをUserクラス自身に配置してクラスメソッドにすることにしましょう (クラスメソッドの作り方については4.4.1で簡単に説明しました)。変更の結果をリスト8.18に示します。

リスト8.18: フィクスチャ向けのdigestメソッドを追加する app/models/user.rb
class User < ActiveRecord::Base
  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

リスト8.18digestメソッドができたので、有効なユーザーを表すユーザーフィクスチャを作成できるようになりました (リスト8.19)。

リスト8.19: ユーザーログインのテストで使用するフィクスチャ test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

フィクスチャではERBを利用できる点にご注目ください。

<%= User.digest('password') %>

上のERBコードでテストユーザー用の有効なパスワードを作成できます。

has_secure_passwordで必要となるpassword_digest属性はこれで準備できましたが、ハッシュ化されていない生のパスワードも参照できると便利です。しかし残念なことに、フィクスチャではこのようなことはできません。さらに、リスト8.19password属性を追加すると、そのようなカラムはデータベースに存在しないというエラーが発生します。実際、データベースにはそんなカラムはありません。この状況を切り抜けるために、テスト用のフィクスチャユーザーでは全員同じパスワード「password」を使用することにします。これはフィクスチャでよく使われる手法です。

有効なユーザーのフィクスチャを作成できたので、テストで以下のようにフィクスチャデータを参照できます。

user = users(:michael)

上のusersはフィクスチャのファイル名users.ymlを表し、:michaelというシンボルはリスト8.19のユーザーを参照するためのキーを表します。

フィクスチャのユーザーにアクセスできるようになったので、レイアウトのリンクをテストできる状態になりました。レイアウトのリンクをテストするには、前述の操作手順をテストコードに書き換えます (リスト8.20)。

リスト8.20: 有効な情報を使用してユーザーログインをテストする GREEN 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, 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!

上の行では、実際にそのページに移動します。リスト8.20では、ログイン用リンクが表示されなくなったことも確認しています。このチェックは、ログインパスのリンクがページにないかどうかで判定しています。

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

count: 0というオプションをアサーションに追加すると、渡したパターンに一致するリンクがゼロになっているかどうかを確認するようassert_selectに指示します。なお、リスト5.25では、count: 2を使用してリンクが2つあるかどうかを確認しているので、比較してみるとよいでしょう。

アプリケーションのコードは既に動作するようになっているので、ここでテストを実行するとGREENになるはずです。

リスト8.21: GREEN
$ bundle exec rake test TEST=test/integration/users_login_test.rb \
>                       TESTOPTS="--name test_login_with_valid_information"

上のコマンドでは、指定したテストファイル内にある特定のテストだけを実行するために、以下のオプションを追加してあります。

TESTOPTS="--name test_login_with_valid_information"

(上記の2行目にある '>' という文字は、改行を示すためにシェルが自動的に挿入する文字です。手動で入力しないよう、注意してください。) 上のオプションは、テスト名を指定するときに使うオプションです。なお、指定するテスト名は、接頭語の「test_」と、テストの説明文の単語をアンダースコアでつないだ文字列で表します。

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

以上で認証システムが動作するようになりましたが、今のままでは、登録の終わったユーザーがデフォルトではログインしていないので、ユーザーがとまどう可能性があります。ユーザー登録が終わってからユーザーに手動ログインを促すと、ユーザーに余分な手順を強いることになるので、ユーザー登録中にログインを済ませておくことにします。ユーザー登録中にログインするには、Usersコントローラのcreateアクションにlog_inを追加するだけで済みます (リスト8.22)10

リスト8.22: ユーザー登録中にログインする 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

リスト8.22の動作をテストするために、リスト7.26のテストに1行追加して、ユーザーがログイン中かどうかをチェックします。そのために、リスト8.15で定義したlogged_in?ヘルパーメソッドとは別に、is_logged_in?ヘルパーメソッドを定義しておくと便利です。このヘルパーメソッドは、テストのセッションにユーザーがあればtrueを返し、それ以外の場合はfalseを返します (リスト8.23)。残念ながらヘルパーメソッドはテストから呼び出せないので、リスト8.15のようにcurrent_userを呼び出せません。sessionメソッドはテストでも利用できるので、これを代わりに使用します。ここでは取り違えを防ぐため、logged_in?の代わりにis_logged_in?を使用して、ヘルパーメソッド名がテストヘルパーとSessionヘルパーで同じにならないようにしておきます11

リスト8.23: テスト中のログインステータスを論理値で返すメソッド test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログインしていればtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end

リスト8.23のコードを使用すると、ユーザー登録の終わったユーザーがログイン状態になっているかどうかを確認できます (リスト8.24)。

リスト8.24: ユーザー登録後のログインのテスト GREEN 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_via_redirect users_path, user: { name:  "Example User",
                                            email: "user@example.com",
                                            password:              "password",
                                            password_confirmation: "password" }
    end
    assert_template 'users/show'
    assert is_logged_in?
  end
end

これで、テストを実行するとGREENになるはずです。

リスト 8.25: GREEN
$ bundle exec rake test

8.3 ログアウト

8.1で解説したように、このアプリケーションで使用する認証モデルでは、ユーザーが明示的にログアウトするまではログイン状態を保てなくてはなりません。この節では、そのために必要なログアウト機能を追加することにします。ログアウト用リンクはリスト8.16で既に作成済みなので、ユーザーセッションを破棄するための有効なアクションをコントローラで作成するだけで済みます。

これまで、SessionsコントローラのアクションはRESTfulルールに従っていました。newでログインページを表示し、createでログインを完了するといった具合です。セッションを破棄するdestroyアクションも、引き続き同じ要領で作成することにします。ただし、ログインの場合 (リスト8.13リスト8.22) と異なり、ログアウト処理は1か所で行えるので、destroyアクションに直接ログアウト処理を書くことにします。8.4.6でも説明しますが、この設計 (および若干のリファクタリング) のおかげで認証メカニズムのテストが行い易くなります。

ログアウト処理では、リスト8.12log_inメソッドの実行結果を取り消します。つまり、セッションからユーザーIDを削除します12。そのためには、以下のようにdeleteメソッドを実行します。

session.delete(:user_id)

現在のユーザーもnilに設定します。今回は上のコードにより、即座にルートURLにリダイレクトされるので、それほど重要ではありません13log_inおよび関連メソッドのときと同様に、Sessionヘルパーモジュールに配置するlog_outメソッドをリスト8.26に示します。

リスト8.26: 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

log_outメソッドをSessionsコントローラのdestroyアクションでも同様に使用します (リスト8.27)。

リスト8.27: セッションを破棄する (ユーザーログアウト) 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
    log_out
    redirect_to root_url
  end
end

ログアウト機能をテストするために、リスト8.20のユーザーログインのテストに手順を若干追加します。ログイン後、deleteメソッドでDELETEリクエストをログアウト用パス (8.1) に発行し、ユーザーがログアウトしてルートURLにリダイレクトされたことを確認します。ログイン用リンクが再度表示されること、ログアウト用リンクとプロフィール用リンクが非表示になることも確認します。手順を追加したテストをリスト8.28に示します。

リスト8.28: ユーザーログアウトのテスト 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, 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

テストでis_logged_in?ヘルパーメソッドを利用できるようにしてあったおかげで、有効な情報をセッション用パスにpostした直後にassert is_logged_in?で簡単にテストできました。

セッションのdestroyアクションの定義とテストが完成したので、ついに3大機能である「ユーザー登録/ログイン/ログアウト」がすべて完成しました。テストスイートはGREENになるはずです。

リスト 8.29: GREEN
$ bundle exec rake test

8.4 [このアカウント設定を保存する]

8.2で完了したログインシステムは、それ自体で十分完結した機能です。しかし多くのWebサイトでは、ブラウザを閉じた後にもセッションを継続する機能などを追加しているのが普通です。本節では、ユーザーログインをデフォルトで保持するように変更し、ユーザーが明示的にログアウトするまではセッションを期限切れにしないようにします。この後8.4.5では、別の方法として、ログインを保存する「remember-me」チェックボックスを追加して、ログインを継続するかどうかをユーザーが選択できるようにする予定です。どちらの方式も商用に利用できる品質を備えています。前者はGitHubBitbucketで、後者はFacebookTwitterでそれぞれ採用されています。

8.4.1 記憶トークンと暗号化

8.2では、Railsのsessionメソッドを使用してユーザーIDを保存しましたが、この情報はブラウザを閉じると消えてしまいます。本節では、セッションの永続化の第一歩として記憶トークン (remember token) を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト (remember digest) によるトークン認証にこの記憶トークンを活用します。

8.2.1で解説したように、sessionメソッドで保存した情報は自動的に安全が保たれますが、cookiesメソッドに保存する情報は残念ながらそのようにはなっていません。特に、cookiesを永続化するとセッションハイジャックという攻撃を受ける可能性があります。この攻撃は、記憶トークンを奪って、特定のユーザーになりすましてログインするというものです。cookiesを盗み出す有名な方法は4とおりあります。(1) 管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す14。(2) データベースから記憶トークンを取り出す。(3) クロスサイトスクリプティング (XSS) を使う。(4) ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。7.5では、最初の問題を防止するためにSecure Sockets Layer (SSL) をサイト全体に適用して、ネットワークデータを暗号化で保護し、パケットスニッファから読み取られないようにしています。2番目の問題の対策としては、記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存するようにします。これは、6.3で生のパスワードをデータベースに保存する代わりにパスワードのダイジェストを保存したのと同じコンセプトです。3 番目の問題については、Railsによって自動的に対策が行われます。具体的には、ビューテンプレートで入力した内容をすべて自動的にエスケープします。4番目のログイン中のコンピュータへの物理アクセスによる攻撃については、さすがにシステム側での根本的な防衛手段を講じることは不可能なのですが、ユーザーがログアウトしたときにトークンを必ず変更し、機密上重要になる可能性のある情報をブラウザに表示するときには暗号による署名を行うようにすることで、物理アクセスによる攻撃を最小限に留めるようにします。

上で説明した設計やセキュリティ上の考慮事項を元に、以下の方針で永続的セッションを作成することにします。

  1. 記憶トークンにはランダムな文字列を生成して用いる。
  2. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
  3. トークンはハッシュ値に変換してからデータベースに保存する。
  4. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
  5. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。

上の最後の手順が、ユーザーログインのときの手順と似ていることにご注目ください。ユーザーログインでは、メールアドレスをキーにしてユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを (authenticateメソッドで) 確認します (リスト8.5)。つまり、ここでの実装はhas_secure_passwordと似た側面を持ちます。

それでは最初に、必要となるremember_digest属性をUserモデルに追加します (8.9)。

user_model_remember_digest
図8.9: remember_digest属性を追加したUserモデル

8.9のデータモデルをアプリケーションに追加するために、以下のマイグレーションを生成します。

$ rails generate migration add_remember_digest_to_users remember_digest:string

(6.3.1のパスワードダイジェストのときのマイグレーションと比較してみましょう)。前回のマイグレーションと同様、今回のマイグレーション名も_to_usersで終わっています。これは、マイグレーションの対象がデータベースのusersテーブルであることをRailsに指示するためのものです。今回は種類=stringremember_digest属性を追加しているので、いつものようにRailsによってデフォルトのマイグレーションが作成されます (リスト8.30)。

リスト8.30: 記憶ダイジェスト用に生成したマイグレーション db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration
  def change
    add_column :users, :remember_digest, :string
  end
end

記憶ダイジェストはユーザーが直接読み出すことはないので (かつ、そうさせてはならないので)、remember_digestカラムにインデックスを追加する必要はありません。従って、上のマイグレーションは変更せずにそのまま使用します。

$ bundle exec rake db:migrate

ここで、記憶トークンとして何を使用するかを決める必要があります。有力な候補として様々なものが考えられますが、基本的には長くてランダムな文字列であればどんなものでも構いません。Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドならこの用途にぴったりです15。このメソッドは、A–Z、a–z、0–9、“-”、“_”のいずれかの文字 (64種類) からなる長さ22のランダムな文字列を返します (そのためbase64と呼ばれています)。典型的なbase64の文字列は、次のようなものです。

$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"

2人のユーザーのパスワードが完全に同じであれば16、記憶トークンが一意である必然性はなくなりますが、現実にはそんなことはないので、記憶トークンによってセキュリティが高められます17。上のbase64文字列では、22個の文字でそれぞれ64種類の文字が使用される可能性があり、2つの記憶トークンがたまたま完全に一致する (=衝突する) 確率は64のマイナス16乗、2のマイナス96乗 (10のマイナス29乗) にほぼ等しく、トークンが衝突することはまずありません。さらにありがたいことに、base64はURLを安全にエスケープするためにも用いられる (urlsafe_base64という名前のメソッドがあることからもわかります) ので、base64を採用すれば、10でアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使用できるようになります。

ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存します。フィクスチャをテストするときにdigestメソッドを既に作成してあったので (リスト8.18)、上の結論に従って、新しいトークンを作成するためのnew_tokenメソッドを作成できます。この新しいdigestメソッドではユーザーオブジェクトが不要なので、このメソッドもUserモデルのクラスメソッドとして作成することにします18。以上を反映したUserモデルをリスト8.31に示します。

リスト8.31: トークン生成用メソッドを追加する app/models/user.rb
class User < ActiveRecord::Base
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end
end

さしあたっての実装計画としては、user.rememberメソッドを作成することにします。このメソッドは、記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存します。リスト8.30のマイグレーションを行ってあるので、Userモデルには既にremember_digest属性が追加されていますが、remember_token属性はまだ追加されていません。そこで、user.remember_tokenメソッド (cookiesの保存場所です) を使用してトークンにアクセスできるようにする必要があります。しかも、トークンをデータベースに保存せずに実装する必要があります。そのためには、6.3の安全なパスワードの問題のときと同様の手法でこれを解決します。あのときは、「仮想の」password属性と、データベース上のセキュアなpassword_digest属性を使用しました。仮想のpassword属性はhas_secure_passwordメソッドで自動的に作成できましたが、今回はremember_tokenのコードを自分で書く必要があります。これを行うには、4.4.5で行ったようにattr_accessorを使用してアクセス可能な属性を作成します。

class User < ActiveRecord::Base
  attr_accessor :remember_token
  .
  .
  .
  def remember
    self.remember_token = ...
    update_attribute(:remember_digest, ...)
  end
end

rememberメソッドの1行目の代入にご注目ください。selfというキーワードを使用しないと、Rubyによってremember_tokenという名前のローカル変数が作成されてしまいます。この動作は、Rubyにおけるオブジェクト内部への要素代入の仕様によるものです。今欲しいのはローカル変数ではありません。selfキーワードを与えると、この代入によってユーザーのremember_token属性が期待どおりに設定されます (リスト6.31の他のbefore_saveコールバックで、emailではなくself.emailと記述していた理由が、これでおわかりいただけたと思います)。rememberメソッドの次の行では、update_attributeメソッドで記憶ダイジェストを更新しています (6.1.5で説明したように、このメソッドはバリデーションを素通りさせます。ここではユーザーのパスワードやパスワード確認にアクセスできないので、バリデーションを素通りさせなければなりません)。

以上の点を考慮して、有効なトークンとそれに関連するダイジェストを作成できるようにします。具体的には、最初にUser.new_tokenで記憶トークンを作成し、続いてUser.digestを適用した結果で記憶ダイジェストを更新します。rememberメソッドの更新結果をリスト8.32に示します。

リスト8.32: rememberメソッドをUserモデルに追加する GREEN app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続的セッションで使用するユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end

8.4.2 ログイン状態の保持

user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する準備ができました。これを実際に行うにはcookiesメソッドを使用します。このメソッドは、sessionのときと同様にハッシュとして扱えます。個別のcookiesは、ひとつのvalue (値) と、オプションのexpires (有効期限) からできています。有効期限は省略可能です。たとえば以下のように、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができます。

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }

(上のコードではRailsの便利なtimeヘルパーを使用しています。コラム8.2を参照してください)。上のように20年で期限切れになるcookies設定はよく使われるようになり、今ではRailsにも特殊なpermanentという専用のメソッドが追加されたほどです。このメソッドを使用すると、コードは以下のようにシンプルになります。

cookies.permanent[:remember_token] = remember_token

上のコードによって、Railsは自動的に期限を20.years.from_nowに設定します。

コラム 8.2 cookiesは今から20年後に切れる (20.years.from_now)

Rubyは組み込みクラスを含むあらゆるクラスにメソッドを追加できることを4.4.2で学びました。あのときは、palindrome?メソッドをStringクラスに追加しました (ついでに"deified"も回文になっていることを発見しました)。また、Railsが実はblank?メソッドをObjectクラスに追加していることも判明しました (これにより、"".blank?" ".blank?nil.blank?はいずれもtrueになります)。このcookies.permanentメソッドでは、cookiesが20年後に期限切れになる (20.years.from_now) ように指定していますが、これはRailsのtimeヘルパーを使用した格好の例題になります。timeヘルパーはRailsによって、数値関連の基底クラスであるFixnumクラスに追加されます。

  $ rails console
  >> 1.year.from_now
  => Sun, 09 Aug 2015 16:48:17 UTC +00:00
  >> 10.weeks.ago
  => Sat, 31 May 2014 16:48:45 UTC +00:00

Railsは以下のようなヘルパーも追加しています。

  >> 1.kilobyte
  => 1024
  >> 5.megabytes
  => 5242880

上のヘルパーは、ファイルのアップロードに5.megabytesなどの制限を与えるのに便利です。

メソッドを組み込みクラスに追加できる柔軟性の高さのおかげで、純粋なRubyを極めて自然に拡張することができます (もちろん注意して使う必要はありますが)。実際、Railsのエレガントな仕様の多くは、背後にあるRubyの高い拡張性によって実現されているのです。

ユーザーIDをcookiesに保存するには、sessionメソッドで使用したのと同じパターン (リスト8.12) を使用します。具体的には以下のようになります。

cookies[:user_id] = user.id

しかしこのままではIDが生のテキストとしてcookiesに保存されてしまうので、アプリケーションのcookiesの形式が見え見えになってしまい、攻撃者がユーザーアカウントを奪い取ることを助けてしまう可能性があります。これを避けるために、署名付きcookieを使用します。これは、cookieをブラウザに保存する前に安全に暗号化するためのものです。

cookies.signed[:user_id] = user.id

ユーザーIDと永続記憶トークンはペアで扱う必要があるので、cookieも永続化しなくてはなりません。そこで、以下のようにsignedメソッドとpermanentメソッドをチェイン (連鎖) して使用します。

cookies.permanent.signed[:user_id] = user.id

cookiesを設定すると、以後のページのビューで以下のようにしてcookiesからユーザーを取り出せるようになります。

User.find_by(id: cookies.signed[:user_id])

cookies.signed[:user_id]では自動的にユーザーIDのcookiesの暗号が解除され、元に戻ります。続いてbcryptを使用し、cookies[:remember_token]remember_digestと一致することを確認します (リスト8.32)。ところで、署名されたユーザーIDがあれば記憶トークンは不要なのではないかと疑問に思う方もいるかもしれません。しかし記憶トークンがなければ、暗号化されたIDを奪った攻撃者は、暗号化IDをそのまま使ってお構いなしにログインしてしまうでしょう。現在の設計では、攻撃者が仮に両方のcookiesを奪い取ることに成功したとしても、本物のユーザーがログアウトするとログインできないようになっています。

パズルもいよいよ最後のピースを残すだけとなりました。渡されたトークンがユーザーの記憶ダイジェストと一致することを確認します。この一致をbcryptで確認するための様々な方法があります。secure_passwordのソースコードを調べてみると、以下のような比較を行っている箇所があります19

BCrypt::Password.new(password_digest) == unencrypted_password

今回の場合、上のコードを参考に下のようなコードを使用します。

BCrypt::Password.new(remember_digest) == remember_token

このコードをじっくり調べてみると、実に奇妙なつくりになっています。bcryptで暗号化されたパスワードを、トークンと直接比較しています。ということは、==で比較する際にダイジェストを復号化しているのでしょうか。しかし、bcryptのハッシュは復号化できないはずなので、復号化しているはずはありません。そこでbcrypt gemのソースコードを詳しく調べてみると、なんと、比較に使用している==演算子が再定義されています。実際の比較をコードで表すと、以下のようになっています。

BCrypt::Password.new(remember_digest).is_password?(remember_token)

実際の比較では、==の代わりにis_password?という論理値メソッドが使用されています。これで少し見えてきました。今から書くアプリケーションコードでもこれと同じ方法を使用することにしましょう。

以上の説明を元に、ダイジェストトークンの比較をUserモデルのauthenticated?メソッドの中に置けばよいのではないかと推測できます。このメソッドは、has_secure_passwordで提供されるユーザー認証用のauthenticateメソッドと似ています (リスト8.13)。この実装結果をリスト8.33に示します。ところで、このauthenticated?メソッド (リスト8.33) は記憶ダイジェストと強く結びついていますが、実は他の様々な用途にも応用できます。10ではこのメソッドを一般化してみます。

リスト8.33: authenticated?メソッドをUserモデルに追加する app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続的セッションで使用するユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

リスト8.33で定義されているauthenticated?メソッド内のremember_tokenの引数は、リスト8.32attr_accessor :remember_tokenで定義されているアクセサと同じではない点にご注意ください。引数は、メソッド内ローカルな変数になっていますが、記憶トークンを参照しているので問題ありません。メソッドの引数と同じ名前を使用することはよくあります。もうひとつ、remember_digestの属性の使用法にご注目ください。この使用法はself.remember_digestと同じであり、6nameemailの使用法とも似ています。remember_digestの属性は、データベースのカラムに対応してActive Recordによって自動的に作成されます (リスト8.30)。

これで、ログインユーザーの記憶処理を作る準備が整いました。rememberヘルパーメソッドを追加して、log_inと連携させます (リスト8.34)。

リスト8.34: ログインしてユーザーを保持する 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
      remember user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out
    redirect_to root_url
  end
end

log_inのときと同様に、リスト8.34では実際のSessionsヘルパーの動作は、rememberメソッド定義のuser.rememberを呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストをデータベースに保存します。続いて上と同様に、cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成します。変更結果をリスト8.35に示します。

リスト8.35: ユーザーを記憶する app/helpers/sessions_helper.rb
module SessionsHelper

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

  # ユーザーを永続的セッションに記憶する
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

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

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

  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

リスト8.35のコードでは、ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されますが、リスト8.14で定義したcurrent_userメソッドでは一時セッションしか扱っていないので、このままでは正常に動作しません。

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

永続セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要があります。これを行うには、以下のように記述します。

if session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
elsif cookies.signed[:user_id]
  user = User.find_by(id: cookies.signed[:user_id])
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

上のコードでもuser && user.authenticated (リスト8.5) を使用している点にご注目ください。上のコードは動作しますが、今のままではsessioncookiesもそれぞれ2回使用されてしまい、無駄です。これを解消するには、次のようにします。

if (user_id = session[:user_id])
  @current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
  user = User.find_by(id: user_id)
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

上のコードでは、よく使われる以下のような構造が使用されていますが、少し紛らわしい点があります。

if (user_id = session[:user_id])

一見、上のコードは比較を行っているように見えますが、これは比較ではありません。比較であれば==を使用するはずですが、ここでは代入を行っています。このコードを言葉で表すと、「ユーザーIDがユーザーIDのセッションと等しければ...」ではなく、「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザーIDのセッションが存在すれば」となります20

前述のようにcurrent_userヘルパーを定義すると、リスト8.36のようになります。

リスト8.36: 永続的セッションのcurrent_userを更新する RED app/helpers/sessions_helper.rb
module SessionsHelper

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

  # ユーザーを永続的セッションに記憶する
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

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

  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

リスト8.36のコードでは、新しくログインしたユーザーは正しく記憶されます。実際にログインしてからブラウザを閉じ、アプリケーションを再起動してからもう一度ブラウザでアプリケーションを開いてみると、期待どおり動作していることを確認できます。その気になれば、ブラウザのcookiesをブラウザで直接調べて結果を確認することもできます (8.10)21

アプリケーションに現在残された問題はあと1つだけです。ブラウザのcookiesを削除する手段が未実装なので (20年待てば消えますが)、ユーザーがログアウトできません。これは当然テストスイートでキャッチすべき問題であり、REDにならなければなりません。

リスト8.37: RED
$ bundle exec rake test

8.4.3 ユーザーを忘れる

ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義します。このuser.forgetメソッドによって、user.rememberが取り消されます。具体的には、記憶ダイジェストをnilで更新します (リスト8.38)。

リスト8.38: forgetメソッドをUserモデルに追加する app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token
  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

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続的セッションで使用するユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # ユーザーログインを破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
end

リスト8.38のコードを使用すると、永続セッションを終了できるようになる準備が整います。終了するには、forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出します (リスト8.39)。リスト8.39を見ると、forgetヘルパーメソッドではuser.forgetを呼んでからuser_idremember_token cookiesを削除していることがわかります。

リスト8.39: 永続セッションからログアウトする app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 永続的セッションを破棄する
  def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

  # 現在のユーザーがログアウトする
  def log_out
    forget(current_user)
    session.delete(:user_id)
    @current_user = nil
  end
end

8.4.4 2つの目立たないバグ

実は小さなバグが2つ残っています。2つのバグは互いに強く関連しています。1つ目の地味な問題です。ユーザーは場合によっては、同じサイトを複数のウィンドウ (あるいはタブ) で開いていることもあります。ログアウト用リンクはログイン中には表示されませんが、現在のcurrent_userの使用法では、ユーザーが1つのウィンドウでログアウトすると、もう1つのウィンドウでログアウトしたときにエラーになります リスト8.39)22。これは、もう1つのウィンドウにある "Log out" リンクをクリックすると、current_userが既にnilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうからです。この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要があります。

2番目の地味な問題は、ユーザーが複数のブラウザ (ChromeやFirefoxなど) でログインしていたときに生じます。具体的には、一方のブラウザではログアウトし、もう一方のブラウザではログアウトせずに、一度ブラウザを終了させ、再度同じページを開くと、この問題が発生します23。FirefoxとChromeを使った具体例で考えてみましょう。ユーザーがFirefoxからログアウトすると、user.forgetメソッドによって記憶ダイジェストがnilになります (リスト8.38)。この時点では、アプリケーションはFirefoxでまだ正常に動作するはずです。つまり、リスト8.39ではlog_outメソッドによってユーザーIDが削除されるため、ハイライトされている2つの条件がfalseになります。

# 記憶トークンcookieに対応するユーザーを返す
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

結果として、current_userメソッドの最終的な評価結果は、期待どおりnilになります。

一方、Chromeを閉じたとき、session[:user_id]nilになります (これはブラウザが閉じたときに、全てのセッション変数の有効期限が切れるためです)。しかし、cookiesはブラウザの中に残り続けているため、データベースからそのユーザーを見つけることができてしまいます。

# 記憶トークンcookieに対応するユーザーを返す
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

結果として、次のif文の条件式が評価されます。

user && user.authenticated?(cookies[:remember_token])

このときにusernilであれば、1番目の条件式で評価は終了するのですが、実際にはnilではないので、2番目の条件式まで評価が進み、そのときにエラーが発生します。これは、Firefoxでログアウトしたときに (リスト8.38) ユーザーの記憶ダイジェストが削除されているので、Chromeでアプリケーションにアクセスしたとき、最終的に次の文を実行するからです。

BCrypt::Password.new(remember_digest).is_password?(remember_token)

つまり、上の式が評価されるとき、記憶ダイジェストが既にnilになっているため、bcryptライブラリ内部で例外が発生します。この問題を修正するには、authenticated?falseを返すようにする必要があります。

テスト駆動開発は、この種の地味なバグ修正にはうってつけです。そこで、2つのエラーをキャッチするテストを書くことにします。リスト8.28を元に、redになるテストを作成します (リスト8.40)。

リスト8.40: ユーザーログアウトのテスト RED 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, 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
    # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_path
    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

リスト8.40では、current_userがないために2回目のdelete logout_pathの呼び出しでエラーが発生し、テストスイートはredになります。

リスト 8.41: RED
$ bundle exec rake test

リスト8.42のアプリケーションコードでは、logged_in?がtrueの場合に限ってlog_outを呼び出すように変更しました。

リスト8.42: ログイン中の場合のみログアウトする GREEN app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

2番目の問題についてですが、統合テストで2種類のブラウザをシミュレートするのは正直かなり困難です。その代わり、同じ問題をUserモデルで直接テストするだけなら簡単に行えます。記憶ダイジェストを持たないユーザーを用意し (setupメソッドで定義した@userインスタンス変数ではtrueになります)、続いてauthenticated?を呼び出します (Listing 8.43)。この中で、記憶トークンを空欄のままにしていることにご注目ください。記憶トークンが使用される前にエラーが発生するので、記憶トークンの値は何でも構わないのです。

リスト8.43: ダイジェストが存在しない場合のauthenticated?のテスト RED test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end

BCrypt::Password.new(nil)でエラーが発生し、テストスイートはredになります。

リスト 8.44: RED
$ bundle exec rake test

エラーを修正してテストがGREENになるようにするには、記憶ダイジェストがnilの場合にfalseを返すようにすればよいのです (リスト8.45)。

リスト8.45: authenticated?を更新して、ダイジェストが存在しない場合に対応 GREEN app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

ここでは、記憶ダイジェストがnilの場合にはreturnキーワードで即座にメソッドを終了しています。処理を中途で終了する場合によく使われるテクニックです。以下のコードでもよいのですが、

if remember_digest.nil?
  false
else
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

筆者はリスト8.45のように明示的にreturnする方が、コードが若干短くなることもあって好みです。

リスト8.45のコードを使用すると、テストスイート全体がGREENになり、サブタイトルは両方とも修正されるはずです。

リスト 8.46: GREEN
$ bundle exec rake test

8.4.5 “Remember me” チェックボックス

8.4.3のコードで、アプリケーションにプロ仕様の完全な認証システムが導入されました。本章の最後に、[remember me] チェックボックスでログインを保持する方法を解説します。チェックボックスを追加したモックアップを8.11に示します。

images/figures/login_remember_me_mockup
図8.11: [remember me] チェックボックスのモックアップ

今回の実装は、リスト8.2のログインフォームにチェックボックスを追加するところから始めます。チェックボックスは、他のラベル、テキストフィールド、パスワードフィールド、送信ボタンと同様にヘルパーメソッドで作成できます。ただし、チェックボックスが正常に動作するためには、以下のようにラベルの内側に配置する必要があります。

<%= f.label :remember_me, class: "checkbox inline" do %>
  <%= f.check_box :remember_me %>
  <span>Remember me on this computer</span>
<% end %>

上をログインフォームに反映したコードをリスト8.47に示します。

リスト8.47: [remember me] チェックボックスをログインフォームに追加する 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.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

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

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

リスト8.47では、2つのCSSクラスcheckboxinlineをインクルードしています。Bootstrapではこれらをチェックボックスとテキスト「Remember me on this computer”」として同じ行に配置します。スタイルを整えるため、もう少しCSSルールを追加します (リスト8.48)。表示されるログインフォームを8.12に示します。

リスト8.48: [remember me] チェックボックスのCSS app/assets/stylesheets/custom.css.scss
.
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}
images/figures/login_form_remember_me
図8.12 "remember_token" チェックボックスを追加したloginフォーム

ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにします。信じられないかもしれませんが、必要な準備はすべて終わっているので、実装はわずか1行で終わります。ログインフォームから送信されたparamsハッシュには既にチェックボックスの値が含まれています。リスト8.47のフォームに無効な値を入力して実際に送信すれば、ページのデバッグ情報で値を確認することもできます。特に、以下の値は、

params[:session][:remember_me]

チェックボックスがオンのときに’1’になり、オフのときに’0’になります。

paramsハッシュのこの値を調べれば、送信された値に基いてユーザーを記憶したり忘れたりできるようになります24

if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end

コラム8.3で解説したように、以下のような「三項演算子」を使用すると、このようなif-then分岐構造を1行で表すことができます25

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

Sessionsコントローラのcreateに上の行を追加した結果をリスト8.49に示します。驚くほどコンパクトなコードになりました。既に読者の皆様は、cost変数の定義に三項演算子を使用したリスト8.18のコードも理解できるようになったことでしょう。

リスト8.49: [remember me] チェックボックスの送信結果を処理する 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
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

リスト8.49の実装によって、ログインシステムの実装がついに完了しました。ブラウザでこのチェックボックスを実際にオンにしたりオフにしたりして、動作を確認してみましょう。

コラム8.3 10種類の人々

「この世には10種類の人間がいる。2進法を理解できる奴と、2進法を理解できない奴だ」は、この業界に古くから伝わるジョークです。ここで言う「10種類」とは、もちろん10進法ではなく2進法のことです。つまり、2進法で「10」を解釈できれば、「2種類」という意味だと理解できます。上のジョークに倣えば、次のようなジョークもできそうです。「この世には10種類の人々がいる。三項演算子が好きな人、嫌いな人、三項演算子を知らない人」。ちなみにもし3番目に該当したとしても、このコラムを読めばそのカテゴリの人ではなくなりますので、ご心配なく。

プログラミング経験を重ねるうちに、論理値に応じて分岐する、以下のような制御フローが頻繁に出現することに気付くと思います。

  if boolean?
    do_one_thing
  else
    do_something_else
  end

Rubyや他の言語 (C/C++、Perl、PHP、Javaなど) では、上のようなフローをよりコンパクトな三項演算子 (ternary operator) と呼ばれる表現で置き換えることができます (3つの部分から構成されるためそのように呼ばれます)。

  boolean? ?  何かをする : 別のことをする

以下のような代入文を三項演算子で置き換えることもできます。

  if boolean?
    var = foo
  else
    var = bar
  end

上のコードは以下のように1行で書けます。

  var = boolean? ? foo : bar

最後に、三項演算子を関数の戻り値として使用することもよくあります。

  def foo
    do_stuff
    boolean? ? "bar" : "baz"
  end

Rubyでは暗黙的に関数の最後の式の値を返すので、上のfooメソッドは、boolean?trueであるかfalseであるかに応じて、"bar"または"baz"をそれぞれ返します。

8.4.6 Rememberのテスト

[remember me] 機能は既に快調に動作していますが、ここで終わらせずにテストをちゃんと書き、動作をテストで確認できるようにしておくことが重要です。テストを書く理由のひとつは、今行った実装のエラーをキャッチできるようにすることです。しかしもっと重要な理由は、ユーザーを永続化するコードの中心部分が、実はまだまったくテストされていないからです。これらの課題を達成するには、もう少し新しいテストのテクニックを覚える必要がありますが、それによりテストスイートが一段と強力になります。

[remember me] ボックスをテストする

恥を忍んで申し上げると、筆者が自分自身でリスト8.49でチェックボックスの処理を実装したときは、

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

最初は上のコードではなく、以下のコードを使用していました。

params[:session][:remember_me] ? remember(user) : forget(user)

この流れでは、params[:session][:remember_me]の値は’0’または’1’のいずれかになりますが、そこに罠がありました。0も1もRubyの論理値ではtrueであることを思い出してください。従って、値は常にtrueになってしまい、チェックボックスは常にオンになっているのと同じ動作になってしまいました。この種のミスはまさに、テストでキャッチすべきエラーです。

ユーザーが記憶されるにはログインが必要です。そこで、テスト内でユーザーがログインできるようにするためのヘルパーメソッドを定義することから始めます。リスト8.20では、postメソッドと有効なsessionハッシュを使用してログインしましたが、毎回このようなことをするのは面倒です。そこで、log_in_asというヘルパーメソッドを作成してテスト用にログインできるようにし、無駄な繰り返しを排除します。

ログインに使用するメソッドは、テストの種類によって異なります。統合テストの内部では、リスト8.20のようにセッションパスをpostしますが、コントローラやモデルなどの単体テストでは同じ方法が使えません (セッションがないからです)。後者の場合はsessionメソッドを人為的に操作して回避しなければなりません。このため、log_in_asではテストの種類を検出して、それに応じたログインを行えるようにする必要があります。統合テストとその他のテストを区別するには、Rubyの定番であるdefined?メソッドを使用します。このメソッドは、引数の内容が定義されている場合はtrueを、その他の場合はfalseを返します。この場合は、post_via_redirectメソッド (リスト7.26) が統合テストの場合にのみアクセス可能であることを利用し、以下のようなコードを使用します。

defined?(post_via_redirect) ...

上のコードは、統合テストの実行中にはtrueを返し、その他の場合にはfalseを返します。せっかくなので、統合テストを実行中かどうかを論理値で返すintegration_test?メソッドを定義し、以下のようにif-thenステートメントをわかりやすく書くことにしましょう。

if integration_test?
  # セッション用パスにpostしてログインする
else
  # sessionメソッドでログインする
end

コメント部分にコードを書けば、log_in_asヘルパーメソッドができあがります (リスト8.50)。注: このコメント部分に書くコードはそれなりに込み入っています。可能であれば、一行ずつ読んで完全に理解しておくことをおすすめします。(訳注: テストのコードでは、操作を実現するためにこのようなトリッキーなコードを使わざるを得ないときがあります。)

リスト8.50: log_in_asヘルパーを追加する test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

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

  # テストユーザーとしてログインする
  def log_in_as(user, options = {})
    password    = options[:password]    || 'password'
    remember_me = options[:remember_me] || '1'
    if integration_test?
      post login_path, session: { email:       user.email,
                                  password:    password,
                                  remember_me: remember_me }
    else
      session[:user_id] = user.id
    end
  end

  private

    # 統合テスト内ではtrueを返す
    def integration_test?
      defined?(post_via_redirect)
    end
end

テストコードを最大限に柔軟にするため、log_in_asメソッド (リスト8.50) ではoptionsハッシュ (リスト7.31) を引数に取り、パスワードと [remember me] チェックボックスのデフォルト値をそれぞれ’password’’1’に設定します。特に、キーが存在しない場合はハッシュがnilを返すので、

remember_me = options[:remember_me] || '1'

上のようなコードでは、渡されたオプションが存在すれば評価し、それ以外の場合はデフォルトのオプションを使用します (このテクニックはコラム8.1でご紹介した「短絡評価」の応用です)。

[remember me] チェックボックスの動作を確認するために、2つのテストを作成します。チェックボックスがオンになっている場合とオフになっている場合のテストです。リスト8.50でログインヘルパーメソッドを定義しておいたので、このテストは簡単に書くことができます。2つのテストはそれぞれ以下のようになります。

log_in_as(@user, remember_me: '1')

および

log_in_as(@user, remember_me: '0')

上のコードの’1’remember_meのデフォルト値なので、1つ目のテストでは省略してもよいのですが、2つのコードを見比べやすいようにあえて省略しませんでした。

ログインに成功すれば、cookies内部のremember_tokenキーを調べることで、ユーザーが保存されたかどうかをチェックできるようになります。cookiesの値がユーザーの記憶トークンと一致することを確認できれば理想的なのですが、現在の設計ではテストでこの確認を行うことはできません。コントローラ内のuser変数には記憶トークンの属性が含まれていますが、remember_tokenは実在しない「仮想」のものなので、@userインスタンス変数の方には含まれていません。この課題は大して難しくないので、8.6の演習に回すことにします。さしあたって、今は関連するcookiesがnilであるかどうかだけをチェックすればよいことにします。

実はもうひとつ地味な問題があります。ある理由によって、テスト内ではcookiesメソッドにシンボルを使用できないのです。そのため、

cookies[:remember_token]

上のコードは常にnilになってしまいます。ありがたいことに、文字列キーならcookies使用できるので、

cookies['remember_token']

上のように書けば期待どおりに値が返されます。以上の結果を反映したテストコードをリスト8.51に示します (リスト8.20users(:michael)と書くと、リスト8.19のフィクスチャユーザーを参照していたことを思い出しましょう)。

リスト8.51: [remember me] チェックボックスのテスト GREEN test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_nil cookies['remember_token']
  end

  test "login without remembering" do
    log_in_as(@user, remember_me: '0')
    assert_nil cookies['remember_token']
  end
end

皆さんが著者と同じ間違いをしていなければ、このテストはGREENになるはずです。

リスト8.52: GREEN
$ bundle exec rake test

記憶ブランチをテストする

8.4.2では、それまでの節で実装した永続的セッションが動作するかどうかを手動で確認していました。しかし実は、current_user内のある分岐部分については、これまでまったくテストが行われていないのです。筆者はこのことに気付いた場合に、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むという手法を好んで使います。そのコードブロックがテストから漏れていれば、テストはパスしてしまうはずです。コードブロックがテストから漏れていなければ、例外が発生してテストが中断するはずです。現在のコードでこれを行ってみた結果をリスト8.53に示します。

リスト8.53: テストされていないブランチで例外を発生するGREEN app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      raise       # テストがパスすれば、この部分がテストされていないことがわかる
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

この段階でテストを実行してみると、GREENになります。

リスト8.54: GREEN
$ bundle exec rake test

リスト8.53のコードが正常でないことがわかった以上、これはもちろん問題です。さらに申し上げると、この種の永続的セッションを手動で確認するのは非常に面倒なので、current_userをリファクタリングするのであれば (10で行う予定です) 同時にテストも作成しておくことが重要です。

リスト8.50で定義したlog_in_asヘルパーメソッドでは、session[:user_id]が自動的に定義されます。従って、current_userメソッドで今問題になっている「記憶」ブランチを統合テストでテストするのは非常に困難です。ありがたいことに、以前作成した以下のSessionsヘルパーのテストでcurrent_userを直接テストすれば、この制約を突破できます。

$ touch test/helpers/sessions_helper_test.rb

テスト手順はシンプルです。

  1. フィクスチャでuser変数を定義する
  2. 渡されたユーザーをrememberメソッドで記憶する
  3. current_userが、渡されたユーザーと同じであることを確認します。

上の手順ではrememberメソッドではsession[:user_id]が設定されないので、問題の「記憶」ブランチをこれでテストできるようになります。修正の結果をリスト8.55に示します。

リスト8.55: 永続的セッションのテスト test/helpers/sessions_helper_test.rb
require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

  def setup
    @user = users(:michael)
    remember(@user)
  end

  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end

テストをもうひとつ追加していることにご注目ください。この追加テストでは、ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に現在のユーザーがnilになるかどうかをチェックしています。これによって、以下のネストしたifステートメント内のauthenticated?の式をテストします。

if user && user.authenticated?(cookies[:remember_token])

ところで、リスト8.55では以下のように書いてもよいように思えるかもしれません。

assert_equal current_user, @user

実際、上のように書いても動作します。しかし、5.6で簡単に触れたように、アサーションassert_equalの引数は、期待する値実際の値の順序で書くのがルールになっています。

assert_equal <期待する値>, <実際の値>

上の原則に従って、リスト8.55のコードは以下のように書かれています。

assert_equal @user, current_user

リスト8.55のコードを実行すると、今度は期待どおりredになるはずです。

リスト8.56: RED
$ bundle exec rake test TEST=test/helpers/sessions_helper_test.rb

ここまでできれば、current_userメソッドに仕込んだraiseを削除して元に戻す (リスト8.57) ことで、リスト8.55のテストがパスするはずです (リスト8.57からauthenticated?の式を削除するとリスト8.55の2番目のテストが失敗することも確認できます。つまりこのテストが正しいものであるということです)。

リスト8.57: 例外発生部分を削除する GREEN app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

これでテストスイートはGREENになるはずです。

リスト8.58: GREEN
$ bundle exec rake test

current_userの「記憶」ブランチをテストできたので、今後は手動でひとつひとつ確認しなくても、自信を持って回帰バグをキャッチできます。

8.5 最後に

この章とひとつ前の章では、実に多くの作業をこなしました。かつては未熟そのものだったアプリケーションを、約束通り、ユーザー登録機能やログイン機能を完全に備えた立派なアプリケーションへと変身させることができたのです。認証機能の完成に必要なのは、一口に言えばログインステータスとユーザーIDに基いてページへのアクセスを制限することだけです。次は、ユーザーが自分のプロフィール情報を編集できるようにする予定です。これは9の最終目標でもあります。

次の章に進む前に、変更をmasterブランチにマージしておきましょう。

$ bundle exec rake test
$ git add -A
$ git commit -m "Finish log in/log out"
$ git checkout master
$ git merge log-in-log-out

続いて、リモートリポジトリとproductionサーバーにもプッシュします。

$ bundle exec rake test
$ git push
$ git push heroku
$ heroku run rake db:migrate

プッシュした後、マイグレーションが完了するまでの間、一時的にステータスが無効 (invalid) になりますので、ご注意ください。トラフィックの多い本番サイトでは、変更を行う前に以下のようにメンテナンスモードをオンにしておくとよいでしょう。

$ heroku maintenance:on
$ git push heroku
$ heroku run rake db:migrate
$ heroku maintenance:off

上の操作でデプロイとマイグレーションを行うと、その間に標準のエラーページが出力されます (この作業で煩わされることは今後ありませんが、一度はこのエラーページを目にしておくのもよいでしょう)。詳しくは、Herokuのエラーに関するページ (英語) にあるドキュメントを参照してください。

8.5.1 本章のまとめ

  • Railsでは、あるページから別のページに移動するときに状態を保持することができます。ページの状態の保存には、一時cookiesと永続cookiesのどちらも使用できます。
  • ログインフォームは、ユーザーがログインするための新しいセッションを作成するように設計されています。
  • flash.nowメソッドを使用すると、レンダリング済みのページにもフラッシュメッセージを表示できます。
  • テスト駆動開発は、テストでバグを再現してからデバッグしたい場合に便利です。
  • sessionメソッドを使用すると、ユーザーIDを安全にブラウザに保存して一時セッションを作成できます。
  • ログインの状態に応じて、レイアウト上のリンクなどの機能を変更できます。
  • 統合テストでは、ルーティング、データベースの更新、レイアウトの変更が正しく行われているかどうかを確認できます。
  • 記憶トークンやそれと対応する記憶ダイジェストをユーザーごとに関連付けて、永続的セッションで使用できます。
  • cookiesメソッドを使用すると、永続的な記憶トークンのcookiesをブラウザに保存して、永続的セッションを作成できます。
  • ログイン状態 (ログインしているかどうか) は、一時セッションのユーザーIDか、永続的セッションの一意な記憶トークンに基いた現在のユーザーが存在しているかどうかで決定されます。
  • セッションのユーザーIDを削除し、ブラウザの永続的cookiesを削除すると、アプリケーションからユーザーがログアウトします。
  • 三項演算子を使用すると、単純なif-thenステートメントをコンパクトに記述することができます。

8.6 演習

: 『演習の解答マニュアル (英語)』にはRuby on Railsチュートリアルのすべての演習の解答が掲載されており、www.railstutorial.orgで原著を購入いただいた方には無料で配布しています (訳注: 解答は英語です)。

なお、演習とチュートリアル本編の食い違いを避ける方法については、演習用のトピックブランチに追加したメモ (3.6) を参考にしてください。

  1. リスト8.32では、明示的にUserをプレフィックスとして、新しいトークンやダイジェストのクラスメソッドを定義しました。これらは問題なく動作します。これらは、実際にUser.new_tokenUser.digestを使用して呼び出されるので、おそらく最も明確な定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が、おそらく2とおりあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、リスト8.59 (ややわかりにくい) や リスト8.60 (非常に混乱する) の実装が正しいことを確認してください。これが問題です (selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト8.59リスト8.60の文脈では、selfUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります)。
  2. 8.4.6では、現在のアプリケーション設計では、リスト8.51の統合テストで仮想のremember_token属性にアクセスする手段がないことを説明しました。実は、assignsという特殊なテストメソッドを使用するとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使用します。このメソッドにはインスタンス変数に対応するシンボルを渡します。たとえば、createアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userを (インスタンス変数ではない) 通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイディアに従ってリスト8.61リスト8.62の不足分を埋め (ヒントとしてFILL_INを目印に置いてあります)、[remember me] チェックボックスのテストを改良してください。
リスト8.59: selfを使ってトークンやダイジェストの新しいメソッドを定義する GREEN app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  # 与えられた文字列のハッシュ値を返す
  def self.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def self.new_token
    SecureRandom.urlsafe_base64
  end
  .
  .
  .
end
リスト8.60: class << selfを使ってトークンやダイジェストの新しいメソッドを定義するGREEN app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  class << self
    # 与えられた文字列のハッシュ値を返す
    def digest(string)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                    BCrypt::Engine.cost
      BCrypt::Password.create(string, cost: cost)
    end

    # ランダムなトークンを返す
    def new_token
      SecureRandom.urlsafe_base64
    end
  end
  .
  .
  .
リスト8.61: createアクション内のインスタンス変数を使用するためのテンプレート 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
      params[:session][:remember_me] == '1' ? remember(●user) : forget(●user)
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end
リスト8.62: [remember me] テストを改良するためのテンプレート GREEN test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal FILL_IN, assigns(:user).FILL_IN
  end

  test "login without remembering" do
    log_in_as(@user, remember_me: '0')
    assert_nil cookies['remember_token']
  end
  .
  .
  .
end
  1. その他に、一定時間が経過するとセッションを期限切れにするモデルもあります。このモデルは、インターネットバンキングや金融取引口座などの重要な情報を扱うWebサイトに向いています。
  2. ブラウザによっては、「中断した時点から再開」などのオプション機能でセッションを復旧できるものもあります。このような動作はブラウザ依存で、かつブラウザ側でしか行えないので、Railsサーバーではこうしたセッション復旧機能を実現することはできません。
  3. (注: form_forの代わりにform_tagを使うこともでき、Railsではこの方が慣用的な方法です。しかし、ユーザー登録フォームではform_forを使用する方が一般的であり、並列構造を強調するためにもform_forを使用しました。
  4. リスト8.11でモジュールをインクルードしているので、Sessionコントローラでlog_inメソッドを使用できます。
  5. このように、メソッド呼び出しでの変数代入を記憶して次回以降の呼び出しで使い回す手法をメモ化 (memoization) と呼びます。memorizationのスペルミスではなく、それをもじったmemoization (rがない) という造語であることにご注意ください。
  6. 画像はhttp://www.flickr.com/photos/hermanusbackpackers/3343254977/から引用しました。
  7. Webブラウザは実際にはDELETEリクエストを発行できないので、RailsではJavaScriptを使用してこのリクエストを「偽造」します。
  8. 詳しくは、Bootstrapコンポーネント一覧ページ (英語) を参照してください。
  9. クラウドIDEをご利用の場合は、別のブラウザでログイン動作を確認することをおすすめします。そうでないと、クラウドIDEを開いているブラウザも一緒に閉じるはめになってしまいます。
  10. Sessionsコントローラがあることで、Usersコントローラでlog_inメソッドを使用できるようになります。そのために必要なモジュールはリスト8.11でインクルードされています。
  11. 一例として、かつて筆者が作成したテストスイートでは、Sessionsヘルパーからlog_inメソッドをうっかり削除してしまったにもかかわらず、テストがGREENのまま変わらなかったことがありました。原因は、テストで使用していたテストヘルパーメソッドの名前が、うかつにもSessionsヘルパーメソッド名と同じだったことです。そのため、アプリケーションが壊れていてもテストがパスしてしまいました。テストヘルパーメソッド名を、Sessionヘルパーメソッド名log_in_as (リスト8.50) とは異なるis_logged_in?で定義することで、この問題を回避できます。
  12. ブラウザによっては、「ログイン状態を保存する」などでセッションを自動復元する機能がサポートされていることがあります。この機能は開発の邪魔になるので、ログアウトする前にこの機能を必ずオフにしておいてください。
  13. インスタンス変数@current_usernilにする必要があるのは、@current_userdestroyアクションより前に作成され (作成されていない場合)、かつ、リダイレクトを直接発行しなかった場合だけです。今回はリダイレクトを直接発行しているので、不要です。現実にこのような条件が発生する可能性はかなり低く、このアプリケーションでもこのような条件を作り出さないように開発しているので、本来はnilに設定する必要はないのですが、ここではセキュリティ上の死角を万が一にでも作り出さないためにあえてnilに設定しています。
  14. セッションハイジャックは、セキュリティ上の注意を呼びかけるためにこれを実演するFiresheepアプリケーションによって広く知られるようになりました。Firesheepを使用すると、公共Wi-Fiネットワーク経由で接続したときに多くの有名Webサイトの記憶トークンが丸見えになっていることがわかります。
  15. このメソッドは、RailsCastの「remember me」の記事を元に選びました。
  16. 実際、これでもOKなのです。bcryptのハッシュはソルト化されているので、2人のユーザーのパスワードが本当に一致するのかどうかはハッシュからは絶対わかりません。(訳注: 「ソルト」とは、暗号を強化するために加えられる任意の短い文字列です。念には念を入れて「塩ひとつまみ」を加えるというイメージであり、英語の「take it with a grain of salt」=半分疑ってかかるという言い回しが語源です)
  17. 記憶トークンが一意に保たれることで、攻撃者はユーザーIDと記憶トークンを両方とも奪い取ることに成功しない限りセッションをハイジャックできなくなります。
  18. 一般に、あるメソッドがオブジェクトのインスタンスを必要としていない場合は、クラスメソッドにするのが常道です。10.1.2では、実際にこの決定が重要になってきます。
  19. 6.3.1で解説したように、「暗号化されていないパスワード (unencrypted password)」という呼び方は正しくありません。ここで言うセキュアなパスワードとは、単にハッシュ化したという意味であり、本格的な暗号化は行われていないからです。
  20. 筆者はこのような場合、代入式全体をかっこで囲むようにしています。これが比較でないことを思い出せるようにするためです。
  21. システムでのcookiesの調べ方については、「<ブラウザ名> inspect cookies」でググってください。
  22. 読者のPaulo Célio Júniorからのご指摘でした。ありがとうございました。
  23. 読者のNiels de Ronからのご指摘でした。ありがとうございます。
  24. ユーザーがこのチェックボックスをオフすると、すべてのコンピュータ上のすべてのブラウザからログアウトしますので、注意が必要です。ブラウザごとにユーザーのログインセッションを記憶する設計に変更すれば、ユーザーにとってもう少し便利にはなりますが、その分セキュリティが低下するうえ、実装も面倒になります。やる気の余っている方は実装してみてもよいでしょう。
  25. 以前はremember userをかっこなしで書きましたが、三項演算子ではかっこを省略すると文法エラーになります。
前の章
第8章 ログイン、ログアウト Rails 4.2 (第3版)
次の章