Ruby on Rails チュートリアル

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

第2版 目次

第8章サインイン、サインアウト

第7章でWebサイトでの新規ユーザー登録が行えるようになりましたので、今度はユーザーがサインインとサインアウトを行えるようにしましょう。これにより、サインインの状態と現在のユーザーidに応じて動作を変更できるようになります。たとえば、この章では、サイトのヘッダー部分にサインイン/サインアウトのリンクとプロファイルへのリンクを表示するようにします。第10章では、サインインしたユーザーidを使用して、そのユーザーに関連付けられたマイクロポストを作成します。また第11章では、現在のユーザーが他のユーザーをフォローして、マイクロポストのフィードを受け取ることができるようにします。

ユーザーがサインインすることでセキュリティモデルも実装され、サインインしているユーザーidに基づいて、特定のページへのアクセスを制限することもできます。第9章でも説明しますが、たとえばサインインしたユーザーのみがユーザー情報編集ページにアクセスできるようにします。サインインシステムを使用して、管理者ユーザーに特別な権限を与えることもできます。たとえば (第9章で導入する) データベースからユーザーを削除する機能があります。

コアとなる認証機能を実装した後は、少々回り道して、振舞駆動開発 (8.3) で有名なCucumberというツールを試してみます。特に、2つの開発手法を比較するために、RSpecによる結合テストの組み合わせをCucumberで再実装します。

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

$ git checkout -b sign-in-out

8.1セッション、サインインの失敗

セッションとは、2つのコンピュータの間、たとえばクライアント側のブラウザとサーバーで動作しているRailsとの間の、半永続的な接続のことです。ここでは、”サインイン” の共通パターンを実装するためにセッションを使用します。ここで、Webの世界にはセッションの振る舞いを表現するためのいくつかの異なったモデルがあります。ブラウザを閉じるとセッションを終了する「忘却モデル」、[パスワードを保存する] チェックボックスを使用してセッションを継続する「継続モデル」、ユーザーが明示的にサインアウトするまでセッションを継続する「永続モデル」などです1。ここでは、最後の永続モデルを採用することにします。ユーザーがサインインすると、ユーザーが明示的にサインアウトするまでサインインの状態を永続させます (8.2.1では、“永続” が実際にはどのぐらいの時間であるかをお見せします)。

セッションは、RESTfulなリソースとして作成しておくと便利です。たとえばサインインページをnewセッションで、サインインをcreateセッションで、サインアウトをdestroyセッションでそれぞれ扱います。Usersリソースのように (Usersモデルを経由して) データベースをバックエンドに持つリソースとは異なり、このSessionsリソースではcookiesを使用します。cookiesとは、ブラウザに保存される小さなテキストデータです。サインイン関連の作業の大半は、このcookiesをベースにして認証システムを構築することになります。この節と次の節では、セッション機能を作成する準備として、Sessionコントローラ、サインイン用のフォーム、そしてこれらに関連するコントローラのアクションを作成します。次の8.2では、cookies管理のために必要なコードを追加して、ユーザーがサインインするための仕組みを完成させます。

8.1.1 Sessionコントローラ

サインインとサインアウトの要素は、Sessionsコントローラの特定のRESTアクションにそれぞれ対応します。サインインのフォームは、この節のnewアクションで処理されます。実際のサインイン処理は、createアクションにPOSTリクエストが送信されたときに処理されます (8.1および8.2) 。サインアウトは、destroyアクションにDELETEリクエストを送信することで処理されます(8.2.6)。(表7.1のHTTPメソッドとRESTアクションの関連付けを思い出してください。) それでは最初に、Sessionsコントローラと認証システムをテストする結合テストを作成します。

$ rails generate controller Sessions --no-test-framework
$ rails generate integration_test authentication_pages

7.2で作成したユーザー登録ページのモデルを元に、新しいセッションを確立するためのフォームを図8.1のモックアップに従って作成します。

signin_mockup_bootstrap
図8.1サインインフォームのモックアップ。(拡大)

このサインインページは、signin_pathによって仮に定義されるURLでアクセス可能になります。リスト8.1のように、最初は最小限のテストから始めます (リスト7.6のユーザー登録ページのコードと似ているので、比較してみてください)。

リスト8.1 セッションのnewアクションとビューをテストする。
spec/requests/authentication_pages_spec.rb
require 'spec_helper'

describe "Authentication" do

  subject { page }

  describe "signin page" do
    before { visit signin_path }

    it { should have_content('Sign in') }
    it { should have_title('Sign in') }
  end
end

以下のテストは、この時点では失敗するはずです。

$ bundle exec rspec spec/
 

リスト8.1のテストがパスするためには、サインインページ用にカスタマイズした名前付きルートを使用して、Sessionsリソースのルーティングを定義する必要があります。このサインインページはSessionコントローラのnewアクションと対応しています。Usersリソースの場合と同様に、resourcesメソッドを使用して通常のRESTfulなルーティングを設定することができます。

resources :sessions, only: [:new, :create, :destroy]

セッションを編集したりユーザーに表示したりする必要がないので、resourcesメソッドに:onlyオプションを追加し、newcreatedestroyの3つのアクションのみを有効にします。サインインとサインアウト用のルーティングを含む完全なコードをリスト8.2に示します。

リスト8.2 リソースを追加して標準のRESTfulアクションを設定する。
config/routes.rb
SampleApp::Application.routes.draw do
  resources :users
  resources :sessions, only: [:new, :create, :destroy]
  root  'static_pages#home'
  match '/signup',  to: 'users#new',            via: 'get'
  match '/signin',  to: 'sessions#new',         via: 'get'
  match '/signout', to: 'sessions#destroy',     via: 'delete'
  .
  .
  .
end

注: サインアウトのルーティングにあるvia: ’delete’は、このアクションが HTTPのDELETEリクエストによって呼び出されることを示しています。

リスト8.2で定義されたリソースは、表8.1に示したように、表7.1とよく似たURLとアクションを提供します。注:サインインとサインアウトのアクションのルーティングはカスタムで設定しますが、セッションの生成アクションへのルーティングはデフォルトを使います (i.e., [resource name]_path).

HTTPリクエストURL名前付きルートアクション用途
GET/signinsignin_pathnew新しいセッション用 (サインイン)
POST/sessionssessions_pathcreate新しいセッションを作成する
DELETE/signoutsignout_pathdestroyセッションを削除する (サインアウト)
表8.1リスト8.2のセッションルールによって提供されるRESTfulなルート。

次に、リスト8.1のテストがパスするようにするために、リスト8.3に示したようにnewアクションをSessionsコントローラに追加します (後で使えるようcreateアクションとdestroyアクションも定義しておきます) 。

リスト8.3 最初のSessionsコントローラ。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
  end

  def destroy
  end
end

最後に、サインインページを新規に定義します。このページは新規セッション用なので、今から作成するサインインページをapp/views/sessions/new.html.erbに置くことに注目してください。リスト8.4に示したように、今の時点ではタイトルと一番上のヘッダ部分のみを定義します。

リスト8.4 最初のサインインビュー。
app/views/sessions/new.html.erb
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>

これにより、リスト8.1のテストが青信号 (成功) になります。これで実際のサインインフォームを作成する準備ができました。

$ bundle exec rspec spec/

8.1.2サインインをテストする

図8.1図7.11を比較すると、サインインフォーム (新規セッションフォーム) の外観は、4つあったフィールドがメールアドレスとパスワードの2つになったこと以外は、ユーザー登録フォームによく似ていることに気付くと思います。ユーザー登録フォームのときと同様に、サインインフォームでもCapybaraを使ってフォームに値を入力し、ボタンをクリックするテストを行うことができます。

テストを作成していると、私たちはアプリケーションをさまざまな側面から設計することを強いられます。これはテスト駆動開発の素晴らしい副次的効果のひとつです。図8.2のモックアップで示されているように、最初はサインインの入力に誤りがあった場合のテストから作成します。

