Ruby on Rails チュートリアル

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

第4版 目次

第8章基本的なログイン機構

7でWebサイトでの新規ユーザー登録が行えるようになりましたので、今度はユーザーがログインやログアウトを行えるようにしましょう。本章では、ログインの基本的な仕組みを実装していきます。ここでいうログインの基本的な仕組みとは、ブラウザがログインしている状態を保持し、ユーザーによってブラウザが閉じられたら状態を破棄するといった仕組み (認証システム (Authentification System))です。この認証システムの基盤が出来上がったら、ログイン済みのユーザー (current user) だけがアクセスできるページや、扱える機能などを制御していきます。なお、このような制限や制御の仕組みを認可モデル (Authorization Model) と呼び、例えば本章で実装するログイン済みかどうかでヘッダー部分を切り替える、といった仕組みもこれにあたります。

この認証システムと認可モデルは、今後実装するサンプルアプリケーションの様々な機能の基盤となる仕組みです。例えば10では、ログインしたユーザーだけがユーザーの一覧ページに移動できるようにしたり、正当なユーザーだけが自分のプロフィール情報を編集できるようにしたり、管理者だけが他のユーザーをデータベースから削除できるようになります。また13では、本章で使えるようになるログイン済みユーザー (current user) を利用して、ユーザーのIDとマイクロポストを関連付ける時に必要になります。最後の14では、他のユーザーをフォローする機能や、自分のフィード一覧を実装するとき、「誰がログインしているのか」という情報が必須になります。

なお、9では、本章で構築した基本的なログイン機構を改善して、より発展的なログイン機構を実装します。例えば本章で構築する仕組みでは、ブラウザを閉じるとログインしたユーザー情報を強制的に忘れて (forget) しまいますが、9で改善した認証機構では、ユーザーが任意でブラウザにログイン情報を覚えさせる (remember me) 機能を実装します (具体的には [remember me] というチェックボックスをログインフォームに用意します)。結果として、この89を通して、1: ブラウザを閉じるとログインを破棄する (Session)、 2: ユーザーのログイン情報を自動で保存する (Cookie)、3: ユーザーがチェックボックスをオンにした場合のみログインを保存する (Remember me) 、という3つの一般的なログイン機構を実装することになります。

8.1 セッション

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

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

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

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

$ git checkout -b basic-login

8.1.1 Sessionsコントローラ

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

まずは、Sessionsコントローラとnewアクションを生成するところから始めてみましょう (リスト 8.1)。

リスト 8.1: Sessionsコントローラを生成する
$ rails generate controller Sessions new

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

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

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

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

リスト 8.2のルーティングと同様に、リスト 8.1のテストを更新し、新しいログイン用の名前付きルートを使うようにする必要があります。結果は、リスト 8.3のとおりです。

リスト 8.3: Sessionsコントローラのテストで名前付きルートを使うようにする green test/controllers/sessions_controller_test.rb
require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest

  test "should get new" do
    get login_path
    assert_response :success
  end
end

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

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

名前付きルーティングもだいぶ増えてきたので、ここまで追加した全ルーティングを表示できると便利そうです。このようなときはrails routesコマンドを実行してみましょう。いつでも現状のルーティングを確認することができます。

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

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

演習

  1. GET login_pathPOST login_pathとの違いを説明できますか? 少し考えてみましょう。
  2. ターミナルのパイプ機能を使ってrails routesの実行結果とgrepコマンドを繋ぐことで、Usersリソースに関するルーティングだけを表示させることができます。同様にして、Sessionsリソースに関する結果だけを表示させてみましょう。現在、いくつのSessionsリソースがあるでしょうか? ヒント: パイプやgrepの使い方が分からない場合は Learn Enough Command Line to Be DangerousSection on Grep (英語) を参考にしてみてください。

8.1.2 ログインフォーム

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

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

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

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

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

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

form_for(@user)

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

form_for(:session, url: login_path)

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

