Ruby on Rails チュートリアル
-
第3版 目次
- 第1章 ゼロからデプロイまで
- 第2章 Toyアプリケーション
- 第3章 ほぼ静的なページの作成
- 第4章 Rails風味のRuby
- 第5章 レイアウトを作成する
- 第6章 ユーザーのモデルを作成する
- 第7章 ユーザー登録
- 第8章 ログイン、ログアウト
- 第9章 ユーザーの更新・表示・削除
- 第10章 アカウント有効化とパスワード再設定
- 第11章 ユーザーのマイクロポスト
- 第12章 ユーザーをフォローする
|
||
第3版 目次
|
||
最新版を読む |
Ruby on Rails チュートリアル
プロダクト開発の0→1を学ぼう
下記フォームからメールアドレスを入力していただくと、招待リンクが記載されたメールが届きます。リンクをクリックし、アカウントを有効化した時点から『30分間』解説動画のお試し視聴ができます。
メール内のリンクから視聴を開始できます。
第3版 目次
- 第1章 ゼロからデプロイまで
- 第2章 Toyアプリケーション
- 第3章 ほぼ静的なページの作成
- 第4章 Rails風味のRuby
- 第5章 レイアウトを作成する
- 第6章 ユーザーのモデルを作成する
- 第7章 ユーザー登録
- 第8章 ログイン、ログアウト
- 第9章 ユーザーの更新・表示・削除
- 第10章 アカウント有効化とパスワード再設定
- 第11章 ユーザーのマイクロポスト
- 第12章 ユーザーをフォローする
第8章 ログイン、ログアウト
第7章でWebサイトでの新規ユーザー登録が行えるようになりましたので、今度はユーザーがログインやログアウトを行えるようにしましょう。ここでは、Webのログインやログアウトで一般的に実装される、以下の3種類の動作をすべて実装することにします。1: ブラウザを閉じるとログインを破棄する (8.1と8.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
アクションを生成すると、それに対応するビューも生成されます。create
やdestroy
には対応するビューがない (=不要) なので、無駄なビューを作成しないためにここではnewだけを指定しています。7.2のユーザー登録ページのときと同様に、図8.1モックアップを元にセッション新規開始用のログインフォームを作成します。
Usersリソースのときは専用のresources
メソッドを使用してRESTfulなルーティングを自動的にフルセットで利用できるようにしました (リスト7.3) が、Sessionリソースではフルセットはいらないので、「名前付きルーティング」だけを使用します。この名前付きルーティングでは、GETリクエストやPOSTリクエストをlogin
ルーティングで、DELETEリクエストをlogout
ルーティングで扱います。このルーティングを反映したものをリスト8.1に示します。なお、rails generate controller
で生成された不要なルートは、このリストから削除してあります。
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 |
セッションの削除 (ログアウト) |
これまでに名前付きルーティングをだいぶ追加してきたので、ここでアプリケーションの全ルーティングを表示できると便利です。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.1と図7.11を比較してみると、ログインフォームとユーザー登録フォームにはほとんど違いがないことがわかります。違いは、4つあったフィールドが [Email] と [Password] の2つに減っていることだけです。
ログインフォームで入力した情報に誤りがあったときは、ログインページをもう一度表示してエラーメッセージを出力します (図8.2)。7.3.3では、エラーメッセージの表示にエラーメッセージ用パーシャル (部分テンプレート) を使用しましたが、そのエラーメッセージはActive Recordによって自動的に表示されていたことを思い出しましょう。セッションはActive Recordオブジェクトではないので、上のようにActive Recordがよしなにエラーメッセージを表示してくれるということは期待できません。そこで、ここではフラッシュメッセージでエラーを表示します。
リスト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)。
<% 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で動くようにします)。
生成されたHTMLフォームをリスト8.3に示します。
<form accept-charset="UTF-8" action="/login" method="post">
<input name="utf8" type="hidden" value="✓" />
<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.4のcreate
アクションの中では何も行われませんが、アクションを実行するとnew
ビューが出力されるのでこれで十分です。/sessions/newフォームを送信すると図8.4のようになります。
create
アクション (暫定版) app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
render 'new'
end
def destroy
end
end
図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に示します。
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ではnil
とfalse
以外のすべてのオブジェクトは、真偽値ではtrue
になる (4.2.3) という性質を考慮すると、&&の前後の値の組み合わせは表8.2のようになります。表8.2を見ると、入力されたメールアドレスを持つユーザーがデータベースに存在し、かつ入力されたパスワードがそのユーザーのパスワードである場合のみ、if
文がtrue
になることがわかります。言葉でまとめると「ユーザーがデータベースにあり、かつ、認証に成功した場合にのみ」となります。
ユーザー | パスワード | a && b |
存在しない | 何でもよい | (nil && [anything]) == false |
有効なユーザー | 誤ったパスワード | (true && false) == false |
有効なユーザー | 正しいパスワード | (true && true) == true |
8.1.4 フラッシュメッセージを表示する
7.3.2では、ユーザー登録のエラーメッセージ表示にUserモデルのエラーメッセージをうまく利用したことを思い出しましょう。ユーザー登録の場合、エラーメッセージは特定のActive Recordオブジェクトに関連付けられていたのでその手が使えました。しかしセッションではActive Recordのモデルを使用していないため、その手が通用しません。そこで、ログインに失敗したときには代わりにフラッシュメッセージを表示することにします。最初のコードをリスト8.6に示します (このコードはわざと少し間違えてあります)。
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)。
本文およびリスト8.6のコメントで述べたように、このコードには誤りがあります。ページにはちゃんとエラーメッセージが表示されていますが、どこが問題なのでしょうか。実は上のコードのままでは、リクエストのフラッシュメッセージが一度表示されると消えずに残ってしまいます。リスト7.24でリダイレクトを使用したときとは異なり、表示したテンプレートをrender
メソッドで強制的に再レンダリングしてもリクエストと見なされないため、リクエストのメッセージが消えません。たとえば、わざと無効な情報を入力して送信してエラーメッセージを表示してから、Homeページをクリックして移動すると、そこでもフラッシュメッセージが表示されたままになっています (図8.6)。この問題は8.1.5で修正します。
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の手順をテストコードで再現する必要があります。基本的な流れを以下に示します。
- ログイン用のパスを開く
- 新しいセッションのフォームが正しく表示されたことを確認する
- わざと無効な
params
ハッシュを使用してセッション用パスにPOSTする - 新しいセッションのフォームが再度表示され、フラッシュメッセージが追加されることを確認する
- 別のページ (Homeページなど) にいったん移動する
- 移動先のページでフラッシュメッセージが表示されていないことを確認する
上のテスト手順の実装をリスト8.7に示します。
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になるはずです。
$ bundle exec rake test TEST=test/integration/users_login_test.rb
上のように、引数でTEST
にテストファイルのフルパスを与えると、そのテストファイルだけを実行できます。
リスト8.7の失敗するテストをパスさせるには、本編のコードでflash
をflash.now
に置き換えます。後者は、レンダリングが終わっているページで特別にフラッシュメッセージを表示することができます。flash
のメッセージとは異なり、flash.now
のメッセージはその後リクエストが発生したときに消滅します (リスト8.7ではまさにその手順を再現しています)。置き換えの終わった正しいアプリケーションコードをリスト8.9に示します。
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になることを確認できます。
$ 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)。
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)
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.12でlog_in
というヘルパーメソッドを定義できたので、やっと、ユーザーログインを行ってセッションのcreate
アクションを完了し、ユーザーのプロフィールページにリダイレクトする準備ができました。作成したコードをリスト8.13に示します4。
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.13でcreate
アクションを定義できたので、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)。
この「||=」(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では、nilとfalseを除いて、あらゆるオブジェクトの論理値が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の内部では実際にすべての項が評価されます。これは、@fooがnilやfalseの場合に無駄な代入を避ける必要があるためです。しかしこの式の動作では||=記法の動作と同じにならず、説明上不都合なので、上の解説では@foo = @foo || "bar" (||が右辺にある点に注意) という式を用いて説明しました。
前述の簡潔な記法をcurrent_user
メソッドに適用した結果をリスト8.14に示します。
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.14のcurrent_user
メソッドが動作するようになったので、ユーザーがログインしているかどうかに応じてアプリケーションの動作を変更するための準備が整いました。
8.2.3 レイアウトリンクを変更する
ログイン機能の最初の具体的な応用として、ユーザーがログインしているときとそうでないときでレイアウトを変更してみましょう。特に、図8.7のモックアップ6に示したように、「ログアウト」リンク、「ユーザー設定」リンク、「ユーザーリストを表示」リンク、「現在のユーザープロフィールを表示」リンクも追加します。図8.7では、ログアウトのリンクとプロフィールのリンクは [Account] メニューの項目として表示されている点にご注目ください。リスト8.16では、Bootstrapを使用してこのようなメニューを実現する方法を示します。
筆者なら即、この時点で上のメニューを記述する統合テストを書くでしょう。コラム3.3でも解説したように、Railsのテストツールに習熟するにつれ、何も指示されなくても、筆者のようにこの時点でテストを書きたくなると思います。とはいうものの今は無理は禁物です。このテストではまたいくつか新しいアイディアを投入する必要もあるので、テスト作成は8.2.4に回すことにします。
サイトレイアウトのリンクを変更する方法のひとつとして考えられるのは、ERBコードの中でif-elseを使用し、条件に応じてリンクを表示し分けることです。
<% if logged_in? %>
# ログインユーザー用のリンク
<% else %>
# ログインしていないユーザー用のリンク
<% end %>
このコードを書くためには、論理値を返すlogged_in?
メソッドが必要なので、まずそれを定義します。
ユーザーがログイン中の状態とは、セッションにユーザーが存在する、つまりcurrent_user
がnil
でないということです。これをチェックするには否定演算子 (4.2.3)が必要なので、!
(参考: 英語ではbangと読みます) を使用します。作成したlogged_in?
メソッドをリスト8.15に示します。
logged_in?
ヘルパーメソッド 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
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
end
リスト8.15を追加したので、ユーザーのログイン時にレイアウトを変えられるようにする準備ができました。なお、新しく作るリンクは4つですが、そのうち以下の2つのリンクは当面の間未実装のままとします (第9章で完成の予定)。
<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>
ログアウト用リンクでは、リスト8.1で定義したログアウト用パスを使用します。
<%= link_to "Log out", logout_path, method: :delete %>
上のコードでは、ログアウト用リンクの引数としてハッシュを渡している点にご注目ください。このハッシュでは、HTTPのDELETEリクエスト7を使用するよう指示しています。プロフィール用リンクについても同様に以下のようにします。
<%= link_to "Profile", current_user %>
なお、上のコードは以下のように書くこともできます。
<%= link_to "Profile", user_path(current_user) %>
しかしこの状況ではcurrent_user
を使う方が、Railsによってuser_path(current_user)
され、ユーザープロフィールへのリンクが自動的に実現できるのでずっと便利です。そしてユーザーがログインしていない場合は、リスト8.1のログイン用パスを使用して、以下のようにログインフォームへのリンクを作成します。
<%= link_to "Log in", login_path %>
以上をすべてヘッダーのパーシャル部分に適用して更新したものをリスト8.16に示します。
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<ul class="nav navbar-nav navbar-right">
<li><%= link_to "Home", root_path %></li>
<li><%= link_to "Help", help_path %></li>
<% if logged_in? %>
<li><%= link_to "Users", '#' %></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
Account <b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li><%= link_to "Profile", current_user %></li>
<li><%= link_to "Settings", '#' %></li>
<li class="divider"></li>
<li>
<%= link_to "Log out", logout_path, method: "delete" %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Log in", login_path %></li>
<% end %>
</ul>
</nav>
</div>
</header>
レイアウトに新しいリンクを追加したので、リスト8.16にBootstrapのドロップダウンメニュー機能8を適用できる状態になりました。具体的には、BootstrapのCSSのdropdown
クラスやdropdown-menu
などをインクルードします。ドロップダウンを実際に有効にするには、Railsのアセットパイプライン用のapplication.js
ファイルでBootstrapのカスタムJavaScriptライブラリをインクルードします (リスト8.17)。
application.js
にBootstrapのJavaScriptライブラリを追加する app/assets/javascripts/application.js
//= require jquery
//= require jquery_ujs
//= require bootstrap
//= require turbolinks
//= require_tree .
この時点で、ログインパスにアクセスして有効なユーザーとしてログインできるようになっているので、これまでの3つの節9のコードを効率よくテストできるようになります。リスト8.16やリスト8.17のコードにより、図8.8のようにドロップダウンメニューとログイン中ユーザー用のリンクが表示されます。ブラウザを完全に終了すると、期待どおりアプリケーションのログインステータスが消去され、再びログインを要求されるようになったことを確認できます。
8.2.4 レイアウトの変更をテストする
アプリケーションでのログイン成功を手動で確認したので、先に進む前に統合テストを書いてこの動作をテストで表現し、今後の回帰バグの発生をキャッチできるようにしましょう。リスト8.7を元にテストを作成し、以下の操作手順をテストで記述して確認できるようにします。
- ログイン用のパスを開く
- セッション用パスに有効な情報をpostする
- ログイン用リンクが表示されなくなったことを確認する
- ログアウト用リンクが表示されていることを確認する
- プロフィール用リンクが表示されていることを確認する
上の変更を確認するためには、テスト時に登録済みユーザーとしてログインしておく必要があります。当然ながら、データベースにそのためのユーザーが登録されていなければなりません。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に示します。
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.18のdigest
メソッドができたので、有効なユーザーを表すユーザーフィクスチャを作成できるようになりました (リスト8.19)。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
フィクスチャではERBを利用できる点にご注目ください。
<%= User.digest('password') %>
上のERBコードでテストユーザー用の有効なパスワードを作成できます。
has_secure_password
で必要となるpassword_digest
属性はこれで準備できましたが、ハッシュ化されていない生のパスワードも参照できると便利です。しかし残念なことに、フィクスチャではこのようなことはできません。さらに、リスト8.19にpassword
属性を追加すると、そのようなカラムはデータベースに存在しないというエラーが発生します。実際、データベースにはそんなカラムはありません。この状況を切り抜けるために、テスト用のフィクスチャユーザーでは全員同じパスワード「password
」を使用することにします。これはフィクスチャでよく使われる手法です。
有効なユーザーのフィクスチャを作成できたので、テストで以下のようにフィクスチャデータを参照できます。
user = users(:michael)
上のusers
はフィクスチャのファイル名users.yml
を表し、:michael
というシンボルはリスト8.19のユーザーを参照するためのキーを表します。
フィクスチャのユーザーにアクセスできるようになったので、レイアウトのリンクをテストできる状態になりました。レイアウトのリンクをテストするには、前述の操作手順をテストコードに書き換えます (リスト8.20)。
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になるはずです。
$ 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。
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。
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログインしていればtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
end
リスト8.23のコードを使用すると、ユーザー登録の終わったユーザーがログイン状態になっているかどうかを確認できます (リスト8.24)。
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になるはずです。
$ 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.12のlog_in
メソッドの実行結果を取り消します。つまり、セッションからユーザーIDを削除します12。そのためには、以下のようにdelete
メソッドを実行します。
session.delete(:user_id)
現在のユーザーもnil
に設定します。今回は上のコードにより、即座にルートURLにリダイレクトされるので、それほど重要ではありません13。log_in
および関連メソッドのときと同様に、Sessionヘルパーモジュールに配置するlog_out
メソッドをリスト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)。
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に示します。
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になるはずです。
$ bundle exec rake test
8.4 [このアカウント設定を保存する]
8.2で完了したログインシステムは、それ自体で十分完結した機能です。しかし多くのWebサイトでは、ブラウザを閉じた後にもセッションを継続する機能などを追加しているのが普通です。本節では、ユーザーログインをデフォルトで保持するように変更し、ユーザーが明示的にログアウトするまではセッションを期限切れにしないようにします。この後8.4.5では、別の方法として、ログインを保存する「remember-me」チェックボックスを追加して、ログインを継続するかどうかをユーザーが選択できるようにする予定です。どちらの方式も商用に利用できる品質を備えています。前者はGitHubやBitbucketで、後者はFacebookやTwitterでそれぞれ採用されています。
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番目のログイン中のコンピュータへの物理アクセスによる攻撃については、さすがにシステム側での根本的な防衛手段を講じることは不可能なのですが、ユーザーがログアウトしたときにトークンを必ず変更し、機密上重要になる可能性のある情報をブラウザに表示するときには暗号による署名を行うようにすることで、物理アクセスによる攻撃を最小限に留めるようにします。
上で説明した設計やセキュリティ上の考慮事項を元に、以下の方針で永続的セッションを作成することにします。
- 記憶トークンにはランダムな文字列を生成して用いる。
- ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
- トークンはハッシュ値に変換してからデータベースに保存する。
- ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
- 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
上の最後の手順が、ユーザーログインのときの手順と似ていることにご注目ください。ユーザーログインでは、メールアドレスをキーにしてユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを (authenticate
メソッドで) 確認します (リスト8.5)。つまり、ここでの実装はhas_secure_password
と似た側面を持ちます。
それでは最初に、必要となるremember_digest
属性をUserモデルに追加します (図8.9)。
図8.9のデータモデルをアプリケーションに追加するために、以下のマイグレーションを生成します。
$ rails generate migration add_remember_digest_to_users remember_digest:string
(6.3.1のパスワードダイジェストのときのマイグレーションと比較してみましょう)。前回のマイグレーションと同様、今回のマイグレーション名も_to_users
で終わっています。これは、マイグレーションの対象がデータベースのusers
テーブルであることをRailsに指示するためのものです。今回は種類=string
のremember_digest
属性を追加しているので、いつものようにRailsによってデフォルトのマイグレーションが作成されます (リスト8.30)。
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に示します。
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に示します。
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
に設定します。
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章ではこのメソッドを一般化してみます。
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.32のattr_accessor :remember_token
で定義されているアクセサと同じではない点にご注意ください。引数は、メソッド内ローカルな変数になっていますが、記憶トークンを参照しているので問題ありません。メソッドの引数と同じ名前を使用することはよくあります。もうひとつ、remember_digest
の属性の使用法にご注目ください。この使用法はself.remember_digest
と同じであり、第6章のname
やemail
の使用法とも似ています。remember_digestの属性は、データベースのカラムに対応してActive Recordによって自動的に作成されます (リスト8.30)。
これで、ログインユーザーの記憶処理を作る準備が整いました。remember
ヘルパーメソッドを追加して、log_in
と連携させます (リスト8.34)。
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に示します。
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) を使用している点にご注目ください。上のコードは動作しますが、今のままではsession
もcookies
もそれぞれ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のようになります。
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にならなければなりません。
$ bundle exec rake test
8.4.3 ユーザーを忘れる
ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義します。このuser.forget
メソッドによって、user.remember
が取り消されます。具体的には、記憶ダイジェストをnil
で更新します (リスト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_id
とremember_token
cookiesを削除していることがわかります。
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])
このときにuser
がnil
であれば、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)。
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になります。
$ bundle exec rake test
リスト8.42のアプリケーションコードでは、logged_in?
がtrueの場合に限ってlog_out
を呼び出すように変更しました。
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)。この中で、記憶トークンを空欄のままにしていることにご注目ください。記憶トークンが使用される前にエラーが発生するので、記憶トークンの値は何でも構わないのです。
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になります。
$ bundle exec rake test
エラーを修正してテストがGREENになるようにするには、記憶ダイジェストがnil
の場合にfalse
を返すようにすればよいのです (リスト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になり、サブタイトルは両方とも修正されるはずです。
$ bundle exec rake test
8.4.5 “Remember me” チェックボックス
8.4.3のコードで、アプリケーションにプロ仕様の完全な認証システムが導入されました。本章の最後に、[remember me] チェックボックスでログインを保持する方法を解説します。チェックボックスを追加したモックアップを図8.11に示します。
今回の実装は、リスト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に示します。
<% 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クラスcheckbox
とinline
をインクルードしています。Bootstrapではこれらをチェックボックスとテキスト「Remember me on this computer”」として同じ行に配置します。スタイルを整えるため、もう少しCSSルールを追加します (リスト8.48)。表示されるログインフォームを図8.12に示します。
.
.
.
/* forms */
.
.
.
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにします。信じられないかもしれませんが、必要な準備はすべて終わっているので、実装はわずか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のコードも理解できるようになったことでしょう。
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の実装によって、ログインシステムの実装がついに完了しました。ブラウザでこのチェックボックスを実際にオンにしたりオフにしたりして、動作を確認してみましょう。
「この世には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)。注: このコメント部分に書くコードはそれなりに込み入っています。可能であれば、一行ずつ読んで完全に理解しておくことをおすすめします。(訳注: テストのコードでは、操作を実現するためにこのようなトリッキーなコードを使わざるを得ないときがあります。)
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.20でusers(:michael)
と書くと、リスト8.19のフィクスチャユーザーを参照していたことを思い出しましょう)。
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になるはずです。
$ bundle exec rake test
記憶ブランチをテストする
8.4.2では、それまでの節で実装した永続的セッションが動作するかどうかを手動で確認していました。しかし実は、current_user
内のある分岐部分については、これまでまったくテストが行われていないのです。筆者はこのことに気付いた場合に、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むという手法を好んで使います。そのコードブロックがテストから漏れていれば、テストはパスしてしまうはずです。コードブロックがテストから漏れていなければ、例外が発生してテストが中断するはずです。現在のコードでこれを行ってみた結果をリスト8.53に示します。
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になります。
$ 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
テスト手順はシンプルです。
- フィクスチャで
user
変数を定義する - 渡されたユーザーを
remember
メソッドで記憶する -
current_user
が、渡されたユーザーと同じであることを確認します。
上の手順ではremember
メソッドではsession[:user_id]
が設定されないので、問題の「記憶」ブランチをこれでテストできるようになります。修正の結果をリスト8.55に示します。
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になるはずです。
$ bundle exec rake test TEST=test/helpers/sessions_helper_test.rb
ここまでできれば、current_user
メソッドに仕込んだraise
を削除して元に戻す (リスト8.57) ことで、リスト8.55のテストがパスするはずです (リスト8.57からauthenticated?
の式を削除するとリスト8.55の2番目のテストが失敗することも確認できます。つまりこのテストが正しいものであるということです)。
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になるはずです。
$ 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) を参考にしてください。
-
リスト8.32では、明示的に
User
をプレフィックスとして、新しいトークンやダイジェストのクラスメソッドを定義しました。これらは問題なく動作します。これらは、実際にUser.new_token
やUser.digest
を使用して呼び出されるので、おそらく最も明確な定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が、おそらく2とおりあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、リスト8.59 (ややわかりにくい) や リスト8.60 (非常に混乱する) の実装が正しいことを確認してください。これが問題です (self
は、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト8.59やリスト8.60の文脈では、self
はUser
「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります)。 -
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] チェックボックスのテストを改良してください。
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
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
.
.
.
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
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
- その他に、一定時間が経過するとセッションを期限切れにするモデルもあります。このモデルは、インターネットバンキングや金融取引口座などの重要な情報を扱うWebサイトに向いています。↑
- ブラウザによっては、「中断した時点から再開」などのオプション機能でセッションを復旧できるものもあります。このような動作はブラウザ依存で、かつブラウザ側でしか行えないので、Railsサーバーではこうしたセッション復旧機能を実現することはできません。↑
- (注:
form_for
の代わりにform_tag
を使うこともでき、Railsではこの方が慣用的な方法です。しかし、ユーザー登録フォームではform_forを使用する方が一般的であり、並列構造を強調するためにもform_forを使用しました。↑ -
リスト8.11でモジュールをインクルードしているので、Sessionコントローラで
log_in
メソッドを使用できます。↑ - このように、メソッド呼び出しでの変数代入を記憶して次回以降の呼び出しで使い回す手法をメモ化 (memoization) と呼びます。memorizationのスペルミスではなく、それをもじったmemoization (rがない) という造語であることにご注意ください。↑
- 画像はhttp://www.flickr.com/photos/hermanusbackpackers/3343254977/から引用しました。↑
- Webブラウザは実際にはDELETEリクエストを発行できないので、RailsではJavaScriptを使用してこのリクエストを「偽造」します。↑
- 詳しくは、Bootstrapコンポーネント一覧ページ (英語) を参照してください。↑
- クラウドIDEをご利用の場合は、別のブラウザでログイン動作を確認することをおすすめします。そうでないと、クラウドIDEを開いているブラウザも一緒に閉じるはめになってしまいます。↑
- Sessionsコントローラがあることで、Usersコントローラで
log_in
メソッドを使用できるようになります。そのために必要なモジュールはリスト8.11でインクルードされています。↑ - 一例として、かつて筆者が作成したテストスイートでは、Sessionsヘルパーから
log_in
メソッドをうっかり削除してしまったにもかかわらず、テストがGREENのまま変わらなかったことがありました。原因は、テストで使用していたテストヘルパーメソッドの名前が、うかつにもSessionsヘルパーメソッド名と同じだったことです。そのため、アプリケーションが壊れていてもテストがパスしてしまいました。テストヘルパーメソッド名を、Sessionヘルパーメソッド名log_in_as
(リスト8.50) とは異なるis_logged_in?
で定義することで、この問題を回避できます。↑ - ブラウザによっては、「ログイン状態を保存する」などでセッションを自動復元する機能がサポートされていることがあります。この機能は開発の邪魔になるので、ログアウトする前にこの機能を必ずオフにしておいてください。↑
- インスタンス変数
@current_user
をnil
にする必要があるのは、@current_user
がdestroy
アクションより前に作成され (作成されていない場合)、かつ、リダイレクトを直接発行しなかった場合だけです。今回はリダイレクトを直接発行しているので、不要です。現実にこのような条件が発生する可能性はかなり低く、このアプリケーションでもこのような条件を作り出さないように開発しているので、本来はnilに設定する必要はないのですが、ここではセキュリティ上の死角を万が一にでも作り出さないためにあえてnilに設定しています。↑ - セッションハイジャックは、セキュリティ上の注意を呼びかけるためにこれを実演するFiresheepアプリケーションによって広く知られるようになりました。Firesheepを使用すると、公共Wi-Fiネットワーク経由で接続したときに多くの有名Webサイトの記憶トークンが丸見えになっていることがわかります。↑
- このメソッドは、RailsCastの「remember me」の記事を元に選びました。↑
- 実際、これでもOKなのです。bcryptのハッシュはソルト化されているので、2人のユーザーのパスワードが本当に一致するのかどうかはハッシュからは絶対わかりません。(訳注: 「ソルト」とは、暗号を強化するために加えられる任意の短い文字列です。念には念を入れて「塩ひとつまみ」を加えるというイメージであり、英語の「take it with a grain of salt」=半分疑ってかかるという言い回しが語源です)↑
- 記憶トークンが一意に保たれることで、攻撃者はユーザーIDと記憶トークンを両方とも奪い取ることに成功しない限りセッションをハイジャックできなくなります。↑
- 一般に、あるメソッドがオブジェクトのインスタンスを必要としていない場合は、クラスメソッドにするのが常道です。10.1.2では、実際にこの決定が重要になってきます。↑
- 6.3.1で解説したように、「暗号化されていないパスワード (unencrypted password)」という呼び方は正しくありません。ここで言うセキュアなパスワードとは、単にハッシュ化したという意味であり、本格的な暗号化は行われていないからです。↑
- 筆者はこのような場合、代入式全体をかっこで囲むようにしています。これが比較でないことを思い出せるようにするためです。↑
- システムでのcookiesの調べ方については、「<ブラウザ名> inspect cookies」でググってください。↑
- 読者のPaulo Célio Júniorからのご指摘でした。ありがとうございました。↑
- 読者のNiels de Ronからのご指摘でした。ありがとうございます。↑
- ユーザーがこのチェックボックスをオフすると、すべてのコンピュータ上のすべてのブラウザからログアウトしますので、注意が必要です。ブラウザごとにユーザーのログインセッションを記憶する設計に変更すれば、ユーザーにとってもう少し便利にはなりますが、その分セキュリティが低下するうえ、実装も面倒になります。やる気の余っている方は実装してみてもよいでしょう。↑
- 以前は
remember user
をかっこなしで書きましたが、三項演算子ではかっこを省略すると文法エラーになります。↑
Railsチュートリアルは YassLab 社によって運営されています。
コンテンツを継続的に提供するため、書籍・動画・質問対応サービスなどもご検討していただけると嬉しいです。
研修支援や教材連携にも対応しています。note マガジンや YouTube チャンネルも始めたので、よければぜひ遊びに来てください!