signin_failure_mockup_bootstrap
図8.2サインイン失敗時のモックアップ。(拡大)

図 8.2に示されているように、サインインで入力した情報に誤りがあったときは、サインインページをもう一度表示して、エラーメッセージを出力します。ここではエラーをフラッシュメッセージとして表示するので、以下のようにテストできます。

it { should have_selector('div.alert.alert-error', text: 'Invalid') }

上のコードでは、リスト7.32で導入したCapybaraのhave_selectorメソッドを使用しています (第7章の演習)。have_selectorメソッドは、特定のセレクタ要素 (HTMLタグなど) があるかどうかをチェックします。なお、Capybara 2.0では画面に表示される要素しかチェックできません。セレクタ要素(つまりタグ)は以下のように指定します。

div.alert.alert-error

上のコードはdivタグがあるかどうかをチェックします。上のコードのドットはCSSのクラスを意味することを思い出してください (5.1.2) 。つまりこのテストでは、"alert"クラスと"alert-error"クラスのdivタグを対象にしていることがわかります。また、エラーメッセージに"Invalid"という単語が含まれていることもテストします。これらを合わせると、次のフォームの要素を探しだしてテストが行われます。

<div class="alert alert-error">Invalid...</div>

リスト 8.5のコードは、タイトルとフラッシュメッセージをまとめてテストします。ただしこのテストは、実はある重要な点を欠いています。これは8.1.5で改善します。

リスト8.5 サインイン失敗時のテスト。
spec/requests/authentication_pages_spec.rb
require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error', text: 'Invalid') }
    end
  end
end

サインインに失敗した時のテストができたので、次はサインインに成功した場合のテストを作成しましょう。ユーザープロファイルページが表示されること (ページタイトルがユーザー名になっていること)、サイトのナビゲーションに次の3つの変更が加えられていることを確認するよう、テストを変更します。

  1. プロファイルページヘのリンクの表示
  2. [Sign out] リンクの表示
  3. [Sign in] リンクの非表示

([Setting] リンクについては9.1で、[Users] リンクについては9.3でそれぞれテストします) 。これらの変更を加えたモックアップを図8.3に示しました2。サインアウトとプロファイルページヘのリンクは、[Account] メニューにドロップダウンで表示されます。Bootstrapを使用してドロップダウンメニューを作成する方法については8.2.4で説明します。

signin_success_mockup_bootstrap
図8.3サインインに成功後表示されるユーザープロファイルのモックアップ。(拡大)

サインインに成功したときのテストコードをリスト8.6に示しました。

リスト8.6 サインインに成功したときのテスト。
spec/requests/authentication_pages_spec.rb
require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    before { visit signin_path }
    .
    .
    .
    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      before do
        fill_in "Email",    with: user.email.upcase
        fill_in "Password", with: user.password
        click_button "Sign in"
      end

      it { should have_title(user.name) }
      it { should have_link('Profile', href: user_path(user)) }
      it { should have_link('Sign out', href: signout_path) }
      it { should_not have_link('Sign in', href: signin_path) }
    end
  end
end

上のコードでは、Capybaraのhave_linkメソッドが導入されています。このメソッドは、リンクテキストを引数にとります。オプションとして次のように:hrefパラメータを加えると、

it { should have_link('Profile', href: user_path(user)) }

アンカータグahref (URL) 属性を追加することもできます (この例では、ユーザープロファイルへのリンク)。上のテストでは、upcaseメソッドを使用してユーザーのメールアドレスを大文字に変換することで、大文字小文字を区別しないデータベースが使用されている場合であってもユーザーを確実に検索できるように配慮してあることに注目してください。

8.1.3サインインのフォーム

テストの準備が完了したので、いよいよサインインフォームの開発に取りかかりましょう。リスト7.17のときは、以下のようにユーザー登録フォームでform_forヘルパーを使用し、ユーザーのインスタンス変数 @userを引数にとっていました。

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

このユーザー登録フォームとサインインフォームの主な違いは、サインインフォームにはSessionモデルがないために@user変数のような存在がないことです。 これはつまり、新しいセッションフォームを作成するときにform_forヘルパーに追加の情報を渡す必要があるということです。

form_for(@user)

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

form_for(:session, url: sessions_path)

(注: form_forの代わりにform_tagを使うこともでき、Railsではこの方が慣用的な方法です。しかし、ユーザー登録フォームではform_forを使用する方が一般的であり、並列構造を強調するためにもform_forを使用しました。form_tagを使ったフォームについては(8.5)の演習に回します。)

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

リスト8.7 サインインフォームのコード。
app/views/sessions/new.html.erb
<% provide(:title, "Sign in") %>
<h1>Sign in</h1>

<div class="row">
  <div class="span6 offset3">
    <%= form_for(:session, url: sessions_path) do |f| %>

      <%= f.label :email %>
      <%= f.text_field :email %>

      <%= f.label :password %>
      <%= f.password_field :password %>

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

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

ユーザーにとって便利なように、ユーザー登録ページのリンクを追加してあることに注目してください。リスト 8.7のコードで生成されるサインインフォームを図 8.4に示します。

signin_form_bootstrap
図8.4サインインフォーム (/signin)。(拡大)

これまではRailsが生成したHTMLをブラウザでたびたび確認していた方も多いと思いますが、やがてその習慣は忘れ去られ、ヘルパーが正常に動作していることを信頼するようになる日が来ることでしょう。今はいつものように(リスト 8.8)を見てみましょう。

リスト8.8 リスト8.7で生成されたサインインフォームのHTML。
<form accept-charset="UTF-8" action="/sessions" method="post">
  <div>
    <label for="session_email">Email</label>
    <input id="session_email" name="session[email]" type="text" />
  </div>
  <div>
    <label for="session_password">Password</label>
    <input id="session_password" name="session[password]"
           type="password" />
  </div>
  <input class="btn btn-large btn-primary" name="commit" type="submit"
       value="Sign in" />
</form>

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

8.1.4確認フォームを送信する

ユーザーを作成する (ユーザー登録) 場合と同様、セッションを作成する場合 (サインイン) で最初にやることは、正しくない入力の取り扱いです。ユーザー登録のテストは既に作成済みです (リスト 8.5)。そしてサインインのアプリケーションコードは、いくつかの点においてユーザー登録のコードとわずかに異なります。最初に、フォームが送信されたとき何が起こるかを考えましょう。次に、サインインが失敗した場合に表示されるエラーメッセージを配置します (モックアップを図8.2に示しました)。次に、サインインが送信されるたびに、パスワードとメールアドレスの組み合わせが正しいかどうかを判定し、サインインに成功した場合に使用する土台 (8.2) を作成します。

最初に、Sessionsコントローラに最小限のcreateアクションを定義します (リスト8.9) 。このアクションは何も実行しませんが、newビューを表示します。/sessions/newフォームを表示し、フィールドを空白のまま [Sign in] を押して送信すると図8.5のように表示されます。

リスト8.9 Sessionsコントローラのcreateアクションの試作バージョン。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    render 'new'
  end
  .
  .
  .
end
initial_failed_signin_rails_3_bootstrap
図8.5リスト8.9createへのサインイン失敗結果。(拡大)

図8.5 に表示されているデバッグ情報に注目してください。8.1.3 の終わりにもヒントがありましたが、paramsハッシュはメールアドレスとパスワードを以下のように:sessionキーの下に持ちます。

---
session:
  email: ''
  password: ''
commit: Sign in
action: create
controller: sessions

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

{ session: { password: "", email: "" } }

これはつまり、

params[:session]

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

{ password: "", email: "" }

その結果、

params[:session][:email]

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

params[:session][:password]

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

言い換えれば、createアクションのparamsハッシュには、ユーザーの認証に必要な情報がすべて含まれているということになります。ここまでに、私たちはすでに必要なメソッドを手にしています。 Active RecordがUser.find_by_emailを提供し (6.1.4)、 has_secure_passwordauthenticateメソッドを提供しています (6.3.3)。認証に失敗したとき、authenticateの返り値はfalseになることを思い出してください。ユーザーのサインイン方法の方針をまとめると以下のようになります。