リスト 8.4: ログインフォームのコード 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.4のコードを使うと、図 8.3のようにログインフォームが表示されます ([Log in] リンクがまだ効かないので、自分でブラウザのアドレスバーに「/login」とURLを直接入力してください。ログインリンクは8.2.3で動くようにします)。

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

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

リスト 8.5: リスト 8.4で生成したログインフォームの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.5リスト 7.17を比較することで、フォーム送信後にparamsハッシュに入る値が、メールアドレスとパスワードのフィールドにそれぞれ対応したparams[:session][:email]params[:session][:password]になることが推測できると思います。

演習

  1. リスト 8.4で定義したフォームで送信すると、Sessionsコントローラのcreateアクションに到達します。Railsはこれをどうやって実現しているでしょうか? 考えてみてください。ヒント:表 8.1リスト 8.5の1行目に注目してください。

8.1.3 ユーザーの検索と認証

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

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

リスト 8.6: 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.6createで最初に失敗したログイン

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

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

ユーザー登録の場合 (図 7.15) と同様、これらのパラメータはリスト 4.13に示したようにネストした (入れ子になった) ハッシュになっていました。特に、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.7になります。

リスト 8.7: ユーザーをデータベースから見つけて検証する 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.7) では、送信されたメールアドレスを使って、データベースからユーザーを取り出しています (6.2.5ではメールアドレスをすべて小文字で保存していたことを思い出しましょう。そこでここではdowncaseメソッドを使って、有効なメールアドレスが入力されたときに確実にマッチするようにしています)。次の行は少しわかりにくいかもしれませんが、Railsプログラミングでは定番の手法です。

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

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

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

演習

  1. Railsコンソールを使って、表 8.2のそれぞれの式が合っているか確かめてみましょう. まずはuser = nilの場合を、次にuser = User.firstとした場合を確かめてみてください。ヒント: 必ず論理値オブジェクトとなるように、4.2.3で紹介した!!のテクニックを使ってみましょう。例: !!(user && user.authenticate(’foobar’))

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

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

リスト 8.8: ログイン失敗時の処理を扱う (誤りあり) 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.31)、flash[:danger]で設定したメッセージは自動的に表示されます。Bootstrap CSSのおかげで適切なスタイルも与えられます (図 8.5)。

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

本文およびリスト 8.8のコメントで述べたように、このコードには誤りがあります。ページにはちゃんとエラーメッセージが表示されていますが、どこが問題なのでしょうか。実は上のコードのままでは、リクエストのフラッシュメッセージが一度表示されると消えずに残ってしまいます。リスト 7.29でリダイレクトを使ったときとは異なり、表示したテンプレートを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.5図 8.6の手順をテストコードで再現する必要があります。基本的な流れを次に示します。

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

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

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

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

リスト 8.10: red
$ rails test test/integration/users_login_test.rb

なお、上の例のように、rails testの引数にテストファイルを与えると、そのテストファイルだけを実行することができます。

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

リスト 8.11: ログイン失敗時の正しい処理 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.12: green
$ rails test test/integration/users_login_test.rb
$ rails test

演習

  1. 8.1.4の処理の流れが正しく動いているかどうか、ブラウザで確認してみてください。特に、flashがうまく機能しているかどうか、フラッシュメッセージの表示後に違うページに移動することを忘れないでください。

8.2 ログイン

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

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

リスト 8.13: 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メソッド (9.1) とは対照的に、sessionメソッドで作成された一時cookiesは、ブラウザを閉じた瞬間に有効期限が終了します。

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

リスト 8.14: log_inメソッド app/helpers/sessions_helper.rb
module SessionsHelper

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