def create
  user = User.find_by(email: params[:session][:email].downcase)
  if user && user.authenticate(params[:session][:password])
    # ユーザーをサインインさせ、ユーザーページ (show) にリダイレクトする。
  else
    # エラーメッセージを表示し、サインインフォームを再描画する。
  end
end

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

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

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

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

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

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

リスト8.10 サインインの失敗を扱う (誤りあり)。
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])
      # ユーザーをサインインさせ、ユーザーページ (show) にリダイレクトする。
    else
      flash[:error] = 'Invalid email/password combination' # 誤りあり!
      render 'new'
    end
  end

  def destroy
  end
end

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

failed_signin_flash_bootstrap
図8.6サインインに失敗したときのフラッシュメッセージ。(拡大)

残念ながら、本文およびリスト8.10のコメントで述べたように、このコードには誤りがあります。ページの表示には問題がないようですが、どこがおかしいのでしょうか。実は、あるリクエストに対するフラッシュメッセージの内容がすぐに消えずに残ってしまうという問題があります。リスト7.28で使用したリダイレクトのときとは異なり、renderで表示したテンプレートを再描画してもリクエストと見なされません。このため、フラッシュメッセージはひとつのリクエストに対して期待よりも長い間消えずに表示され続けてしまいます。たとえば、無効な情報を送信するとサインインページにフラッシュメッセージが設定されて表示されます (図8.6)。ここでHomeページなどの別のページへのリンクをクリックすると、これはフォームが送信されてから最初のリクエストであるため、フラッシュメッセージが移動先のページでも表示されたままになってしまいます (図8.7)。

flash_persistence_bootstrap
図8.7フラッシュメッセージが消えずに残っている例。(拡大)

フラッシュメッセージの残留問題はこのアプリケーションのバグです。この問題を修正する前に、この問題をキャッチするテストを書くのが正しいやり方です。特に、現在のサインイン失敗テストではこの問題がキャッチされずにパスしてしまいます。

$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "signin with invalid information"

当然ながら、既知のバグが未修正の状態であれば、このテストはパスするべきではありません。この問題をキャッチする、失敗するテストを追加しましょう。幸い、結合テストはフラッシュメッセージ残留などの多くの問題解決において大活躍します。期待される動作は以下のテストで正確に表現されています。

describe "after visiting another page" do
  before { click_link "Home" }
  it { should_not have_selector('div.alert.alert-error') }
end

このテストは、無効なサインインデータを送信し、次にWebサイトのレイアウトにあるHomeリンクを開き、フラッシュメッセージが表示されていないことを確認します。フラッシュテストの部分を更新したテストコードをリスト8.11に示します。

リスト8.11 サインインの失敗を正しくテストするコード。
spec/requests/authentication_pages_spec.rb
require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do

    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error', text: 'Invalid') }

      describe "after visiting another page" do
        before { click_link "Home" }
        it { should_not have_selector('div.alert.alert-error') }
      end
    end
    .
    .
    .
  end
end

再度テストを実行すると、期待どおり失敗します。

$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "signin with invalid information"

失敗するテストがパスするようになるために、flashの代わりにflash.nowを使用します。これもフラッシュメッセージをページに表示するために設計されたメソッドですが、flashの場合とは異なり、他のリクエストが発生したらすぐにメッセージを消します。正しいアプリケーションコードをリスト8.12に示します。

リスト8.12 サインインが失敗したときの正しいコード。
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])
      # ユーザーをサインインさせ、ユーザーページ (show) にリダイレクトする。
    else
      flash.now[:error] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

これで、ユーザー情報が無効な場合のテストスイートが緑色 (成功) になりました。

$ bundle exec rspec spec/requests/authentication_pages_spec.rb \
> -e "with invalid information"

8.2サインイン成功

サインイン失敗をテストできるようにしたので、次は実際にユーザーをサインインさせましょう。この節で必要なRubyのプログラミングは、本書の中ではこれまでで最も難易度が高くなっています。どうか最後まであきらめずにがんばってください。ここからの力仕事に備えておきましょう。幸い、はじめの一歩は簡単です。Sessionsコントローラのcreateアクションはすぐできあがります。残念ながら、少々ズルもしています。

サインインの実装 (リスト8.12) はシンプルです。サインインに成功すると、sign_in関数を使用して実際にユーザーをサインインさせ、プロファイルページにリダイレクトします (リスト8.13)。なぜこれがズルなのかというと、何とsign_inはこの時点では存在していないのです。この節の残りは、この関数を完成させることに費やされます。

リスト8.13 Sessionsコントローラのcreateアクションが完成したところ (まだ動きません)。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      sign_in user
      redirect_to user
    else
      flash.now[:error] = 'Invalid email/password combination'
      render 'new'
    end
  end
  .
  .
  .
end

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

これよりサインインモデルの実装を開始します。具体的には、サインイン状態を「永続化」し、ユーザーが明示的にサインアウトしたときにのみセッションを終了します。サインイン関数そのものは伝統的なMVC (model-view-controller)) に帰着します。特に、いくつかのサインイン関数についてはコントローラとビューのどちらからも使用できるようにする必要があります。4.2.5を思い出してみてください。Rubyには、いくつもの関数をパッケージ化し、さまざまな場所でインクルードできるようにするモジュールという機能が提供されています。これを認証機能の実装に使用しましょう。認証のためにまったく新しいモジュールを作ることも可能ですが、Sessionsコントローラには既にSessionsHelperというモジュールが備わっています。さらに、ヘルパーはRailsのビューに自動的にインクルードされるので、Applicationコントローラにこのモジュールをインクルードするだけで、Sessionsヘルパー機能をコントローラ内で使用できるようになります (リスト8.14)。

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

デフォルトでは、すべてのヘルパーはビューで使用できますが、コントローラでは使用可能になっていません。Sessionsヘルパーはビューとコントローラの両方でメソッドが必要となるので、コントローラでは上のように明示的にインクルードする必要があります。

HTTPはステートレスなプロトコルであり、そのままでは状態が保存されないので、Webアプリケーションのサインインは、ページからページの移動を追跡するという方法で実装する必要があります。ユーザーのサインイン状態を保持するひとつの方法は、伝統的なRailsセッション (特殊なsession関数を使用) を使って、ユーザーIDに等しい「記憶トークン (remember token)」を保持することです。

session[:remember_token] = user.id

このsessionオブジェクトは、ユーザーidをcookiesに保持することで、ページ移動後にもユーザーidを参照できるようにしています。cookiesはブラウザが閉じられると無効になります。アプリケーションは、ページごとに以下の呼び出しを行います。

User.find(session[:remember_token])

これだけで、ユーザーを取り出すことができます。Railsでは、セッションがセキュアになるように扱っていますので、悪意のあるユーザーがidをなりすまそうとしても、Railsはセッションごとに生成される特別のセッションidによって不一致を検出します。

私たちのアプリケーション設計では、永続的なセッションを採用します。つまり、ブラウザを閉じた後にもサインイン状態を保持するということであり、サインインしたユーザーに対して何らかの恒久的な識別子を使用する必要があります。これを実現するために、ユーザーごとに一意かつ安全な記憶トークンを生成し、ブラウザを閉じても無効にならない恒久的なcookiesとして登録します。

記憶トークンはユーザーに関連付けられる必要があります。また、今後使用するときのために保持しておく必要もあります。そこで、図8.8に示すUserモデルの属性に記憶トークンの保存場所を追加しましょう。

user_model_remember_token_31
図8.8remember_token属性を追加したUserモデル。

最初に、Userモデルのspecに若干の追加を行います (リスト8.15)。

リスト8.15 記憶トークン用の最初のテスト。
spec/models/user_spec.rb
require 'spec_helper'

describe User do
  .
  .
  .
  it { should respond_to(:password_confirmation) }
  it { should respond_to(:remember_token) }
  it { should respond_to(:authenticate) }
  .
  .
  .
end

コマンドラインで以下のように記憶トークンを生成することで、上のテストがパスするようになります。

$ rails generate migration add_remember_token_to_users

次に、上で生成されたマイグレーションにリスト8.16のコードを記入します。ユーザーを記憶トークンで検索できるように、インデックス (コラム 6.2) をremember_tokenカラムに追加していることに注目してください。

リスト8.16 remember_tokenusersテーブルに追加したマイグレーション。
db/migrate/[ts]_add_remember_token_to_users.rb
class AddRememberTokenToUsers < ActiveRecord::Migration
  def change
    add_column :users, :remember_token, :string
    add_index  :users, :remember_token
  end
end

次に、いつものように開発データベースとテストデータベースを更新します。

$ bundle exec rake db:migrate
$ bundle exec rake db:test:prepare

この時点で、Userモデルのspecはパスするはずです。

$ bundle exec rspec spec/models/user_spec.rb

ここで、記憶トークンに何を使用するかを決める必要があります。有力な候補としてさまざまなものが考えられますが、基本的には一意性を確保できる、長くてランダムな文字列でさえあればどんなものでも良いでしょう。Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドならこの用途にぴったりです3。このメソッドは、A–Z、a–z、0–9、“-”、“_”のいずれかの文字 (64種類) からなる長さ16のランダムな文字列を返します (そのためbase64と呼ばれています)。生成された2つの記憶トークンが偶然一致 (衝突) する確率は、64のマイナス16乗、2のマイナス96乗 (10のマイナス29乗) にほぼ等しく、トークンが衝突することはまずありません。

ここでのねらいは、ブラウザにこのbase64トークンを保存しておき、データベースにはトークンを暗号化したものを保存することです。そして、cookiesからこのトークンを読みだして暗号化し、データベース上にある暗号化された記憶トークンと一致するものがあるかどうかを検索することにより、ユーザーを自動的にサインインさせることができます。暗号化したトークンだけをデータベースに保存する理由は、万が一データベースが不正アクセスを受けるようなことがあっても、攻撃者が記憶トークンを使用してサインインできないようにするためです。記憶トークンをさらにセキュアにするためには、新しいセッションを作成するたびにトークンを更新するのがよい方法です。この方法なら、攻撃者が盗んだcookiesを使用して本物のユーザーになりすましてサインインしようとする (セッションハイジャック) ことがあっても、ユーザーが次回サインインするときにはトークンが期限切れになります。(セッションハイジャックは、セキュリティ上の注意を呼びかけるためにこれを実演するFiresheepアプリケーションによって広く知られるようになりました。Firesheepを使用すると、公共Wi-Fiネットワーク経由で接続したときに多くの有名Webサイトの記憶トークンが丸見えになっていることがわかります。この問題を解決するには、7.4.4で説明したようにWebサイト全体をSSLで暗号化することです。)

サンプルアプリケーションでは、新規作成されたユーザーをその場でサインインさせ、その副作用として記憶トークンがついでに作成されますが、この動作に依存すべきではありません。安全を確保するために、すべてのユーザーが必ず最初から有効な記憶トークンを持つようにする必要があります。これを確実に行うには、コールバックを使用して記憶トークンを生成します。このテクニックは、6.2.5でemailの一意性の文脈で紹介しました。あのときはbefore_saveというコールバックを使用しましたが、ここではそれによく似たbefore_createというコールバックを使用して、ユーザー新規作成時に記憶トークンを設定することにします4

記憶トークンをテストするために、最初にテストユーザーを保存し (これまでは作成されても保存はされていませんでした)、次にユーザーのremember_token属性が空欄でないことを確認します。これにより、必要が生じたときにランダム文字列を変更するのに十分な柔軟性が得られます。このテストをリスト8.17に示します。

リスト8.17 記憶トークンが有効である (空欄のない) ことをテストする。
spec/models/user_spec.rb
require 'spec_helper'

describe User do

  before do
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end

  subject { @user }
  .
  .
  .
  describe "remember token" do
    before { @user.save }
    its(:remember_token) { should_not be_blank }
  end
end

上のリスト8.17で導入したitsメソッドは、itと似ていますが、itが指すテストのsubject (ここでは@user) そのものではなく、引数として与えられたその属性 (この場合は:remember_token) に対してテストを行うときに使用します。

its(:remember_token) { should_not be_blank }

つまり、上のコードは以下と等価です。

it { expect(@user.remember_token).not_to be_blank }

このアプリケーションコードでは、新しい要素をいくつか導入します。最初に、記憶トークンを作成するために以下のようにコールバックメソッドを追加します。

before_create :create_remember_token

上のコードはメソッド参照と呼ばれるもので、こうすることでRailsはcreate_remember_tokenというメソッドを探し、ユーザーを保存する前に実行するようになります (リスト6.20では、before_saveに明示的にブロックを渡していましたが、メソッド参照の方が一般にお勧めできます) 。次に、このメソッド自身はUserモデルの内部でしか使用しないので、外部のユーザーがアクセスできるようにする必要はありません。7.3.2でも触れたように、メソッド定義に以下のようにprivateキーワードを与えるのがRuby流です。

private

  def create_remember_token
    # トークンを作成する。
  end

privateキーワード以降で定義されたメソッドはすべて隠蔽されます。

$ rails console
>> User.first.create_remember_token

このため、上をコンソールで実行するとNoMethodError例外が発生します。

最後に、create_remember_tokenメソッドは、ユーザーの属性のひとつに「要素代入 (assignment)」する必要があります。この文脈では、remember_tokenの前にselfキーワードを追加する必要があります。

  def User.new_remember_token
    SecureRandom.urlsafe_base64
  end

  def User.encrypt(token)
    Digest::SHA1.hexdigest(token.to_s)
  end

  private

    def create_remember_token
      self.remember_token = User.encrypt(User.new_remember_token)
    end

上のコードにselfというキーワードがないと、要素代入によってremember_tokenという名前のローカル変数が作成されてしまうので、注意が必要です (ここで本来必要なのはローカル変数ではなく、インスタンス変数です)。この動作は、Rubyにおけるオブジェクト内部への要素代入の仕様によるものです。selfキーワードを与えることで、要素代入は正しくそのユーザーのremember_token属性を設定するようになり、その結果ユーザーが保存されるときに他の属性と一緒にこの属性もデータベースに保存されます (リスト6.20の他のbefore_saveコールバックでも、emailではなくself.emailと記述していた理由が、これでおわかりいただけたと思います)。

SHA1を使用して記憶トークンを暗号化すると、6.3.1でユーザーのパスワード暗号化に使用していたBcryptアルゴリズムよりも動作がはるかに高速である点に注目してください。8.2.2でも説明しますが、トークンの暗号化はユーザーがページを開くたびに行うので、暗号化アルゴリズムが高速であることは重要です。SHA1はBcryptと比べるとそれほどセキュアではありませんが、元となるトークンが既に16桁のランダムな文字列なので、これで十分です。このようなランダムな文字列をさらにSHA1でhexdigestしたものは、本質的にクラック不可能です (to_sメソッドを呼び出しているのは、nilトークンを扱えるようにするためです。ブラウザでnilトークンが発生することはあってはなりませんが、テスト中に発生することはありえるためです)。

encryptメソッドとnew_remember_tokenメソッドは、ユーザーのインスタンスに対して動作する必要がないので、インスタンスではなくUserクラスに属します (クラスメソッド)。また、これらのメソッドはprivate行より上にあるのでpublicになっていますが、これは8.2.3でUserモデルの外で使用する必要があるためです。

以上の実装をまとめたUserモデルをリスト8.18に示します。