sessionメソッドで作成した一時cookiesは自動的に暗号化され、リスト 8.14のコードは保護されます。そしてここが重要なのですが、攻撃者がたとえこの情報をcookiesから盗み出すことができたとしても、それを使って本物のユーザーとしてログインすることはできないのです。ただし今述べたことは、sessionメソッドで作成した「一時セッション」にしか該当しません。cookiesメソッドで作成した「永続的セッション」ではそこまで断言はできません。永続的なcookiesには、セッションハイジャックという攻撃を受ける可能性が常につきまといます。ユーザーのブラウザ上に保存される情報については、9でもう少し注意深く扱うことにします。

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

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

演習

  1. 有効なユーザーで実際にログインし、ブラウザからcookiesの情報を調べてみてください。このとき、sessionの値はどうなっているでしょうか? ヒント: ブラウザでcookiesを調べる方法が分からない? 今こそググってみるときです! (コラム 1.1)
  2. 先ほどの演習課題と同様に、Expiresの値について調べてみてください。

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メソッドが1リクエスト内で何度も呼び出されると、呼び出された回数と同じだけデータベースにも問い合わせされてしまう点です。そこでRubyの慣習に従って、User.find_byの実行結果をインスタンス変数に保存することにします。こうすることで、データベースの読み出しは最初の一回だけになり、以後の呼び出しではインスタンス変数を返すようになります。地味なようですが、Railsを高速化させるための重要なテクニックです5

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の文脈で使うと次のような簡潔なコードになります。

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

お試しあれ。

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

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

リスト 8.16: セッションに含まれる現在のユーザーを検索する 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.16current_userメソッドが動作するようになったので、ユーザーがログインしているかどうかに応じてアプリケーションの動作を変更するための準備が整いました。

演習

  1. Railsコンソールを使って、User.find_by(id: ...)で対応するユーザーが検索に引っかからなかったとき、nilを返すことを確認してみましょう。
  2. 先ほどと同様に、今度は:user_idキーを持つsessionハッシュを作成してみましょう。リスト 8.17に記したステップに従って、||=演算子がうまく動くことも確認してみましょう。
リスト 8.17: sessionのシミュレーション
>> session = {}
>> session[:user_id] = nil
>> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>
>> session[:user_id]= User.first.id
>> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>
>> @current_user ||= User.find_by(id: session[:user_id])
<ココに何が表示されるか?>

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

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

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

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

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

6.3.1で説明したように、has_secure_passwordでbcryptパスワードが作成されるので、同じ方法でfixture用のパスワードを作成します。Railsのsecure_passwordのソースコードを調べてみると、次の部分でパスワードが生成されていることが分かります。

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

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

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

少々込み入っていますが、コストパラメータをテスト中は最小にし、本番環境ではしっかりと計算する方法がわかれば十分です。なお「?」〜「:」という記法については、9.2で解説します。

このdigestメソッドは、今後様々な場面で活用します。例えば9.1.1でもdigestを再利用するので、このdigestメソッドはUserモデル (user.rb) に置いておきましょう。この計算はユーザーごとに行う必要はないので、fixtureファイルなどでわざわざユーザーオブジェクトにアクセスする必然性はありません (つまり、インスタンスメソッドで定義する必要はありません)。そこで、digestメソッドをUserクラス自身に配置して、クラスメソッドにすることにしましょう (クラスメソッドの作り方については4.4.1で簡単に説明しました)。作成したコードをリスト 8.21に示します。

リスト 8.21: fixture向けのdigestメソッドを追加する app/models/user.rb
class User < ApplicationRecord
  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.21digestメソッドができたので、有効なユーザーを表すfixtureを作成できるようになりました (リスト 8.22)11

リスト 8.22: ユーザーログインのテストで使うfixture test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

上のコードにあるように、fixtureではERbを利用できる点にご注目ください。

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

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

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

有効なユーザー用のfixtureを作成できたので、テストでは次のようにfixtureのデータを参照できるようになります。

user = users(:michael)

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

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

リスト 8.23: 有効な情報を使ってユーザーログインをテストする 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, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
  end
end

上のコードのうち、次の行は

assert_redirected_to @user