リスト8.18 before_createコールバックを使用して remember_token属性を作成する。
app/models/user.rb
class User < ActiveRecord::Base
  before_save { self.email = email.downcase }
  before_create :create_remember_token
  .
  .
  .
  def User.new_remember_token
    SecureRandom.urlsafe_base64
  end

  def User.encrypt(token)
    Digest::SHA1.hexdigest(token.to_s)
  end

  private

    def create_remember_token
      self.remember_token = User.encrypt(User.new_remember_token)
    end
end

ちなみに、privateキーワード以降のコードは、強調のためcreate_remember_tokenのインデントを1段深くしてあります (経験上、こうしておくことをお勧めします)。

SecureRandom.urlsafe_base64は決して空欄にはならなくなったので、Userモデルのテストはパスするはずです。

$ bundle exec rspec spec/models/user_spec.rb

8.2.2正しいsign_inメソッド

いよいよ、最初のサインイン要素であるsign_in関数自身の実装に取りかかりましょう。既に述べたように、これから作る認証メソッドは、記憶トークンをブラウザのcookiesに保存し、ユーザーが別のページに移動する (8.2.3で実装予定) ときにそのトークンを使用してデータベース内のユーザーのレコードを見つけます。このコードをリスト8.19に示します。なお、このコードにはcurrent_userというメソッドがありますが、これは8.2.3で実装します。

リスト8.19 完全だがまだ動作しないsign_in関数。
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.encrypt(remember_token))
    self.current_user = user
  end
end

ここでは以下の手順で進めます。1) トークンを新規作成する。2) 暗号化されていないトークンをブラウザのcookiesに保存する。3) 暗号化したトークンをデータベースに保存する。4) 与えられたユーザーを現在のユーザーに設定する (8.2.3)。上のコードで、update_attributeを使用してトークンを保存している点に注目してください。6.1.5でも簡単に説明しましたが、このメソッドを使用すれば検証をすり抜けて単一の属性を更新することができます。ページの移動ではユーザーのパスワードやパスワード確認は与えられないので、このメソッドを使用する必要があります。

リスト8.19ではRailsが提供するcookiesユーティリティも使用しています。これを使用することで、ブラウザのcookiesをハッシュのように扱うことができます。cookiesの各要素は、それ自体が2つの要素 (valueとオプションのexpires日時) のハッシュになっています。たとえば以下のように、cookiesに20年後に期限切れになる記憶トークンに等しい値を保存することで、ユーザーのサインインを実装できます。

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

(上のコードではRailsの便利なtimeヘルパーを使用しています。コラム 8.1を参照してください。)

上のように20年で期限切れになるcookies設定はよく使われるようになり、今ではRailsにも特殊なpermanentという専用のメソッドが追加されたほどです。このメソッドを使用すると、コードは以下のようにシンプルになります。

cookies.permanent[:remember_token] = remember_token

上のコードでは、permanentメソッドによって自動的に期限が20.years.from_nowに設定されます。

cookiesを設定後、移動先のページで以下のようなコードを使用してユーザーを取り出すことができます。

User.find_by(remember_token: remember_token)

もちろん、このcookies本物のハッシュではなく、実際にはcookiesに割り当てを行ったときにブラウザ上のテキストの断片を保存しているだけです。そうしたアプリケーションの実装の詳細を気にしなくてよい点に、Railsの美しさの一端が垣間見えます。

8.2.3現在のユーザー

後で使うために記憶トークンをcookiesに保存する方法の説明が終わりましたので、今度は移動先のページでユーザーを取り出す方法について学びましょう。sign_in関数のコードをもういちどよく見てみてください。

module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.encrypt(remember_token))
    self.current_user = user
  end
end

現時点では、上のコードのうち、以下のコードだけが動作していません。

self.current_user = user

この行の目的は、コントローラからもビューからもアクセスできるcurrent_userを作成することです。これにより、以下のような構文を使用できるようになります。

<%= current_user.name %>

以下も使用できるようになります。

redirect_to current_user

リスト8.18のときに説明したのと同じ理由で、ここにもselfが必要です。selfがないと、Rubyは単にcurrent_userというローカル変数を作成してしまいます。

current_userのコードを書く上で、以下の行については注意が必要です。

self.current_user = user

上は「要素代入 (assignment)」であることに注意してください。このcurrent_user=は別途定義が必要です。リスト8.20に示したように、Rubyにはこのような要素代入関数を定義する特殊な文法があります。

リスト8.20 current_userへの要素代入を定義する。
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    .
    .
    .
  end

  def current_user=(user)
    @current_user = user
  end
end

上のコードで使用されている特殊な文法は混乱しやすいので注意してください。普通のプログラミング言語では、定義するメソッドの名前に等号を使用することはできませんが、Rubyではメソッド名末尾の等号には特殊な意味があり、上のコードはcurrent_userへの要素代入を扱うように設計されたcurrent_user=というメソッドを単に定義します。つまり、以下のコードは

self.current_user = ...

自動的に以下のコードに置き換えられます。

current_user=(...)

そして最終的にcurrent_user=というメソッドが呼び出されます。その引数は要素代入の右側にひとつ置かれます (ここではサインインするユーザー)。この一行メソッドは、単に@current_userインスタンス変数を設定し、後に使用するためにユーザーを効率よく保存します。

通常のRubyでは、もうひとつのメソッドとしてcurrent_userを定義することができます。リスト8.21に示したように、このメソッドは@current_userの値を返すようになっています。

リスト8.21 つい使ってみたくなるが実際には役に立たないcurrent_userの定義
module SessionsHelper

  def sign_in(user)
    .
    .
    .
  end

  def current_user=(user)
    @current_user = user
  end

  def current_user
    @current_user     # 役に立たない。この行は使用しないこと。
  end
end

もしかすると、これを使用して4.4.5attr_accessorを効率よく置き換えることができるのではないでしょうか5。それをしないのは、このコードを使用すると今までの苦労が台無しになってしまうからです。リスト8.21のコードを使用すると、ユーザーが別のページに移動した瞬間にユーザーのサインイン情報がきれいさっぱり消滅してしまいます。セッションは終了し、ユーザーは自動的にサインアウトしてしまいます。この問題を回避するには、リスト8.22に示したように、リスト8.19のコードで作成した記憶トークンに対応するユーザーを検索します。データベース上の記憶トークンは暗号化されているので、cookiesから取り出した記憶トークンは、データベース上の記憶トークンを検索する前に暗号化する必要がある点に注意してください。これを行うには、リスト8.18で定義したUser.encryptを使用します。

リスト8.22 remember_tokenを使用して現在のユーザーを検索する。
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  def current_user=(user)
    @current_user = user
  end

  def current_user
    remember_token = User.encrypt(cookies[:remember_token])
    @current_user ||= User.find_by(remember_token: remember_token)
  end
end

リスト8.22では、Rubyでは定番の (しかし最初はまごつきやすい) ||= (“or equals”) 代入演算子を使用しています (コラム 8.2)。この代入演算子は、@current_userが未定義の場合にのみ、@current_userインスタンス変数に記憶トークンを設定します6

@current_user ||= User.find_by(remember_token: remember_token)

あるユーザーに対してcurrent_userが初めて呼び出される場合はfind_byメソッドを呼び出しますが、以後の呼び出しではデータベースにアクセスせずに@current_userを返します7。このコードは、ひとつのユーザーリクエストに対してcurrent_userが何度も使用される場合にのみ有用です。いずれの場合も、ユーザーがWebサイトにアクセスするとfind_byは最低1回は呼び出されます。

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

サインイン/サインアウトが動作するようになり、実用的なアプリケーションらしくなってきました。今度は、サインインの状態に合わせてレイアウト上のリンクが変わるようにしましょう。特に、図8.3のモックアップで示したように、ユーザーがサインインしているときとサインアウトしているときでリンクを変更し、すべてのユーザーを表示するリンクとユーザー設定用のリンクも追加し (第9章で完成する予定)、さらに現在のユーザーのプロファイルのリンクも追加します。これらを実装することで、リスト8.6のテストがパスするようになります。つまり、本章開始以来初めてテストスイート全体が緑色 (成功) になるということです。

サイトレイアウトのリンクを変更するには、埋め込みRubyの内側でif-else分岐構造を使用します。

<% if signed_in? %>
  # サインインしているユーザー用のリンク
<% else %>
  #サインインしていないユーザー用のリンク
<% end %>

この種のコードでは、signed_in? 論理値が必要になりますので、これから定義しましょう。

ユーザーがサインインしている状態は、セッションに現在のユーザーがいる (current_usernilでない) ことで表されます。これを表現するには否定の演算子が必要なので、! ("bang" と読みます) を使用します。リスト8.23に示すとおり、現在の文脈ではcurrent_usernilない場合にユーザーがサインインしています。

リスト8.23 signed_in?ヘルパーメソッド。
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.encrypt(remember_token))
    self.current_user = user
  end

  def signed_in?
    !current_user.nil?
  end
  .
  .
  .
end

signed_in?メソッドを手作りしてあるので、レイアウトのリンクはすぐに作成できます。なお、新しく作るリンクは4つですが、そのうち以下の2つのリンクは未実装です (第9章で完成の予定)。

<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>

サインアウトのリンクには、リスト8.2で定義したサインアウトのパスを使用します。

<%= link_to "Sign out", signout_path, method: "delete" %>

(上のコードでは、サインアウトのリンクがハッシュ引数を渡していることに注目してください。このハッシュ引数は、HTTPのDELETEリクエストを使用して送信される必要があることを示しています8)。

<%= link_to "Profile", current_user %>

なお、上のコードは以下のように書くこともできます。

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

Railsでは、このユーザーへの直接リンクが許されるので、この場合current_useruser_path(current_user)に自動的に変換されます。

新しいリンクをレイアウトに追加するときに、Bootstrapの機能を使用してドロップダウンメニューを実現しましょう。詳細については「Bootstrapコンポーネント (英語)」を参照してください。完全なコードをリスト8.24に示します。このコードでは、Bootstrapのドロップダウンメニューに関連するCSSのidとクラスが与えられていることに注目してください。

リスト8.24 サインインしているユーザー用にリンクを変更する。
app/views/layouts/_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
  <div class="navbar-inner">
    <div class="container">
      <%= link_to "sample app", root_path, id: "logo" %>
      <nav>
        <ul class="nav pull-right">
          <li><%= link_to "Home", root_path %></li>
          <li><%= link_to "Help", help_path %></li>
          <% if signed_in? %>
            <li><%= link_to "Users", '#' %></li>
            <li id="fat-menu" 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 "Sign out", signout_path, method: "delete" %>
                </li>
              </ul>
            </li>
          <% else %>
            <li><%= link_to "Sign in", signin_path %></li>
          <% end %>
        </ul>
      </nav>
    </div>
  </div>
</header>

ドロップダウンメニューを実現するには、BootstrapのJavaScriptライブラリが必要です。このライブラリは、RailsのAsset Pipelineを使用してインクルードすることができます。そのためには、リスト8.25に示すようにアプリケーションのJavaScriptファイルを編集します。

リスト8.25 Bootstrap JavaScriptライブラリをapplication.jsに追加する。
app/assets/javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require bootstrap
//= require turbolinks
//= require_tree .

上のコードは、Sprocketsライブラリを使用してBootstrap JavaScriptをインクルードします。5.1.2bootstrap-sass gemを導入してあるので、これでBootstrapが利用できるようになります。

リスト8.24のコードを使用することで、すべてのテストにパスするはずです。

$ bundle exec rspec spec/

以上の変更を行ったことで、図8.9に示したように、リスト8.24で定義したドロップダウンメニューがサインインしたユーザーに表示されるようになりました。

profile_with_signout_link_bootstrap
図8.9サインインしたユーザーにリンクとドロップダウンが表示されるようになった。(拡大)

この時点で、ブラウザで実際にサインインできることを確認し、続いてブラウザを閉じてから再度サンプルアプリケーションを表示するとサインインしたままになっていることを確認してください。その気になれば、ブラウザのcookiesをブラウザで調べて結果を直接確認することもできます (図8.10)。

cookie_in_browser
図8.10ローカルのブラウザで記憶トークンのcookiesを表示する。(拡大)

8.2.5ユーザー登録と同時にサインインする

認証機能の基本的な部分はできましたが、ユーザーが登録を行った後、そのユーザーがデフォルトではサインインしておらず、このままではユーザーが混乱する可能性があります。そこで、サインアウトの機能を実装する前にその部分をもう少し作り込みましょう。最初に、認証のテストを追加します (リスト8.26)。ここには、7.6の演習にあるリスト7.32でも使った “after saving the user” describeブロックが含まれています。このリストに対応する演習を行なっていなかった方は、この時点で追加する必要があります。

リスト8.26 新規ユーザー登録後にユーザーがサインインしたことをテストする。
spec/requests/user_pages_spec.rb
require 'spec_helper'

describe "User pages" do
    .
    .
    .
    describe "with valid information" do
      .
      .
      .
      describe "after saving the user" do
        before { click_button submit }
        let(:user) { User.find_by(email: 'user@example.com') }

        it { should have_link('Sign out') }
        it { should have_title(user.name) }
        it { should have_selector('div.alert.alert-success', text: 'Welcome') }
      end
    end
  end
end

上のコードでは、ユーザー登録後にサインインしていることを確認するために、サインアウト用のリンクが表示されているかどうかをテストしています。

8.2sign_inメソッドを作成してあるので、ユーザーをデータベースに保存した直後にsign_in @userを追加するだけで、ユーザーが実際にサインインしてテストにパスするようになります (リスト8.27)。

リスト8.27 ユーザー登録後にサインアップする。
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      sign_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end
  .
  .
  .
end

8.2.6サインアウトする

8.1で説明したとおり、このアプリケーションで使用する認証モデルでは、ユーザーは明示的にサインアウトするまでサインインしたままになります。この節では、そのサインアウト機能を追加します。

Sessionsコントローラのアクションは、これまでもRESTful慣例に従ってサインインページにはnewを使用し、サインインの完了にはcreateを使用しました。今回も同様に慣例に従い、セッションの削除 (サインアウト) にはdestroyを使用します。このことをテストするには、[Sign out] リンクをクリックして、そこにサインイン用のリンクが再び現れるかどうかを確認します (リスト8.28)。

リスト8.28 ユーザーのサインアウトをテストする。
spec/requests/authentication_pages_spec.rb
require 'spec_helper'

describe "Authentication" do
  .
  .
  .
  describe "signin" do
    .
    .
    .
    describe "with valid information" do
      .
      .
      .
      describe "followed by signout" do
        before { click_link "Sign out" }
        it { should have_link('Sign in') }
      end
    end
  end
end

サインインがsign_in関数に依存しているのと同様に、サインアウトでもsign_out関数を単に実行します (リスト8.29)。

リスト8.29 セッションを削除する (ユーザーのサインアウト)。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    sign_out
    redirect_to root_url
  end
end

他の認証用機能と同様に、sign_outもSessionsヘルパーモジュールの中に置きます。実装は極めてシンプルです。現在のユーザーをnilにし、deleteメソッドを使用して、cookiesにあるセッションの記憶トークンを削除します (リスト8.30)。(destroyアクション内ですぐにリダイレクトされるので、現在のユーザーをnilにする必要は必ずしもありません。ただし、リダイレクトなしでsign_outを使用したい状況が今後発生するかもしれないので、そのときに役立つでしょう。)

リスト8.30 Sessionsヘルパーモジュールのsign_outメソッド。
app/helpers/sessions_helper.rb
module SessionsHelper

  def sign_in(user)
    remember_token = User.new_remember_token
    cookies.permanent[:remember_token] = remember_token
    user.update_attribute(:remember_token, User.encrypt(remember_token))
    self.current_user = user
  end
  .
  .
  .
  def sign_out
    self.current_user = nil
    cookies.delete(:remember_token)
  end