リダイレクト先が正しいかどうかをチェックしています。

follow_redirect!

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

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

count: 0というオプションをassert_selectに追加すると、渡したパターンに一致するリンクが0かどうかを確認するようになります。

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

リスト 8.24: green
$ rails test test/integration/users_login_test.rb

演習

  1. 試しにSessionヘルパーのlogged_in?メソッドから!を削除してみて、リスト 8.23redになることを確認してみましょう。
  2. 先ほど削除した部分 (!) を元に戻して、テストが greenに戻ることを確認してみましょう。

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

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

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

リスト 8.26: テスト中のログインステータスを論理値で返すメソッド 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.26のコードを使うと、ユーザー登録の終わったユーザーがログイン状態になっているかどうかを確認できます (リスト 8.27)。

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

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

リスト 8.28: green
$ rails test

演習

  1. リスト 8.25log_inの行をコメントアウトすると、テストスイートは red になるでしょうか? それとも green になるでしょうか? 確認してみましょう。
  2. 現在使っているテキストエディタの機能を使って、リスト 8.25をまとめてコメントアウトできないか調べてみましょう。また、コメントアウトの前後でテストスイートを実行し、コメントアウトすると red に、コメントアウトを元に戻すと green になることを確認してみましょう。ヒント: コメントアウト後にファイルを保存することを忘れないようにしましょう。また、テキストエディタのコメントアウト機能については Test Editor TutorialCommenting Out (英語) などを参照してみてください。

8.3 ログアウト

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

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

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

session.delete(:user_id)

上のコードで、現在のユーザーをnilに設定できます。今回はログイン済みでない場合、即座にルートURLにリダイレクトするようにしているので、このコードが特に問題になることはありません15。次に、log_inおよび関連メソッドのときと同様に、Sessionヘルパーモジュールに配置するlog_outメソッドとしてリスト 8.29のように定義してみましょう。

リスト 8.29: 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.30)。

リスト 8.30: セッションを破棄する (ユーザーのログアウト) 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.23のユーザーログインのテストに手順を若干追加します。ログイン後、deleteメソッドでDELETEリクエストをログアウト用パスに発行し (表 8.1参照)、ユーザーがログアウトしてルートURLにリダイレクトされたことを確認します。ログイン用リンクが再度表示されること、ログアウト用リンクとプロフィール用リンクが非表示になることも確認します。手順を追加したテストをリスト 8.31に示します。