end

これでユーザー登録/サインイン/サインアウトがすべて揃いました。テストスイートはパスするはずです。

$ bundle exec rspec spec/

本書のテストスイートは認証システムをほぼカバーしていますが、すべてをカバーしているわけではありません。この点をご了承ください。たとえば、[このアカウント設定を保存する] の cookies (remember me) が有効になっているか、その後も保持されているかどうかのテストは含まれていません。このテストは作成可能ですが、著者の経験上、cookiesの値を直接調べる方法はRailsの実装に左右されやすい傾向があり、次のバージョンのRailsではまた変わるかもしれません。その場合、アプリケーションコードは正常に動作してもテストが正常に動作しなくなります。このような理由で、本書のテストコードでは、コアとなるアプリケーションコードをテストする際に高度な機能 (ユーザーがサインインできること、ページ移動後もサインインしていること、サインアウトできること) に重点を置き、重要性の低い機能は必ずしも含んでいません。

8.3Cucumberの紹介 (オプション)

サンプルアプリケーションの認証システムの基礎部分が無事完成したので、この機会にCucumberを使用したサインインのテスト方法をご紹介します。Cucumberは振舞駆動開発用のツールとして有名で、Rubyコミュニティに多くの愛用者がいます。この節の内容は必須ではありませんので、スキップしても問題ありません。

Cucumberを使用すると、アプリケーションの振る舞いをテキストベースの「ストーリー」で定義することができます。多くのRailsプログラマーは、Cucumberは顧客と共同作業するときに便利であることを知っています。Cucumberのストーリーは、専門知識のない人でも読むことができ、ストーリーを顧客と共有したり、場合によっては顧客がストーリーを作成することすらできるためです。もちろん、テスティングのフレームワークが純粋なRubyでないという点は残念でもあり、著者にとってはテキストベースのストーリーはいささか冗長な面もあると思われます。それでもCucumberはRubyのテスティングツールキットとして確固たる地位を占めており、著者としては低レベルの実装を気にすることなく高度な振る舞いを記述できる点が特に気に入っています。

本書ではRSpecとCapybaraをテスティングのメインに据えているので、この節のCucumberに関する説明は完全ではなく、表面的で物足りないことでしょう。この節の目的はCucumberのおいしさ (間違いなくシャキシャキして汁気たっぷりです) を知ってもらうための、いわば試食です。もし気に入っていただけたら、テスティングツールに関する完結した書籍がいくつもあります。(おすすめは「The RSpec Book 」(David Chelimsky著)、「 Rails 3 in Action 」(Ryan Bigg、Yehuda Katz著)、 「The Cucumber Book」(Matt Wynne、Aslak Hellesøy著) です)。(訳注: 日本語で読めるCucumberの書籍としては「はじめる!Cucumber」(諸橋 恭介著) があります。)

8.3.1インストールと設定

Cucumberをインストールするには、最初にcucumber-rails gemとdatabase_cleanerというユーティリティgemをGemfile:testグループに追加します (リスト8.31)。

リスト8.31 cucumber-rails gemをGemfileに追加する。
.
.
.
group :test do
  .
  .
  .
  gem 'cucumber-rails', '1.4.0', :require => false
  gem 'database_cleaner', github: 'bmabey/database_cleaner'
end
.
.
.

次に、いつものように以下を実行します。

$ bundle install

アプリケーションでCucumberを使用するための設定を行うために、次は必要なサポート用ファイルとディレクトリを生成します。

$ rails generate cucumber:install

上のコマンドにより、Cucumber関連のファイルが置かれるfeatures/ディレクトリが作成されます。

8.3.2フィーチャーとステップ

Cucumberでは、Gherkin (キュウリ属の植物: ガーキン) と呼ばれるテキストベースの言語を使用して、アプリケーションに期待される振る舞いを記述します。Gherkinで書かれたテストは、ちゃんと書かれたRSpecの例と同じぐらい読みやすくできています。どちらもテキストベースであり、自然な英語に近くRubyコードよりも読みやすいためです。

ここでは、リスト8.5リスト8.6のサインインのテスト例のサブセットをCucumberで実装してみましょう。最初に、features/ディレクトリ内にsigning_in.featureというファイルを作成します。

Cucumberのフィーチャーファイルは、以下のようにその機能の簡単な説明から始まります。

Feature: Signing in

次に個別のシナリオを追加します。たとえば、サインイン失敗をテストするには、以下のようなシナリオを作成します。

  Scenario: Unsuccessful signin
    Given a user visits the signin page
    When he submits invalid signin information
    Then he should see an error message

同様に、サインイン成功をテストするために以下を使用できます。

  Scenario: Successful signin
    Given a user visits the signin page
      And the user has an account
    When the user submits valid signin information
    Then he should see his profile page
      And he should see a signout link

これらをまとめたものをリスト8.32に示します。

リスト8.32 ユーザーのサインインをテストするCucumberのフィーチャーファイル。
features/signing_in.feature
Feature: Signing in

  Scenario: Unsuccessful signin
    Given a user visits the signin page
    When he submits invalid signin information
    Then he should see an error message

  Scenario: Successful signin
    Given a user visits the signin page
      And the user has an account
    When the user submits valid signin information
    Then he should see his profile page
      And he should see a signout link

これらのフィーチャーを実行するには、cucumber実行ファイルを以下のように実行します。

$ bundle exec cucumber features/

上のCucumberのコマンドを、下のRSpecのコマンドと比較してみてください。

$ bundle exec rspec spec/

ここからおわかりだと思いますが、CucumberはRSpecと同様Rakeタスクから呼び出すこともできます。

$ bundle exec rake cucumber

(rake cucumber:okと書くこともできます)。

まだテキストを書いただけなので、当然ながらこのままではCucumberのシナリオはテストにパスしません。テストスイートが緑色 (成功) になるためには、テキストファイルをRubyコードにマップするステップファイルを作成します。ステップファイルはfeatures/step_definitionsディレクトリに置きます。ファイル名はここではauthentication_steps.rbとします。

Feature行とScenario行は説明のためのものですが、それ以外の行はRubyに対応付けられる必要があります。たとえば、フィーチャーファイルにある以下のコードは、

Given a user visits the signin page

以下のステップ定義によって扱われます。

Given /^a user visits the signin page$/ do
  visit signin_path
end

フィーチャーファイルの中ではGivenは単なる文字列ですが、ステップファイルの中ではGivenメソッドであり、正規表現とブロックを引数に取ります。この正規表現はシナリオの中の行とマッチし、次のブロックの内容は、そのステップを実装するために必要なRubyのコードです。この場合、“a user visits the signin page”という記述は以下のコードによって実装されます。

visit signin_path

上のコードはどこかで見たことがあると思ったら、それもそのはず、Capybaraです。CapybaraはデフォルトでCucumberのステップファイルに含まれます。次の2行もわかりやすいと思います。

When he submits invalid signin information
Then he should see an error message

上のフィーチャーファイルのコードは、ステップファイルでは以下のように扱われます。

When /^he submits invalid signin information$/ do
  click_button "Sign in"
end

Then /^he should see an error message$/ do
  expect(page).to have_selector('div.alert.alert-error')
end

最初のステップでもCapybaraが使用されていますが、その次のステップではCapybaraのpageオブジェクトとRSpecが併用されています。見てのとおり、RSpecとCapybaraで行えるテストは、すべてCucumberでも行えます。

残りのステップも同様に進められます。最終的なステップ定義ファイルをリスト8.33に示します。ステップを追加したら、以下を実行します。

$ bundle exec cucumber features/

テストにパスするまでこれを繰り返します。

リスト8.33 サインインフィーチャーがパスするための完全なステップ定義。
features/step_definitions/authentication_steps.rb
Given /^a user visits the signin page$/ do
  visit signin_path
end

When /^he submits invalid signin information$/ do
  click_button "Sign in"
end

Then /^he should see an error message$/ do
  expect(page).to have_selector('div.alert.alert-error')
end

Given /^the user has an account$/ do
  @user = User.create(name: "Example User", email: "user@example.com",
                      password: "foobar", password_confirmation: "foobar")
end

When /^the user submits valid signin information$/ do
  fill_in "Email",    with: @user.email
  fill_in "Password", with: @user.password
  click_button "Sign in"
end

Then /^he should see his profile page$/ do
  expect(page).to have_title(@user.name)
end

Then /^he should see a signout link$/ do
  expect(page).to have_link('Sign out', href: signout_path)
end

リスト8.33のコードにより、以下のCucumberのテストはパスするはずです。

$ bundle exec cucumber features/

8.3.3対比: RSpecのカスタムマッチャー

簡単なCucumberのシナリオをいくつか紹介したので、それらと同等のRSpecの例と比較してみましょう。まず、リスト8.32のCucumberフィーチャーと、それに対応するリスト8.33のステップ定義をもう一度見てみましょう。次に、以下のRSpecリクエストspec (結合テスト) を見てみましょう。

describe "Authentication" do

  subject { page }

  describe "signin" do
    before { visit signin_path }

    describe "with invalid information" do
      before { click_button "Sign in" }

      it { should have_title('Sign in') }
      it { should have_selector('div.alert.alert-error', text: 'Invalid') }
    end

    describe "with valid information" do
      let(:user) { FactoryGirl.create(:user) }
      before do
        fill_in "Email",    with: user.email.upcase
        fill_in "Password", with: user.password
        click_button "Sign in"
      end

      it { should have_title(user.name) }
      it { should have_link('Profile',     href: user_path(user)) }
      it { should have_link('Sign out',    href: signout_path) }
      it { should_not have_link('Sign in', href: signin_path) }
    end
  end
end

Cucumberと結合テストでそれぞれどのように実装されているかがおわかりいただけると思います。Cucumberのフィーチャーは非常に読みやすいのですが、その代わり実装されているコードから完全に切り離されていて、両刃の剣です。著者にとって、Cucumberは読みやすいが書くのは面倒、結合テストはプログラマーにとってはそれほど読みやすくない代わりに書くのははるかに楽、という印象です。

Cucumberではフィーチャーとステップが分離されていることにより、抽象度の高い記述が可能であるという効果があります。以下のフィーチャーは、エラーメッセージが表示されるはずであるということを記述しています。

Then he should see an error message

そして以下のステップファイルでは、このテストを実装しています。

Then /^he should see an error message$/ do
  expect(page).to have_selector('div.alert.alert-error', text: 'Invalid')
end

これが特に便利なのは、実装に依存するのが2番目の要素であるステップファイルだけである点です。そのため、たとえばエラーメッセージを表示するためのCSSを変更しても、フィーチャーファイルは変更不要です。

同様に、以下のようなコードを何度も書くのは多くの人にとって苦痛だと思います。

should have_selector('div.alert.alert-error', text: 'Invalid')

本当に行いたいのは、そのページでエラーメッセージが表示されることを示すことのはずです。しかも、上の記法は実装に密着しているので、実装が変更されたらそれらもすべて変更が必要になります。純粋なRSpecでは、カスタムマッチャーを使用してこの問題を解決することができます。カスタムマッチャーを使用すると、上のコードを以下のように簡潔に記述することができます。

should have_error_message('Invalid')

このマッチャーは、5.3.4full_titleテストヘルパーを置いたのと同じユーティリティファイル内で定義することができます。コード自体は以下のようになります。

RSpec::Matchers.define :have_error_message do |message|
  match do |page|
    expect(page).to have_selector('div.alert.alert-error', text: message)
  end
end

同様に、よく使われる操作をヘルパーメソッドとして定義することもできます。

def valid_signin(user)
  fill_in "Email",    with: user.email
  fill_in "Password", with: user.password
  click_button "Sign in"
end

作成したサポートコードをリスト8.34に示します (5.6の演習のリスト5.38リスト5.39の結果を含みます)。 この方法は、Cucumberのステップ定義よりも柔軟であることがわかってきました。特に、マッチャーやshouldヘルパーがvalid_signin(user)のように引数を自然に取ることができます。ステップ定義は正規表現マッチャーによって繰り返すことができますが、この手法は一般に厄介なものになりやすいという印象です。

リスト8.34 ヘルパーメソッドとカスタムRSpecマッチャーを追加する。
spec/support/utilities.rb
include ApplicationHelper

def valid_signin(user)
  fill_in "Email",    with: user.email
  fill_in "Password", with: user.password
  click_button "Sign in"
end

RSpec::Matchers.define :have_error_message do |message|
  match do |page|
    expect(page).to have_selector('div.alert.alert-error', text: message)
  end
end

リスト8.34を使用することで、以下のように書くことができます。

it { should have_error_message('Invalid') }

以下も同様です。

describe "with valid information" do
  let(:user) { FactoryGirl.create(:user) }
  before { valid_signin(user) }
  .
  .
  .

テストとサイトの実装を結びつける方法の例は他にも多数あります。現在のテストスイート全般にわたって、カスタムマッチャーとカスタムメソッドを作成してテストと実装の詳細を切り離す作業については、演習に回すことにします (8.5)。

8.4最後に

本章では多くの分野をカバーし、約束どおり、かつて未成熟だったアプリケーションを、ユーザー登録とログインをフル装備したWebサイトに変身させました。認証機能を完成させるためには、サインインの状態とユーザーidに基いてページのアクセスに制限を与える必要もあります。ページごとのアクセス制限機能の実装については、ユーザー情報を編集する機能と、ユーザーをシステムから削除できる権限を管理者に与える機能を実装するときに同時に行う予定です。このアクセス制限は、第9章の主な目標でもあります。

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

$ git add .
$ git commit -m "Finish sign in"
$ git checkout master
$ git merge sign-in-out

次にリモートのGitHubリポジトリとHerokuの本番サーバーにプッシュします。

$ git push
$ git push heroku
$ heroku run rake db:migrate

8.5演習

  1. form_forの代わりにform_tagを使用して、サインインフォームをリファクタリングしてください。テストスイートが以前と同様にパスすることも確認してください。ヒント: RailsCastの「Rails 3.1における認証 (英語)」を参照してください。特に、paramsハッシュの構造の変更に注目してください。
  2. 8.3.3の例に従い、ユーザー用リクエストspecと認証リクエストspec (spec/requestsディレクトリの下にあるファイル) を見直し、spec/support/utilities.rbでユーティリティ関数を定義して、テストを実装から切り離してください。課外活動: サポートコードを独立したファイルとモジュールに再編成し、specヘルパーファイルでそれらのモジュールを適切にインクルードしてすべて動作するようにしてください。
  1. 他に、一定時間が経過するとセッションを期限切れにするモデルもあります。このモデルは、インターネットバンキングや金融取引口座などの重要な情報を扱うWebサイトに向いています。
  2. 画像はhttps://www.flickr.com/photos/hermanusbackpackers/3343254977/からの引用です。
  3. このメソッドは、RailsCastの「remember me」の記事を元に選びました。
  4. Active Recordでサポートされるコールバックの種類の詳細については、Railsガイドの「Active Record コールバック」を参照してください。
  5. 実は、この2つは完全に同等です。attr_accessorは、単にゲッターメソッドやセッターメソッドを自動的に作成する便利な方法でしかありません。
  6. 通常、これは初期値がnilである変数への代入を意味しますが、false値も||=演算子によって上書きされることに注意してください。
  7. これは、コラム 6.3で説明したメモ化 (memoization) の例になっています。
  8. Webブラウザは実際にはDELETEリクエストを発行できないので、RailsではJavaScriptを使用してこのリクエストを偽造します。
第8章 サインイン、サインアウト Rails 4.0 (第2版)