リスト 8.31: ユーザーログアウトのテスト 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, params: { 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アクションの定義とテストも完成したので、ついにサンプルアプリケーションの基本となる「ユーザー登録・ログイン・ログアウト」の機能すべてが完成しました。またこの時点で、テストスイートは greenになるはずです。

リスト 8.32: green
$ rails test

演習

  1. ブラウザから [Log out] リンクをクリックし、どんな変化が起こるか確認してみましょう。また、リスト 8.31で定義した3つのステップを実行してみて、うまく動いているかどうか確認してみましょう。
  2. cookiesの内容を調べてみて、ログアウト後にはsessionが正常に削除されていることを確認してみましょう。

8.4 最後に

本章では、サンプルアプリケーションの基本的なログイン機構 (認証システム) を実装しました。次の章では、このログイン機構をさらに改善して、セッションより長くログイン情報を維持する方法について学んでいきます。

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

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

マージ後、リモートのリポジトリにpushします。

$ rails test
$ git push

最後に、いつものようにHerokuにデプロイしましょう。

$ git push heroku

8.4.1 本章のまとめ

  • Railsのsessionメソッドを使うと、あるページから別のページに移動するときの状態を保持できる。一時的な状態の保存にはcookiesも使える
  • ログインフォームでは、ユーザーがログインするための新しいセッションが作成できる
  • flash.nowメソッドを使うと、描画済みのページにもフラッシュメッセージを表示できる
  • テスト駆動開発は、回帰バグを防ぐときに便利
  • sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できる
  • ログインの状態に応じて、ページ内で表示するリンクを切り替えることができる
  • 統合テストでは、ルーティング、データベースの更新、レイアウトの変更が正しく行われているかを確認できる
  1. ブラウザによっては、「中断した時点から再開」などのオプション機能でセッションを復旧できるものもあります。このような動作はブラウザ依存で、かつブラウザ側でしか行えないので、Railsサーバーではこうしたセッション復旧機能を実現することはできません。 
  2. 注: form_forの代わりにform_tagを使うこともでき、Railsではこの方が慣用的な方法です。しかし、ユーザー登録フォームではform_forを使うのが一般的であり、並列構造を強調するためにもform_forを使いました。 
  3. 筆者はこのテクニックが好きです。というのも、このテクニックはRails流と言うより、Ruby単体のモジュールを追加する方法と基本的に同じだからです。なお、Rails 4で導入されたconcernsという機能でも、似たようなテクニックが使われています。concernsについて詳しく学びたい場合は「how to use concerns in Rails」で検索してみてください。 
  4. リスト 8.13でモジュールを読み込んでいるので、Sessionコントローラでlog_inメソッドが使えます。 
  5. このように、メソッド呼び出しでの変数代入を記憶して次回以降の呼び出しで使い回す手法をメモ化 (memoization) と呼びます。memorizationのスペルミスではなく、それをもじったmemoization (rがない) という造語であることにご注意ください。 
  6. 画像の引用元: https://www.flickr.com/photos/elevy/14730820387 2016-06-03. Copyright © 2014 by Elias Levy (改変不可の Creative Commons Attribution 2.0 Generic ライセンス) 
  7. Webブラウザは実際にはDELETEリクエストを発行できないので、RailsではJavaScriptを使ってこのリクエストを「偽造」しています。 
  8. 詳しくはBootstrapコンポーネント一覧ページ (英語) を参照してください。 
  9. 環境によっては、この時点で一度Webサーバーを再起動する必要があります (コラム 1.1)。 
  10. クラウドIDEをご利用の場合は、別のブラウザでログイン動作を確認することをおすすめします。そうでないと、クラウドIDEを開いているブラウザも一緒に閉じるはめになってしまいます。 
  11. fixtureでは空白スペースの数は重要です。タブを使わずに、リスト 8.22と同じ空白スペースになるように注意してください。 
  12. Sessionsコントローラがあることで、Usersコントローラでlog_inメソッドを使えるようになります。そのために必要なモジュールはリスト 8.13で対応しています。 
  13. 一例として、かつて筆者が作成したテストスイートでは、Sessionsヘルパーからlog_inメソッドをうっかり削除してしまったにもかかわらず、テストが greenのまま変わらなかったことがありました。原因は、テストで使っていたヘルパーメソッドの名前が、うかつにもSessionsヘルパーメソッド名と同じだったことです。そのため、アプリケーションが壊れていてもテストがパスしてしまいました。テストヘルパーメソッド名を、Sessionヘルパーメソッド名log_in_as (リスト 9.24) とは異なるis_logged_in?で定義することで、この問題を回避できます。 
  14. ブラウザによっては、「ログイン状態を保存する」などでセッションを自動復元する機能がサポートされていることがあります。この機能は開発の邪魔になるので、ログアウトする前にこの機能を必ずオフにしておいてください。 
  15. インスタンス変数@current_usernilにする必要があるのは、@current_userdestroyアクションより前に作成され (作成されていない場合)、かつ、リダイレクトを直接発行しなかった場合だけです。今回はリダイレクトを直接発行しているので、不要です。現実にこのような条件が発生する可能性はかなり低く、このアプリケーションでもこのような条件を作り出さないように開発しているので、本来はnilに設定する必要はないのですが、ここではセキュリティ上の死角を万が一にでも作り出さないためにあえてnilに設定しています。 
第8章 基本的なログイン機構 Rails 5.0 (第4版)