Ruby on Rails チュートリアル

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

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

第3版 目次

第10章 アカウント有効化とパスワード再設定

9では、基本的なUsersリソース (7.1の標準的なRESTアクションをすべて使用) と、自由度の高い認証 (authentication) および認可 (authorization) システムを作成しました。本章ではこのシステムの仕上げとして、互いに強く関連している2つの機能、すなわちアカウントの有効化 (アクティベーション: 新規ユーザーのメールアドレスが有効であることを確認する機能) と、パスワードの再設定 (パスワードを忘れてしまったユーザー向けの機能) を実装することにします。これらの機能ごとに新しいリソースを作成し、それぞれのコントローラ/ルーティング/データベース移行の例について見ていくことにします。その途中で、Railsの開発環境や本番環境からメールを送信する方法についても学習します。最後に、2つの機能を完全に連携させます。パスワードを再設定すると、パスワード再設定用のリンクがメールで送信され、その宛先メールアドレスが有効であることは最初のアカウント有効化で確認済みである、といった具合です1

10.1 アカウントの有効化

今の状態では、新しくアカウントを登録したユーザーはアカウントに対するフルアクセス権限を持っています (7) が、このままではいかにも大雑把です。そこで、アカウントを有効化する手順を実装して、ユーザーが登録に使用したメールアドレスを本当に所有、管理しているのかどうかを確認するようにしましょう。これを行うおおよその流れは、有効化トークンやダイジェストをユーザーと関連付け、ユーザーにメールを送信し、そのメールにはトークンを含むリンクを記載しておき、ユーザーがそのリンクをクリックすると有効化できるようになる、というものです。

アカウントを有効化する段取りは、ユーザーログイン (8.2)、特にユーザーの記憶 (8.4) と似ています。基本的な手順は次のようになります。

  1. ユーザーの初期状態は「有効化されていない」(unactivated) にしておく。
  2. ユーザー登録が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する。
  3. 有効化ダイジェストはデータベースに保存しておき、有効化トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく2
  4. ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた有効化ダイジェストと比較することでトークンを認証する。
  5. ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated) に変更する。

都合の良いことに、今回実装するアカウント有効化やパスワード再設定の仕組みと、以前に実装したパスワードや記憶トークンの仕組みにはよく似た点が多いので、多くのアイデアを使い回すことができます (User.digestメソッド、User.new_tokenメソッド、改造版のuser.authenticated?メソッドなど)。10.1に両者の似ている点を示します (10.2のパスワード再設定も含む)。10.1.310.1を元に、より一般性の高いauthenticated?メソッドを定義することにします。

検索キー 文字列 ダイジェスト 認証
email password password_digest authenticate(password)
id remember_token remember_digest authenticated?(:remember, token)
email activation_token activation_digest authenticated?(:activation, token)
email reset_token reset_digest authenticated?(:reset, token)
表10.1: ログイン/記憶トークン/アカウントの有効化/パスワードの再設定で似ている点

それではいつものように、Gitで新機能用のトピックブランチを作成しましょう。10.3で説明したとおり、アカウントの有効化とパスワードの再設定では、メールの設定部分に共通するところがありますので、その部分を両機能に適用してからGitのmasterにマージします。こうすることで共通のトピックブランチを使えるようになり、便利です。

$ git checkout master
$ git checkout -b account-activation-password-reset

10.1.1 AccountActivationsリソース

セッション機能 (8.1) を使用して、アカウントの有効化という作業を「リソース」としてモデル化することにします。アカウントの有効化リソースはActive Recordのモデルとはこの際関係ないので、両者を関連付けることはしません。その代わりに、この作業に必要なデータ (有効化トークンや有効化ステータスなど) をUserモデルに追加することにします。ただし、アカウント有効化におけるやりとりでは、標準のREST URLを使用するようにします。有効化用のリンクにアクセスするとユーザーの有効化ステータスが必然的に変わるので、標準的なeditアクションを使用することにします3。では始めましょう。AccountActivationsコントローラを以下のコマンドで生成します4

$ rails generate controller AccountActivations --no-test-framework

上のコマンドでは、テストを生成しないというオプションを指定していることにご注目ください。著者はコントローラのテストよりも統合テスト (10.1.4) の方が望ましいと考えているので、コントローラのテストを生成しないようにしているのです。

有効化メールでは以下の形式のURLを使用します。

edit_account_activation_url(activation_token, ...)

これは、editアクションへの名前付きルートが必要になるということです。そのためのresources行を追加します (リスト10.1)。

リスト10.1: アカウント有効化に使用するリソースを追加する config/routes.rb
Rails.application.routes.draw do
  root                'static_pages#home'
  get    'help'    => 'static_pages#help'
  get    'about'   => 'static_pages#about'
  get    'contact' => 'static_pages#contact'
  get    'signup'  => 'users#new'
  get    'login'   => 'sessions#new'
  post   'login'   => 'sessions#create'
  delete 'logout'  => 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
end

続いて、一意の有効化トークンがユーザー有効化に必要です。アカウント有効化のセキュリティは、パスワードや記憶トークンやパスワードの再設定 (10.2) に比べると注意点が少なくて済みます。後者への攻撃が成功すれば全権を奪われてしまう可能性がありますが、前者も有効化トークンをハッシュ化しておかなければアカウントに攻撃を受ける可能性があります5。そこで、8.4の記憶トークンの例に従って、一般公開してもよい「仮想トークン」と、データベース内にのみ保存する「ハッシュダイジェスト」を組み合わせるとにします。これにより、以下を使用して有効化トークンにアクセスし、

user.activation_token

以下のようなコードでユーザーを認証できるようになります。

user.authenticated?(:activation, token)

(これを行うにはリスト8.33authenticated?メソッドを改良する必要があります)。 続いて、activated属性を追加して論理値 (true/false) を取るようにします。これで、9.4.1で説明した自動生成の論理値メソッドと同じような感じで、ユーザーが有効であるかどうかをテストできるようになります。

if user.activated? ...

最後に、本チュートリアルで使うことはありませんが、ユーザーを有効にしたときの日時も念のために記録しておきます。変更後のデータモデルは10.1のようになります。

user_model_account_activation
図10.1: Userモデルにユーザー有効化用の属性を追加する

以下のマイグレーションをコマンドラインで実行して10.1のデータモデルを追加すると、3つの属性が新しく追加されます。

$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime

(上記の2行目にある '>' という文字は、改行を示すためにシェルが自動的に挿入する文字です。手動で入力しないよう、注意してください) admin 属性 (リスト9.50) の時と同様に、activated属性のデフォルトの論理値をfalseにします (リスト10.2)。

リスト 10.2: アカウント有効化用の属性とインデックスを追加するマイグレーション db/migrate/[timestamp]_add_activation_to_users.rb
class AddActivationToUsers < ActiveRecord::Migration
  def change
    add_column :users, :activation_digest, :string
    add_column :users, :activated, :boolean, default: false
    add_column :users, :activated_at, :datetime
  end
end

いつものようにマイグレーションを実行します。

$ bundle exec rake db:migrate

ユーザーが新しい登録を完了するためには必ずアカウントの有効化が必要になるのですから、有効化トークンや有効化ダイジェストはユーザーオブジェクトが作成される前に作成しておく必要があります。これによく似た状況を6.2.5でも説明しました。メールアドレスをデータベースに保存する前に、メールアドレスを全部小文字に変換する必要があったのでした。あのときは、before_saveコールバックにdowncaseメソッドをバインドしました (リスト6.31)。オブジェクトにbefore_saveコールバックを用意しておくと、オブジェクトが保存される直前、オブジェクトの作成時や更新時にそのコールバックが呼び出されます。しかし今回は、オブジェクトが作成されたときだけコールバックを呼び出したいのです。それ以外のときには呼び出したくないのです。そこでbefore_createコールバックが必要になります。このコールバックは以下のように定義できます。

before_create :create_activation_digest

上のコードはメソッド参照と呼ばれるもので、こうすることでRailsはcreate_activation_digestというメソッドを探し、ユーザーを作成する前に実行するようになります (リスト6.31では、before_saveに明示的にブロックを渡していましたが、メソッド参照の方が一般にお勧めできます)。 create_activation_digestメソッド自体はUserモデル内でしか使用しないので、外部に公開する必要はありません。7.3.2のときと同じようにprivateキーワードを指定して、このメソッドをRuby流に隠蔽します。

private

  def create_activation_digest
    # トークンとダイジェストを作成する.
  end

クラス内でprivateキーワードより下に記述したメソッドは自動的に非公開となります。このことはコンソールセッションですぐ確かめられます。

$ rails console
>> User.first.create_activation_digest
NoMethodError: private method `create_activation_digest' called for #<User>

今回before_createコールバックを使用する目的は、トークンとそれに対応するダイジェストを割り当てるためです。割り当ては以下のように行うことができます。

self.activation_token  = User.new_token
self.activation_digest = User.digest(activation_token)

このコードでは、記憶トークンで使用したトークンのメソッドやダイジェストのメソッドをストレートに使いまわしています。リスト8.32rememberメソッドと比べてみましょう。

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

主な違いは、後者のupdate_attributeの使い方にあります。この違いは、記憶トークンやダイジェストは既にデータベースにいるユーザーのために作成される (その分やりやすい) のに対し、before_createコールバックの方はユーザーが作成されるに呼び出される (その分面倒) ことが原因です。このコールバックがあることで、(リスト7.17でユーザー登録を行ったときに)User.newで新しいユーザーが定義されると、activation_token属性やactivation_digest属性を自動的に得られます。後者のactivation_digest属性は既にデータベースのカラムとの関連付けができあがっている (10.1) ので、ユーザーが保存されるときに一緒に自動保存されます。

上で説明したことをUserモデルに実装するとリスト10.3のようになります。有効化トークンは本質的に仮のものでなければならないので、このモデルのattr_accessorにもうひとつ追加しました。メールアドレスを小文字にするときにもメソッド参照が使用される機会があることにご注目ください。

リスト10.3: Userモデルにアカウント有効化のコードを追加する GREEN app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email
  before_create :create_activation_digest
  validates :name,  presence: true, length: { maximum: 50 }
  .
  .
  .
  private

    # メールアドレスをすべて小文字にする
    def downcase_email
      self.email = email.downcase
    end

    # 有効化トークンとダイジェストを作成および代入する
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end

先に進む前に、サンプルデータとフィクスチャも更新し、テスト時のサンプルとユーザーを事前に有効化しておきましょう (リスト10.4リスト10.5)。なお、Time.zone.nowはRailsの組み込みヘルパーであり、サーバーのタイムゾーンに応じたタイムスタンプを返します。

リスト10.4: サンプルユーザーを最初から有効にしておく db/seeds.rb
User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin:     true,
             activated: true,
             activated_at: Time.zone.now)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
              email: email,
              password:              password,
              password_confirmation: password,
              activated: true,
              activated_at: Time.zone.now)
end
リスト10.5: フィクスチャのユーザーを有効にしておく test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
  activated: true
  activated_at: <%= Time.zone.now %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

mallory:
  name: Mallory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>
<% end %>

いつものようにデータベースを初期化して、サンプルデータを再度生成し直し、リスト10.4の変更を反映します。

$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed

10.1.2 AccountActivationsメイラーメソッド

データのモデル化が終わったので、今度はアカウント有効化メールの送信に必要なコードを追加しましょう。このメソッドではAction Mailerライブラリを使用してUserのメイラーを追加します。このメイラーはUsersコントローラのcreateアクションで有効化リンクをメール送信するために使用します。メイラーの構成はコントローラのアクションとよく似ており、メールのテンプレートをビューと同じ要領で定義できます。この節ではメイラーとビューを定義して、有効化トークンとメールアドレス (=有効にするアカウントのアドレス) を含むリンクをその中で使用します。

メイラーは、モデルやコントローラと同様にrails generateで生成できます。

$ rails generate mailer UserMailer account_activation password_reset

上のコマンドを実行したことで、本節で必要となるaccount_activationメソッドと、次節 (10.2) で必要となるpassword_resetメソッドが生成されました。

生成したメイラーごとに、ビューテンプレートが2つずつ生成されます。1つはテキストメール用のテンプレート、1つはHTMLメール用テンプレートです。アカウント有効化メイラーメソッドのテンプレートをリスト10.6リスト10.7に示します。

リスト10.6: アカウント有効化メイラーのテキストビュー (自動生成) app/views/user_mailer/account_activation.text.erb
UserMailer#account_activation

<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
リスト10.7: アカウント有効化メイラーのHTMLビュー (自動生成) app/views/user_mailer/account_activation.html.erb
<h1>UserMailer#account_activation</h1>

<p>
  <%= @greeting %>, find me in app/views/user_mailer/account_activation.html.erb
</p>

生成されたメイラーの動作を簡単に追ってみましょう (リスト10.8リスト10.9)。リスト10.8には、デフォルトのfromアドレス (アプリケーション全体で共通) があります。リスト10.9の各メソッドには宛先メールアドレスもあります。リスト10.8ではメールのフォーマットに対応するメイラーレイアウトも使用されています。なお本チュートリアルの説明には直接関係ありませんが、生成されるHTMLメイラーのレイアウトやテキストメイラーのレイアウトはapp/views/layoutsで確認できます。生成されたコードにはインスタンス変数@greetingも含まれています。このインスタンス変数は、ちょうど普通のビューでコントローラのインスタンス変数を利用できるのと同じように、メイラービューで利用できます。

リスト10.8: 生成されたApplicationメイラー app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "from@example.com"
  layout 'mailer'
end
リスト10.9: 生成されたUserメイラー app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer

  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.user_mailer.account_activation.subject
  #
  def account_activation
    @greeting = "Hi"

    mail to: "to@example.org"
  end

  # Subject can be set in your I18n file at config/locales/en.yml
  # with the following lookup:
  #
  #   en.user_mailer.password_reset.subject
  #
  def password_reset
    @greeting = "Hi"

    mail to: "to@example.org"
  end
end

最初に、生成されたテンプレートをカスタマイズして、実際に有効化メールで使えるようにします (リスト10.10)。次に、ユーザーを含むインスタンス変数を作成してビューで使えるようにし、user.emailにメール送信します (リスト10.11)。リスト10.11では、mailsubjectキーも引数として渡しています。この値はメールの件名になります。

リスト10.10: 新しいデフォルトのfromアドレスを使用するアプリケーションメイラー app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "noreply@example.com"
  layout 'mailer'
end
リスト10.11: アカウント有効化リンクをメール送信する app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer

  def account_activation(user)
    @user = user
    mail to: user.email, subject: "Account activation"
  end

  def password_reset
    @greeting = "Hi"

    mail to: "to@example.org"
  end
end

テンプレートビューは、通常のビューと同様ERBで自由にカスタマイズできます。ここでは挨拶文にユーザー名を含め、カスタムの有効化リンクを追加します。この後、Railsサーバーでユーザーをメールアドレスで検索して有効化トークンを認証できるようにしたいので、リンクにはメールアドレスとトークンを両方含めておく必要があります。AccountActivationsリソースで有効化をモデル化したので、トークン自体はリスト10.1で定義した名前付きルートの引数で使用されます。

edit_account_activation_url(@user.activation_token, ...)

ここで思い出してみましょう。

edit_user_url(user)

上のメソッドは、以下の形式のURLを生成します。

http://www.example.com/users/1/edit

これに対応するアカウント有効化リンクのベースURLは以下のようになります。

http://www.example.com/account_activations/q5lt38hQDc_959PVoo6b7A/edit

上のURLの「q5lt38hQDc_959PVoo6b7A」という部分はnew_tokenメソッドで生成されたものです (リスト8.31)。URLで使用できるようにBase64でエンコードされています。これはちょうど/users/1/editの「1」のようなユーザーIDと同じ役割を果たします。このトークンは、特にActivationsコントローラのeditアクションではparamsハッシュでparams[:id]として参照できます。

クエリパラメータを使用して、このURLにメールアドレスもうまく組み込んでみましょう。クエリパラメータとは、URLの末尾で疑問符「?」に続けてキーと値のペアを記述したものです6

account_activations/q5lt38hQDc_959PVoo6b7A/edit?email=foo%40example.com

このとき、メールアドレスの「@」記号がURLでは「%40」となっている点に注目してください。これは「エスケープ」と呼ばれる手法で、ここではURLで通常使用できない文字を含めるために使用されます。Railsでクエリパラメータを設定するには、以下のように名前付きルートにハッシュを追加します。

edit_account_activation_url(@user.activation_token, email: @user.email)

このようにして名前付きルートでクエリパラメータを定義すると、Railsが特殊な文字を自動的にエスケープしてくれます。コントローラでparams[:email]からメールアドレスを取り出すときには、自動的にエスケープを解除してくれます。

ここまでできれば、リスト10.11で定義した@userインスタンス変数、editへの名前付きルート、ERBを組み合わせて、必要なリンクを作成できます (リスト10.12リスト10.13)。リスト10.13のHTMLテンプレートでは、正しいリンクを組立てるためにlink_toメソッドを使用していることにご注目ください。

リスト10.12: アカウント有効化のテキストビュー app/views/user_mailer/account_activation.text.erb
Hi <%= @user.name %>,

Welcome to the Sample App! Click on the link below to activate your account:

<%= edit_account_activation_url(@user.activation_token, email: @user.email) %>
リスト10.13: アカウント有効化のHTMLビュー app/views/user_mailer/account_activation.html.erb
<h1>Sample App</h1>

<p>Hi <%= @user.name %>,</p>

<p>
Welcome to the Sample App!Click on the link below to activate your account:
</p>

<%= link_to "Activate", edit_account_activation_url(@user.activation_token,
                                                    email: @user.email) %>

リスト10.12リスト10.13で定義したテンプレートの実際の表示を簡単に確認するために、メールプレビューという裏技を使ってみましょう。Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができます。メールを実際に送信しなくてもよいので大変便利です。これを利用するには、アプリケーションのdevelopment環境の設定に手を加える必要があります (リスト10.14)。

リスト10.14: development環境のメール設定 config/environments/development.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = 'example.com'
  config.action_mailer.default_url_options = { host: host }
  .
  .
  .
end

リスト10.14のホスト名「’example.com’」の部分は各自のdevelopment環境に合わせて変更してください。たとえば、著者のシステムでは以下のどちらでも動くようになっています (クラウドIDEとローカルサーバーで使い分けています)。

host = 'rails-tutorial-c9-mhartl.c9.io'     # クラウド IDE

または

host = 'localhost:3000'                     # ローカルサーバー

developmentサーバーを再起動してリスト10.14の設定を読み込んだら、次は10.1.2で自動生成したUserメイラーのプレビューファイルの更新が必要です (リスト10.15)。

リスト10.15: Userメイラープレビュー (自動生成) test/mailers/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/account_activation
  def account_activation
    UserMailer.account_activation
  end

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    UserMailer.password_reset
  end

end

リスト10.11で定義したaccount_activationの引数には有効な (=実在する) ユーザーオブジェクトを渡す必要があるため、リスト10.15はこのままでは動きません。これを回避するために、user変数をdevelopmentデータベースの最初のユーザーになるように定義して、それをUserMailer.account_activationに引数として渡します (リスト10.16)。リスト10.16ではuser.activation_tokenにも値を代入している点にご注目ください。リスト10.12リスト10.13のアカウント有効化テンプレートではアカウント有効化トークンが必要なので、代入は省略できません。なお、activation_tokenは仮の属性でしかないので (10.1.1)、データベースのユーザーはこの値を実際には持っていません。

リスト10.16: アカウント有効化のプレビューメソッド (動作可能) test/mailers/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/account_activation
  def account_activation
    user = User.first
    user.activation_token = User.new_token
    UserMailer.account_activation(user)
  end

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    UserMailer.password_reset
  end
end

リスト10.16のプレビューコードを実装すると、指定のURLでアカウント有効化メールをプレビューできるようになります (クラウドIDEをご利用の場合は、localhost:3000の部分を対応するベースURLに置き換えてください)。HTMLメールとテキストメールのプレビューを10.210.3に示します。

images/figures/account_activation_html_preview
図10.2: アカウント有効化メールのプレビュー (HTMLバージョン)
images/figures/account_activation_text_preview
図10.3: アカウント有効化メールのプレビュー (テキストバージョン)

最後に、このメールプレビューのテストも作成して、プレビューをダブルチェックできるようにします。便利なテスト例がRailsによって自動生成されているので (リスト10.17)、これを利用すればテストの作成は割と簡単です。

リスト10.17: Userメイラーのテスト (Railsによる自動生成) test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

  test "account_activation" do
    mail = UserMailer.account_activation
    assert_equal "Account activation", mail.subject
    assert_equal ["to@example.org"], mail.to
    assert_equal ["from@example.com"], mail.from
    assert_match "Hi", mail.body.encoded
  end

  test "password_reset" do
    mail = UserMailer.password_reset
    assert_equal "Password reset", mail.subject
    assert_equal ["to@example.org"], mail.to
    assert_equal ["from@example.com"], mail.from
    assert_match "Hi", mail.body.encoded
  end
end

リスト10.17のテストでは、assert_matchという非常に強力なメソッドが使用されています。これを使えば、正規表現で文字列をテストできます。

assert_match 'foo', 'foobar'      # true
assert_match 'baz', 'foobar'      # false
assert_match /\w+/, 'foobar'      # true
assert_match /\w+/, '$#!*+@'      # false

リスト10.18のテストでは、assert_matchメソッドを使用して名前、有効化トークン、エスケープ済みメールアドレスがメール本文に含まれているかどうかをテストします。最後にもうひとつ小技をお教えします。

CGI::escape(user.email)

このメソッドを使うと、ユーザーのメールのテストをエスケープできます7

リスト10.18: 現在のメールの実装をテストする RED test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

  test "account_activation" do
    user = users(:michael)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name,               mail.body.encoded
    assert_match user.activation_token,   mail.body.encoded
    assert_match CGI::escape(user.email), mail.body.encoded
  end
end

リスト10.18のテストコードでは、フィクスチャユーザーに有効化トークンを追加している点にご注目ください。追加しない場合は空白になります。

このテストがパスするには、テストファイル内のドメイン名を正しく設定する必要があります (リスト10.19)。

リスト10.19: テストのドメインホストを設定する config/environments/test.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'example.com' }
  .
  .
  .
end

上のコードを使用すると、テストはGREENになるはずです。

リスト10.20: GREEN
$ bundle exec rake test:mailers

あとはユーザー登録を行うcreateアクションに数行追加するだけで、メイラーをアプリケーションで実際に使うことができます (リスト10.21)。リスト10.21では、登録時のリダイレクトの挙動が変更されている点にご注意ください。変更前は、ユーザーのプロファイルページ (7.4) にリダイレクトしていましたが、アカウント有効化を実装するうえでは無意味な動作なので、リダイレクト先をルートURLに変更してあります。

リスト10.21: ユーザー登録にアカウント有効化を追加する RED app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      UserMailer.account_activation(@user).deliver_now
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end
  .
  .
  .
end

リスト10.21ではリダイレクト先をプロファイルページからルートURLに変更し、かつユーザーは以前のようにログインしないようになっています。したがって、アプリケーションの動作が仮に正しくても、現在のテストスイートはREDになります。そこで、失敗が発生するテストの行をひとまずコメントアウトしておきます (リスト10.22)。コメントアウトした部分は、10.1.4でアカウント有効化のテストをパスさせるときに元に戻します。

リスト10.22: 失敗するテストを一時的にコメントアウトする GREEN test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, user: { name:  "",
                               email: "user@invalid",
                               password:              "foo",
                               password_confirmation: "bar" }
    end
    assert_template 'users/new'
    assert_select 'div#error_explanation'
    assert_select 'div.field_with_errors'
  end

  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

この状態で実際に新規ユーザーとして登録してみると、リダイレクトされて10.4のようになり、リスト10.23のようなメールが生成されます。ただし、実際にメールが生成されるわけではないのでご注意ください。ここに引用したのはサーバーログに出力されたメールです (メールが見えるまで多少スクロールが必要でしょう)。production環境で実際にメール送信する方法については10.3で説明します。

リスト10.23: サーバーログに表示されたアカウント有効化メールの例
Sent mail to michael@michaelhartl.com (931.6ms)
Date: Wed, 03 Sep 2014 19:47:18 +0000
From: noreply@example.com
To: michael@michaelhartl.com
Message-ID: <540770474e16_61d3fd1914f4cd0300a0@mhartl-rails-tutorial-953753.mail>
Subject: Account activation
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_5407704656b50_61d3fd1914f4cd02996a";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


----==_mimepart_5407704656b50_61d3fd1914f4cd02996a
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

Hi Michael Hartl,

Welcome to the Sample App! Click on the link below to activate your account:

http://rails-tutorial-c9-mhartl.c9.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com
----==_mimepart_5407704656b50_61d3fd1914f4cd02996a
Content-Type: text/html;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

<h1>Sample App</h1>

<p>Hi Michael Hartl,</p>

<p>
Welcome to the Sample App! Click on the link below to activate your account:
</p>

<a href="http://rails-tutorial-c9-mhartl.c9.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com">Activate</a>
----==_mimepart_5407704656b50_61d3fd1914f4cd02996a--
images/figures/redirected_not_activated
図10.4: 登録後リダイレクトしたホームページにアカウント有効化確認のメッセージが表示される

10.1.3 アカウントを有効化する

リスト10.23のとおりにメールが生成できたら、今度はAccountActivationsコントローラのeditアクションを書いて、実際にユーザーを有効化できるようにする必要があります。ここで、有効化トークンとメールをそれぞれparams[:id]params[:email]で参照できる (10.1.2) ことを思い出してみましょう。パスワードのモデル (リスト8.5) と記憶トークン (リスト8.36) で学んだことを元に、次のようなコードでユーザーを検索して認証することにします。

user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])

(この後、上の式にひとつ論理値を追加します。何が追加されるか考えてみましょう)。

上のコードで使用しているauthenticated?メソッドは、アカウント有効化のダイジェストと、渡されたトークンが一致するかどうかをチェックします。ただし、このメソッドは記憶トークン (リスト8.33) 用なので今は正常に動作しません。

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

remember_digestはUserモデルの属性であり、モデル内では以下のように書き換えることができます。

self.remember_digest

上をどうにかして変数として扱いたいのです。そこで以下を呼び出すことにします。

self.activation_token

authenticated?に該当のパラメータを渡す代わりに、上のようにします。

この一見不思議な手法は「メタプログラミング」の最初の例になります。メタプログラミングを一言で言うと「プログラムでプログラムを作成する」ことです。メタプログラミングはRubyが有するきわめて強力な機能であり、Railsの一見魔法のような機能 (訳注: 「黒魔術」と呼ばれることもあります) の多くは、Rubyのメタプログラミングによって実現されています。ここで重要なのは、sendメソッドの強力きわまる機能です。このメソッドは、与えられたオブジェクトに「メッセージを送る」ことによって、メソッド呼び出しに自由に名前を付けることができます。たとえば、このコンソールセッションでネイティブのRubyオブジェクトにsendメソッドを実行して、配列の長さを得るとします。

$ rails console
>> a = [1, 2, 3]
>> a.length
=> 3
>> a.send(:length)
=> 3
>> a.send('length')
=> 3

上のコードで、シンボル:lengthや文字列’length’sendメソッドに渡していますが、これは与えられたオブジェクト (ここではa) のlengthメソッドを呼び出すことと完全に同等です。もうひとつ例をお見せします。データベースの最初のユーザーが持つactivation_digest属性にアクセスしてみます。

>> user = User.first
>> user.activation_digest
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send(:activation_digest)
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send('activation_digest')
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> attribute = :activation
>> user.send("#{attribute}_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"

最後の例では、シンボル:activationに等しいattribute変数を定義し、文字列の式展開 (interpolation) を使用して引数を正しく組み立ててから、sendに渡していることです。文字列’activation’でも同じことができますが、Rubyではシンボルを使う方が普通です。

"#{attribute}_digest"

シンボルと文字列どちらを使用した場合にも、上のコードは以下のようになります。

"activation_digest"

文字列は式展開されます (7.4.2でシンボルが式展開されて文字列になったことを思い出しましょう)。

sendメソッドの動作原理がわかったので、それに基いてauthenticated?メソッドを書き換えます。

def authenticated?(remember_token)
  digest = self.send('remember_digest')
  return false if digest.nil?
  BCrypt::Password.new(digest).is_password?(remember_token)
end

この配置されたテンプレートで、関数の引数にダイジェスト名を追加してこのメソッドを一般化し、続いて上のように文字列の式展開を使用します。

def authenticated?(attribute, token)
  digest = self.send("#{attribute}_digest")
  return false if digest.nil?
  BCrypt::Password.new(digest).is_password?(token)
end

(上では2番目の引数tokenの名前を変更して、メソッドが一般化されたことをあえて強調しています)。このコードはモデル内にあるのでselfは省略できます。最終的にRubyらしく書かれたコードは次のようになります。

def authenticated?(attribute, token)
  digest = send("#{attribute}_digest")
  return false if digest.nil?
  BCrypt::Password.new(digest).is_password?(token)
end

ここまでできれば、以下のように呼び出すことでauthenticated?の従来の振舞いを再現できます。

user.authenticated?(:remember, remember_token)

以上の説明を実際のUserモデルに適用してできた、一般化されたauthenticated?メソッドをリスト10.24に示します。

リスト10.24: 一般化したauthenticated?メソッド RED app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  # トークンがダイジェストと一致したらtrueを返す
  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?
    BCrypt::Password.new(digest).is_password?(token)
  end
  .
  .
  .
end

リスト10.24のキャプションに示されているとおり、テストスイートはREDになります。

リスト10.25: RED
$ bundle exec rake test

テストが失敗する理由は、current_userメソッド (リスト8.36) とnilダイジェストのテスト (リスト8.43) の両方で、authenticated?が古いままになっており、引数も2つではなくまだ1つのままです。これを解消するために両者を更新して、新しい一般的なメソッドを使用するようにします (リスト10.26リスト10.27)。

リスト10.26: current_user内の一般化したauthenticated?メソッド app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 現在ログイン中のユーザーを返す (いる場合)
  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?(:remember, cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end
リスト10.27: Userテスト内の一般化したauthenticated?メソッド GREEN 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?(:remember, '')
  end
end

今度はテストがGREENになるはずです。

リスト10.28: GREEN
$ bundle exec rake test

コードにこのようなリファクタリングを施すと非常にエラーが発生しやすくなるので (訳注: 黒魔術と呼ばれる理由でもあります)、しっかりしたテストスイートが不可欠です。8.4.28.4.6でよいテストを書くためにあえてトラブルを発生させてみたのはそうした理由からです。

authenticated?リスト10.24のようになったことで、やっとeditアクションを書く準備ができました。このアクションは、paramsハッシュで与えられたメールアドレスに対応するユーザーを認証します。ユーザーが有効であることを確認する中核部分は以下のようになります。

if user && !user.activated? && user.authenticated?(:activation, params[:id])

!user.activated?という記述にご注目ください。先ほど「ひとつ論理値を追加します」と言っていたのはこのことです。このコードは、既に有効になっているユーザーを誤って再度有効化しないために必要です。正当であろうとなかろうと、有効化が行われるとユーザーはログイン状態になります。もしこのコードがなければ、攻撃者がユーザーの有効化リンクを後から盗みだしてクリックするだけで、本当のユーザーとしてログインできてしまいます。そうした攻撃を防ぐためにこのコードは非常に重要です。

上の論理値に基いてユーザーを認証するには、ユーザーを認証してからactivated_atタイムスタンプを更新する必要があります。(update_attributesではなくupdate_attributeを実行していることに注目してください。update_attributesだとバリデーションが実行されてしまうため、今回のようにパスワードを入力していない状態で更新すると、バリデーションで失敗してしまいます。)

user.update_attribute(:activated,    true)
user.update_attribute(:activated_at, Time.zone.now)

上のコードをeditアクション (リスト10.29) で使用します。リスト10.29では有効化トークンが無効だった場合の処理も行われている点にご注目ください。トークンが無効になるようなことは実際にはめったにありませんが、もしそうなった場合はルートURLにリダイレクトされる仕組みです。

リスト10.29: アカウントを有効化するeditアクション app/controllers/account_activations_controller.rb
class AccountActivationsController < ApplicationController

  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.update_attribute(:activated,    true)
      user.update_attribute(:activated_at, Time.zone.now)
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end

リスト10.29のコードを使用すると、リスト10.23にあるURLを貼り付けてユーザーを有効化できます。著者のシステム上では、以下のURLをブラウザで開くと、

http://rails-tutorial-c9-mhartl.c9.io/account_activations/
fFb_F94mgQtmlSvRFGsITw/edit?email=michael%40michaelhartl.com

10.5のようになりました。

images/figures/activated_user
図10.5: 有効化が成功した場合に表示されるプロファイルページ

もちろん、この時点ではユーザーのログイン方法を変更していないので、ユーザーの有効化にはまだ何の意味もありません。ユーザーの有効化が役に立つためには、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する必要があります。リスト10.30に示したように、これを行うにはuser.activated?がtrueの場合にのみログインを許可し、そうでない場合はルートURLにリダイレクトしてwarningで警告を表示します (10.6)。

リスト10.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])
      if user.activated?
        log_in user
        params[:session][:remember_me] == '1' ? remember(user) : forget(user)
        redirect_back_or user
      else
        message  = "Account not activated. "
        message += "Check your email for the activation link."
        flash[:warning] = message
        redirect_to root_url
      end
    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
images/figures/not_activated_warning
図10.6: 有効になっていないユーザーに表示される警告メッセージ

これで、ユーザー有効化機能のおおまかな部分については実装できました (改良すべき点として、有効化されていないユーザーが表示されないようにする必要もあるのですが、これは10.5の課題に回すことにします)。次の10.1.4でテストをもう少し追加し、リファクタリングを少々施せば完了です。

10.1.4 有効化のテストとリファクタリング

この節では、アカウント有効化の統合テストを追加します。正しい情報でユーザー登録を行った場合のテスト (リスト7.26) は既にあるので、7.4.4で開発したテストに若干手を加えることにします。追加される行数はそこそこ多いのですが、基本的に素直なので心配はありません。リスト10.31をご覧ください。

リスト10.31: ユーザー登録のテストにアカウント有効化を追加する GREEN test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
  end

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, user: { name:  "",
                               email: "user@invalid",
                               password:              "foo",
                               password_confirmation: "bar" }
    end
    assert_template 'users/new'
    assert_select 'div#error_explanation'
    assert_select 'div.field_with_errors'
  end

  test "valid signup information with account activation" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, user: { name:  "Example User",
                               email: "user@example.com",
                               password:              "password",
                               password_confirmation: "password" }
    end
    assert_equal 1, ActionMailer::Base.deliveries.size
    user = assigns(:user)
    assert_not user.activated?
    # 有効化していない状態でログインしてみる
    log_in_as(user)
    assert_not is_logged_in?
    # 有効化トークンが不正な場合
    get edit_account_activation_path("invalid token")
    assert_not is_logged_in?
    # トークンは正しいがメールアドレスが無効な場合
    get edit_account_activation_path(user.activation_token, email: 'wrong')
    assert_not is_logged_in?
    # 有効化トークンが正しい場合
    get edit_account_activation_path(user.activation_token, email: user.email)
    assert user.reload.activated?
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
end

リスト10.31のコードは分量が多いように見えますが、本当に重要な部分は以下の1行です。

assert_equal 1, ActionMailer::Base.deliveries.size

上のコードは、配信されたメッセージがきっかり1つであるかどうかを確認します。配列deliveriesはグローバルなので、setupメソッドでこれを初期化しておかないと、並行して行われる他のテストでメールが配信されたときにエラーで中断してしまいます (10.2.5でも似た事例をご紹介します)。リスト10.31assignsメソッドは本チュートリアル初登場です。8の演習 (8.6) で説明したように、assignsメソッドを使用すると、対応するアクション内にあるインスタンス変数にアクセスできるようになります。たとえば、Usersコントローラのcreateアクションでは@userというインスタンス変数が定義されています (リスト10.21) ので、テストでassigns(:user)とするとこのインスタンス変数にアクセスできるようになります。最後に、リスト10.22でコメントアウトしておいた行をリスト10.31で元に戻していることにご注意ください。

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

リスト10.32: GREEN
$ bundle exec rake test

リスト10.31のテストができたので、ユーザー操作の一部をコントローラからモデルに移動するというささやかなリファクタリングを行う準備ができました。ここでは特に、activateメソッドを作成してユーザーの有効化属性を更新し、send_activation_emailメソッドを作成して有効化メールを送信します。この新しいメソッドをリスト10.33に示します。また、リファクタリングされたアプリケーションコードをリスト10.34リスト10.35に示します。

リスト10.33: Userモデルにユーザー有効化メソッドを追加する app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_attribute(:activated,    true)
    update_attribute(:activated_at, Time.zone.now)
  end

  # 有効化用のメールを送信する
  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  private
    .
    .
    .
end
リスト10.34: ユーザーモデルオブジェクトからメールを送信する app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      @user.send_activation_email
      flash[:info] = "Please check your email to activate your account."
      redirect_to root_url
    else
      render 'new'
    end
  end
  .
  .
  .
end
リスト10.35: ユーザーモデルオブジェクト経由でアカウントを有効化する app/controllers/account_activations_controller.rb
class AccountActivationsController < ApplicationController

  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.activate
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end

リスト10.33ではuser.という記法を使用していないことにご注目ください。Userモデルにはそのような変数はないので、これがあるとエラーになります。

-user.update_attribute(:activated,    true)
-user.update_attribute(:activated_at, Time.zone.now)
+update_attribute(:activated,    true)
+update_attribute(:activated_at, Time.zone.now)

(userselfに切り替えるという手もあるのですが、selfはモデル内では必須ではないと6.2.5で解説したことを思い出しましょう)。Userメイラー内の呼び出しでは、@userselfに変更されている点にもご注目ください。

-UserMailer.account_activation(@user).deliver_now
+UserMailer.account_activation(self).deliver_now

どんなに簡単なリファクタリングであっても、この手の変更はつい忘れてしまうものです。テストをきちんと書いておけば、この種の見落としを検出できます。以上でテストスイートはGREENになるはずです。

リスト10.36: GREEN
$ bundle exec rake test

ついにアカウントの有効化を実装できました。きりのよい所でGitにコミットしておきましょう。

$ git add -A
$ git commit -m "Add account activations"

10.2 パスワードの再設定

アカウント有効化の実装が完了し、ユーザーのメールアドレスが正しいことを確認できるようになったので、今度はユーザーがパスワードを忘れてしまった場合に対応できるようにしましょう。実際にやってみるとわかると思いますが、パスワード再設定の仕組みは、アカウント有効化と似ている部分が多く、10.1で学んだ手法の多くをここでも適用できます。とは言うものの、最初の部分はそれなりに異なります。アカウントの有効化と異なり、パスワードを再設定する場合はビューを1つ変更する必要があり、新しいフォームも2つ (メールレイアウト用と新しいパスワードの送信用) 必要です。

コードを実際に書く前に、パスワード再設定の想定手順をモックアップ (=スクリーンショット画像を改変して作った模型) で確かめましょう。まず、サンプルアプリケーションのログインフォームに「forgot password」リンクを追加します (10.7)。この「forgot password」リンクをクリックするとフォームが表示され、そこにメールアドレスを入力してメールを送信すると、そのメールにパスワード再設定用のリンクが記載されています (10.8)。この再設定用のリンクをクリックすると、ユーザーのパスワードを再設定してよいかどうかの確認を求めるフォームが表示されます (10.9)。

images/figures/login_forgot_password_mockup
図10.7: 「forgot password」リンクのモックアップ
images/figures/forgot_password_form_mockup
図10.8: 「forgot password」フォームのモックアップ
images/figures/reset_password_form_mockup
図10.9: パスワード再設定用フォームのモックアップ

アカウント有効化の際と似ていて、PasswordResetsリソースを作成して、再設定用のトークンとそれに対応するダイジェストを保存するのが今回の目的となります。全体の流れは以下のとおりです。

  1. ユーザーがパスワードの再設定をリクエストすると、ユーザーが送信したメールアドレスをキーにしてデータベースからユーザーを見つける。
  2. 該当のメールアドレスがデータベースにある場合は、再設定用トークンとそれに対応する再設定用ダイジェストを生成する。
  3. 再設定用ダイジェストはデータベースに保存しておき、再設定用トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。
  4. ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた再設定用ダイジェストと比較することでトークンを認証する。
  5. 認証に成功したら、パスワード変更用のフォームをユーザーに表示する。

10.2.1 PasswordResetsリソース

アカウント有効化 (10.1.1) の場合と同様、最初に新しいリソースで使用するコントローラを生成します。

$ rails generate controller PasswordResets new edit --no-test-framework

10.1.1のときと同様にテストを生成しないようにするフラグを追加し、代わりに10.1.4の統合テストを生成するようにします。

新しいパスワードを再設定するためのフォーム (10.8) と、Userモデル内のパスワードを変更するためのフォーム (10.9) が両方必要になるので、今回はnewcreateeditupdateのルーティングも必要になります。この変更は、ルーティングファイルのresources行で行います (リスト10.37)。

リスト10.37: パスワード再設定用リソースを追加する config/routes.rb
Rails.application.routes.draw do
  root                'static_pages#home'
  get    'help'    => 'static_pages#help'
  get    'about'   => 'static_pages#about'
  get    'contact' => 'static_pages#contact'
  get    'signup'  => 'users#new'
  get    'login'   => 'sessions#new'
  post   'login'   => 'sessions#create'
  delete 'logout'  => 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
end

リスト10.37のコードはRESTfulのルーティング (10.2) に従っています。特に、10.2の最初のルーティングでは「forgot password」フォームへのリンク作成に以下を使用しています。

new_password_reset_path

(リスト10.3810.10参照)

HTTPリクエスト URL アクション 名前付きルート
GET /password_resets/new new new_password_reset_path
POST /password_resets create password_resets_path
GET /password_resets/<トークン>/edit edit edit_password_reset_path(トークン)
PATCH /password_resets/<トークン> update password_reset_path(トークン)
表10.2: リスト10.37のPasswordResetsリソースで提供されるRESTfulルーティング
リスト10.38: パスワード再設定画面へのリンクを追加する 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 %>
      <%= link_to "(forgot password)", new_password_reset_path %>
      <%= 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>

パスワード再設定のデータモデルも、アカウント有効化の場合と似ています (10.1)。記憶トークン (8.4) とアカウント有効化トークン (10.1) で設定したパターンに従い、パスワードの再設定でも、メールに記載する仮想の再設定用トークンと、ユーザーの取得で使う再設定用ダイジェストをペアにすることにします。トークンをハッシュ化せずにデータベースに保存すると、トークンが攻撃者によってデータベースから読み出されたときにセキュリティ上の問題が生じます: 攻撃者がユーザーのメールアドレスにパスワード再設定のリクエストを送信し、このメールと盗んだトークンを使用して攻撃者がパスワード再設定リンクを開けば、アカウントを奪い取ることができてしまいます。従って、パスワードの再設定では必ずダイジェストを使用してください。セキュリティ上の注意点をもうひとつ。再設定用のリンクはなるべく短時間 (数時間以内) で期限切れになるようにしなければなりません。そのために、再設定メールの送信時刻も記録する必要があります。以上に基いてreset_digest属性とreset_sent_at属性を追加したユーザーデータベースは10.11のようになります。

user_model_password_reset
図10.11: パスワード再設定で使用する属性を追加したUserモデル

以下を実行して、マイグレーションに10.11の属性を追加します。

$ rails generate migration add_reset_to_users reset_digest:string \
> reset_sent_at:datetime

(上記の2行目にある '>' という文字は、改行を示すためにシェルが自動的に挿入する文字です。手動で入力しないよう、注意してください) 後はいつものようにマイグレーションを実行します。

$ bundle exec rake db:migrate

10.2.2 PasswordResetsコントローラとフォーム

新しいパスワード再設定の画面を作成するために、前節でActive Recordを使用しないリソースを新規作成したときの手法、つまり、新しいセッションを作成するためのログインフォーム (リスト8.2) をここでも使用することにします。参考までにリスト10.39を再掲したのでご覧ください。

リスト10.39: ログインフォームのコード (再掲) app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

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

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

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

新しいパスワード再設定フォームはリスト10.39と多くの共通点がありますが、重要な違いとして、form_forの呼び出しで使用するリソースとURLが異なっていることと、パスワード属性が省略されていることが挙げられます。変更を反映した結果をリスト10.4010.12に示します。

リスト10.40: 新しいパスワード再設定画面 app/views/password_resets/new.html.erb
<% provide(:title, "Forgot password") %>
<h1>Forgot password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:password_reset, url: password_resets_path) do |f| %>
      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.submit "Submit", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>
images/figures/forgot_password_form
図10.12: 「forgot password」フォーム

10.12のフォームから送信を行なった後、メールアドレスをキーとしてユーザーをデータベースから見つけ、パスワード再設定用トークンと送信時のタイムスタンプでデータベースの属性を更新する必要があります。それに続いてルートURLにリダイレクトし、フラッシュメッセージをユーザーに表示します。送信が無効の場合は、ログイン (リスト8.9) と同様にnewページを出力してflash.nowメッセージを表示します。変更の結果をリスト10.41に示します。

リスト10.41: パスワード再設定用のcreateアクション app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController

  def new
  end

  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @user.create_reset_digest
      @user.send_password_reset_email
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

  def edit
  end
end

Userモデル内のコードは、before_createコールバック (リスト10.3) 内で使用されるcreate_activation_digestメソッドと似ています(リスト10.42)。

リスト10.42: Userモデルにパスワード再設定用メソッドを追加する app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token, :activation_token, :reset_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_attribute(:activated,    true)
    update_attribute(:activated_at, Time.zone.now)
  end

  # 有効化用のメールを送信する
  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_attribute(:reset_digest,  User.digest(reset_token))
    update_attribute(:reset_sent_at, Time.zone.now)
  end

  # パスワード再設定のメールを送信する
  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  private

    # メールアドレスをすべて小文字にする
    def downcase_email
      self.email = email.downcase
    end

    # 有効化トークンとダイジェストを作成および代入する
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end

10.13に示すように、この時点でのアプリケーションは、無効なメールアドレスを入力した場合に正常に動作します。正しいメールアドレスを送信した場合にもアプリケーションが正常に動作するためには、パスワード再設定のメイラーメソッドを定義する必要があります。

images/figures/invalid_email_password_reset
図10.13: 「forgot password」フォームに無効なメールアドレスを入力した場合

10.2.3 PasswordResetsメイラーメソッド

リスト10.42でパスワード再設定のメールを送信するコードは、以下の部分です。

UserMailer.password_reset(self).deliver_now

上のコードが動作するために必要なパスワード再設定用メイラーメソッドは、10.1.2で開発したアカウント有効化用メイラーメソッドとほぼ同じです。最初にユーザーメイラーにpassword_resetメソッドを作成し (リスト10.43)、続いてテキストメールのビューテンプレート (リスト10.44) と HTMLメールのビューテンプレート (リスト10.45) をそれぞれ定義します。

リスト10.43: パスワード再設定のリンクをメール送信する app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer

  def account_activation(user)
    @user = user
    mail to: user.email, subject: "Account activation"
  end

  def password_reset(user)
    @user = user
    mail to: user.email, subject: "Password reset"
  end
end
リスト10.44: パスワード再設定のテンプレート (テキストメール) app/views/user_mailer/password_reset.text.erb
To reset your password click the link below:

<%= edit_password_reset_url(@user.reset_token, email: @user.email) %>

This link will expire in two hours.

If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
リスト10.45: パスワード再設定のテンプレート (HTMLメール) app/views/user_mailer/password_reset.html.erb
<h1>Password reset</h1>

<p>To reset your password click the link below:</p>

<%= link_to "Reset password", edit_password_reset_url(@user.reset_token,
                                                      email: @user.email) %>

<p>This link will expire in two hours.</p>

<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>

アカウント有効化メールの場合 (10.1.2) と同様、Railsのメールプレビュー機能でパスワード再設定のメールをプレビューしましょう。そのためのコードはリスト10.16と基本的にまったく同じです (リスト10.46)。

リスト10.46: パスワード再設定のプレビューメソッド (動作可能) test/mailers/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/account_activation
  def account_activation
    user = User.first
    user.activation_token = User.new_token
    UserMailer.account_activation(user)
  end

  # Preview this email at
  # http://localhost:3000/rails/mailers/user_mailer/password_reset
  def password_reset
    user = User.first
    user.reset_token = User.new_token
    UserMailer.password_reset(user)
  end
end

リスト10.46のコードで、HTMLメールとテキストメールをそれぞれプレビューできるようになります (10.1410.15)。

images/figures/password_reset_html_preview
図10.14: パスワード再設定メールのプレビュー (HTMLバージョン)
images/figures/password_reset_text_preview
図10.15: パスワード再設定メールのプレビュー (テキストバージョン)

アカウント有効化メイラーメソッドのテスト (リスト10.18) の場合と同様、パスワード再設定用メイラーメソッドのテストを書くことにします (リスト10.47)。ただし、有効化トークンの場合と異なり、パスワード再設定用トークンはビューの中で使用される点にご注意ください。有効化トークンはbefore_createコールバックでユーザーひとりひとりに対して作成されます (リスト10.3) が、パスワード再設定用トークンの方はユーザーが「forgot password」フォームを送信できた場合にだけ作成されます。この動作は統合テストで自然に行われます (リスト10.54) が、このテストではパスワード再設定用トークンを手動で作成する必要があります。

リスト10.47: パスワード再設定用メイラーメソッドのテストを追加する GREEN test/mailers/user_mailer_test.rb
require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

  test "account_activation" do
    user = users(:michael)
    user.activation_token = User.new_token
    mail = UserMailer.account_activation(user)
    assert_equal "Account activation", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.name,               mail.body.encoded
    assert_match user.activation_token,   mail.body.encoded
    assert_match CGI::escape(user.email), mail.body.encoded
  end

  test "password_reset" do
    user = users(:michael)
    user.reset_token = User.new_token
    mail = UserMailer.password_reset(user)
    assert_equal "Password reset", mail.subject
    assert_equal [user.email], mail.to
    assert_equal ["noreply@example.com"], mail.from
    assert_match user.reset_token,        mail.body.encoded
    assert_match CGI::escape(user.email), mail.body.encoded
  end
end

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

リスト10.48: GREEN
$ bundle exec rake test

リスト10.43リスト10.44リスト10.45のコードを使用すると、正しいメールアドレスを送信したときの画面は10.16のようになります。このメールはサーバーログではリスト10.49のように表示されます。

images/figures/valid_email_password_reset
図10.16: 有効なメールアドレスを送信した場合
リスト10.49: サーバーログに表示されたパスワード再設定メールの例
Sent mail to michael@michaelhartl.com (66.8ms)
Date: Thu, 04 Sep 2014 01:04:59 +0000
From: noreply@example.com
To: michael@michaelhartl.com
Message-ID: <5407babbee139_8722b257d04576a@mhartl-rails-tutorial-953753.mail>
Subject: Password reset
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_5407babbe3505_8722b257d045617";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


----==_mimepart_5407babbe3505_8722b257d045617
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

To reset your password click the link below:

http://rails-tutorial-c9-mhartl.c9.io/password_resets/3BdBrXeQZSWqFIDRN8cxHA/
edit?email=michael%40michaelhartl.com

This link will expire in two hours.

If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
----==_mimepart_5407babbe3505_8722b257d045617
Content-Type: text/html;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

<h1>Password reset</h1>

<p>To reset your password click the link below:</p>

<a href="http://rails-tutorial-c9-mhartl.c9.io/
password_resets/3BdBrXeQZSWqFIDRN8cxHA/
edit?email=michael%40michaelhartl.com">Reset password</a>

<p>This link will expire in two hours.</p>

<p>
If you did not request your password to be reset, please ignore this email and
your password will stay as it is.
</p>
----==_mimepart_5407babbe3505_8722b257d045617--

10.2.4 パスワードを再設定する

以下のようなフォームリンクが動作するためには、

http://example.com/password_resets/3BdBrXeQZSWqFIDRN8cxHA/edit?email=foo%40bar.com

パスワード再設定のフォームが必要です。この作業はユーザーのeditビューでユーザーを更新する (リスト9.2) のと似ていますが、今回はパスワード入力フィールドと確認用フィールドだけを使います。今回は少しだけ面倒な点があります。メールアドレスをキーとしてユーザーを検索するということは、editアクションとupdateアクションの両方でメールアドレスが必要になるということです。例のメールアドレス入りリンクのおかげで、editアクションでメールアドレスを取り出すのは問題ありません。しかしフォームを送信するとこの値は消えてしまいます。この値はどこに保持しておくのがよいのでしょうか。このメールアドレスの最適な保存方法は、隠しフィールドとしてページ内に保存することです。これにより、フォームを送信すると他の情報と一緒にメールアドレスが送信されます。これらを反映したものをリスト10.50に示します。

リスト10.50: パスワード再設定のフォーム app/views/password_resets/edit.html.erb
<% provide(:title, 'Reset password') %>
<h1>Reset password</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= hidden_field_tag :email, @user.email %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Update password", class: "btn btn-primary" %>
    <% end %>
  </div>
</div>

リスト10.50では以下のフォームタグヘルパーを使用している点にご注意ください。

hidden_field_tag :email, @user.email

以下のようないつものヘルパーではありません。

f.hidden_field :email, @user.email

これは、再設定用のリンクをクリックすると、前者ではメールアドレスがparams[:email]に保存されますが、後者を使用するとparams[:user][:email]に保存されてしまうからです。

今度は、このフォームを出力 (レンダリング) するためにPasswordResetsコントローラのeditアクション内で@userインスタンス変数を定義する必要があります。アカウント有効化 (リスト10.29) の場合と同様、params[:email]にあるメールアドレスに対応するユーザーをこの変数に保存します。続いて、params[:id] (リスト10.24で定義した、一般化されたauthenticated?メソッド) の再設定用トークンを使用して、このユーザーが正当なものである (ユーザーが存在する、有効化されている、認証済みである) ことを確認します。editアクションとupdateアクションのどちらの場合も正当な@userが存在する必要があるので、いくつかのbeforeフィルタを使用して@userの検索とバリデーションを行います (リスト10.51)。

リスト10.51: パスワード再設定のeditアクション app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,   only: [:edit, :update]
  before_action :valid_user, only: [:edit, :update]
  .
  .
  .
  def edit
  end

  private

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # 正しいユーザーを確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end
end

リスト10.51では以下のコードを使用しています。

authenticated?(:reset, params[:id])

上を以下のコードと比べてみましょう。

authenticated?(:remember, cookies[:remember_token])

このコードはリスト10.26で使用されていました。もうひとつ、

authenticated?(:activation, params[:id])

これはリスト10.29で使用されていました。いずれの場合も、10.1の認証メソッドを完了します。

上のコードを使用することで、リスト10.49のログにあるリンクを開いたときにパスワード再設定のフォームが出力されるようになります。実行結果を10.17に示します。

images/figures/password_reset_form
図10.17: パスワード再設定のフォーム

リスト10.51editアクションに対応するupdateアクションを定義するには、4通りの場合分けに対応する必要があります: パスワード再設定の期限が切れている場合、更新に成功した場合、更新が失敗した場合 (パスワードが正しくないなど)、更新が失敗した場合 (一見更新が成功したように見えるがパスワードが2つとも空欄) です。1番目はeditアクションと updateアクションの両方で対応する必要があるため、論理的にはbeforeフィルタで行うべきです (リスト10.52)。2番目と3番目はメインのif文の2つの分岐に対応します (リスト10.52)。editフォームはActive Recordモデルオブジェクト (ユーザーなど) を変更するので、エラーメッセージの出力にリスト10.50の一部を共有できます。唯一の例外は、4番目のパスワードが空欄の場合です。現在のUserモデルでは空パスワードが許されている (リスト9.10) ので、空パスワードの場合については明示的に検出および対応する必要があります8。今回は、以下のように@userオブジェクトのエラーメッセージにエラーを直接追加して対応することにします。

@user.errors.add(:password, "can't be empty")
リスト10.52: パスワード再設定のupdateアクション app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  before_action :get_user,         only: [:edit, :update]
  before_action :valid_user,       only: [:edit, :update]
  before_action :check_expiration, only: [:edit, :update]

  def new
  end

  def create
    @user = User.find_by(email: params[:password_reset][:email].downcase)
    if @user
      @user.create_reset_digest
      @user.send_password_reset_email
      flash[:info] = "Email sent with password reset instructions"
      redirect_to root_url
    else
      flash.now[:danger] = "Email address not found"
      render 'new'
    end
  end

  def edit
  end

  def update
    if params[:user][:password].empty?
      @user.errors.add(:password,  "can't be empty")
      render 'edit'
    elsif @user.update_attributes(user_params)
      log_in @user
      flash[:success] = "Password has been reset."
      redirect_to @user
    else
      render 'edit'
    end
  end

  private

    def user_params
      params.require(:user).permit(:password, :password_confirmation)
    end

    # Beforeフィルタ

    def get_user
      @user = User.find_by(email: params[:email])
    end

    # 正しいユーザーを確認する
    def valid_user
      unless (@user && @user.activated? &&
              @user.authenticated?(:reset, params[:id]))
        redirect_to root_url
      end
    end

    # 再設定用トークンが期限切れかどうかを確認する
    def check_expiration
      if @user.password_reset_expired?
        flash[:danger] = "Password reset has expired."
        redirect_to new_password_reset_url
      end
    end
end

リスト10.52の実装では以下のコードを使用して、パスワード再設定の期限切れの論理値テストをUserモデルに委譲 (delegate) しています。

@user.password_reset_expired?

上のコードが動作するには、このpassword_reset_expired?メソッドを定義する必要があります。10.2.3のメールテンプレートのところで説明したように、パスワード再設定の期限を設定し、2時間以上パスワードが再設定されなかった場合は期限切れにする必要があります。これをRubyで表現すると以下のようになります。

reset_sent_at < 2.hours.ago

この「<」記号を「〜より少ない」と読んでしまうと、「パスワード再設定メール送信時から経過した時間が、2時間より少ない場合」となってしまい、ここで行おうとしていることと反対の意味になってしまいます。「<」はここでは「〜より早い時刻」と読んでください。これなら「パスワード再設定メールの送信時刻が、現在時刻より2時間以上前の場合」となり、期待どおりの条件となります。そして条件が満たされるとリスト10.53password_reset_expired?メソッドが実行されます (この比較の公式な証明を10.6に付録として追加しました)。

リスト10.53: Userモデルにパスワード再設定用メソッドを追加する app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  # パスワード再設定の期限が切れている場合はtrueを返す
  def password_reset_expired?
    reset_sent_at < 2.hours.ago
  end

  private
    .
    .
    .
end

リスト10.53のコードを使用すると、リスト10.52updateアクションが動作するようになります。送信が無効だった場合と有効だった場合の画面をそれぞれ10.1810.19に示します (確認のために2時間も待っていられないので、テストにはもうひとつ分岐を追加しますが、これは10.5の演習に回すことにします)。

images/figures/password_reset_failure
図10.18: パスワードの再設定が失敗した場合
images/figures/password_reset_success
図10.19: パスワードの再設定が成功した場合

10.2.5 パスワードの再設定をテストする

この節では、リスト10.52の2つ (または3つ) の分岐、つまり送信に成功した場合と失敗した場合の統合テストを作成します (前述のとおり、3番目の場合については演習に回します)。まずはパスワード再設定のテストファイルを生成しましょう。

$ rails generate integration_test password_resets
      invoke  test_unit
      create    test/integration/password_resets_test.rb

パスワード再設定をテストする手順は、アカウント有効化のテスト (リスト10.31) と多くの共通点がありますが、テストの冒頭部分には次のような違いがあります: 最初に「forgot password」フォームを表示して無効なメールアドレスを送信し、次はそのフォームで有効なメールアドレスを送信します。後者ではパスワード再設定用トークンが作成され、再設定用メールが送信されます。続いて、メールのリンクを開いて無効な情報を送信し、次にそのリンクから有効な情報を送信して、それぞれが期待どおりに動作することを確認します。作成したテストをリスト10.54に示します。このテストはコードリーディングのよい練習台になりますので、みっちりお読みください。

リスト10.54: パスワード再設定の統合テスト test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest
  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end

  test "password resets" do
    get new_password_reset_path
    assert_template 'password_resets/new'
    # メールアドレスが無効
    post password_resets_path, password_reset: { email: "" }
    assert_not flash.empty?
    assert_template 'password_resets/new'
    # メールアドレスが有効
    post password_resets_path, password_reset: { email: @user.email }
    assert_not_equal @user.reset_digest, @user.reload.reset_digest
    assert_equal 1, ActionMailer::Base.deliveries.size
    assert_not flash.empty?
    assert_redirected_to root_url
    # パスワード再設定用フォーム
    user = assigns(:user)
    # メールアドレスが無効
    get edit_password_reset_path(user.reset_token, email: "")
    assert_redirected_to root_url
    # 無効なユーザー
    user.toggle!(:activated)
    get edit_password_reset_path(user.reset_token, email: user.email)
    assert_redirected_to root_url
    user.toggle!(:activated)
    # メールアドレスが正しく、トークンが無効
    get edit_password_reset_path('wrong token', email: user.email)
    assert_redirected_to root_url
    # メールアドレスもトークンも有効
    get edit_password_reset_path(user.reset_token, email: user.email)
    assert_template 'password_resets/edit'
    assert_select "input[name=email][type=hidden][value=?]", user.email
    # 無効なパスワードと確認
    patch password_reset_path(user.reset_token),
          email: user.email,
          user: { password:              "foobaz",
                  password_confirmation: "barquux" }
    assert_select 'div#error_explanation'
    # パスワードが空
    patch password_reset_path(user.reset_token),
          email: user.email,
          user: { password:              "",
                  password_confirmation: "" }
    assert_select 'div#error_explanation'
    # 有効なパスワードと確認
    patch password_reset_path(user.reset_token),
          email: user.email,
          user: { password:              "foobaz",
                  password_confirmation: "foobaz" }
    assert is_logged_in?
    assert_not flash.empty?
    assert_redirected_to user
  end
end

リスト10.54で使用されているアイデアの大半は、本チュートリアルで既出です。今回の新しい要素はinputタグぐらいでしょう。

assert_select "input[name=email][type=hidden][value=?]", user.email

上のコードは、inputタグに正しい名前、type="hidden"、メールアドレスがあるかどうかを確認します。

<input id="email" name="email" type="hidden" value="michael@example.com" />

リスト10.54のコードを使用すると、テストコードはGREENになるはずです。

リスト10.55: GREEN
$ bundle exec rake test

10.3 本番環境でのメール

アカウント有効化とパスワード復旧の最大の山場であるこのセクションでは、いよいよproduction (本番) 環境でアプリケーションからメールを送信します。最初に無料のサービスを利用してメールを送信し、続いてアプリケーションの設定とデプロイを行います。

production環境からメール送信するために、「SendGrid」というHerokuアドオンを使用してアカウントを検証します (このアドオンを使用するにはHerokuアカウントにクレジットカードを設定する必要がありますが、アカウント検証では料金は発生しません)。本チュートリアルでは、「starter tier」というサービスを使用することにします。これは、(執筆時点では) 1日の最大メール数が400通までという制限がありますが、無料で使用することができます。アドオンをアプリに追加するには、以下のコマンドを実行します。

$ heroku addons:create sendgrid:starter

(訳注: herokuコマンドのバージョンが古いとここで失敗するかもしれません。その場合は、Heroku Toolbelt ( https://toolbelt.heroku.com/ ) を使って最新版に更新するか、次の古い文法のコマンドを試してみてください: $ heroku addons:add sendgrid:starter )

アプリケーションでSendGridアドオンを使用するには、production環境のSMTPに情報を記入する必要があります。リスト10.56に示したとおり、本番Webサイトのアドレスをhost変数に定義する必要もあります。

リスト10.56: production環境のRailsでSendGridを使用する設定 config/environments/production.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  host = '【ここにHerokuのサブドメインを入力】.herokuapp.com'
  config.action_mailer.default_url_options = { host: host }
  ActionMailer::Base.smtp_settings = {
    :address        => 'smtp.sendgrid.net',
    :port           => '587',
    :authentication => :plain,
    :user_name      => ENV['SENDGRID_USERNAME'],
    :password       => ENV['SENDGRID_PASSWORD'],
    :domain         => 'heroku.com',
    :enable_starttls_auto => true
  }
  .
  .
  .
end

リスト10.56のメール設定にはSendGridアカウントのuser_namepassword設定を記入する行もありますが、そこには記入せず、必ず環境変数「ENV」に設定するよう十分ご注意ください。本番運用するアプリケーションでは、暗号化されていないIDやパスワードのような重要なセキュリティ情報は「絶対に」ソースコードに直接書き込まないでください。そのような情報は環境変数に記述し、そこからアプリケーションに読み込む必要があります。今回の場合、そうした変数はSendGridアドオンが自動的に設定してくれますが、11.4.4では環境変数を自分で設定しなければなりません。参考までに、リスト10.56で使用するHerokuの環境変数を表示するには、以下のコマンドを実行します。

$ heroku config:get SENDGRID_USERNAME
$ heroku config:get SENDGRID_PASSWORD

この時点で、Gitのトピックブランチをmasterにマージしておきましょう。

$ bundle exec rake test
$ git add -A
$ git commit -m "Add password resets & email configuration"
$ git checkout master
$ git merge account-activation-password-reset

続いてリモートリポジトリにプッシュし、Herokuにデプロイします。

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

Herokuへのデプロイが完了したら、自分が管理しているメールアドレスを使用して、production環境のサンプルアプリケーションでユーザー登録を行ってみましょう。10.1.1で実装した有効化メールが配信されるはずです (10.20)。また、パスワードを忘れた時の再設定手順も10.2で実装したとおりに動作するはずです (10.21)。

images/figures/activation_email_production
図10.20: production環境から送信したアカウント有効化メール
images/figures/reset_email_production
図10.21: production環境から送信したパスワード再設定メール

10.4 最後に

アカウント有効化機能とパスワード再設定機能が追加されたことで、ついにサンプルアプリケーションの登録、ログイン、ログアウト機能がすべて本格的に実装完了しました。Railsチュートリアルのこの後の章では、Twitterのようなマイクロポスト機能 (11) と、フォロー中のユーザーの投稿のステータスフィード機能 (12) の基本的な部分をサイトに搭載することにしましょう。それらの章では、Railsの強力な機能 (画像アップロード、カスタムのデータベースクエリ、has_manyhas_many :throughを使用した高度なデータベースモデリングなど) を多数紹介する予定です。

10.4.1 本章のまとめ

  • アカウント有効化は Active Recordオブジェクトではないが、セッションの場合と同様に、リソースでモデル化できる。
  • メール送信のためのActive Mailerアクションやビューの生成機能がRailsに備わっている。
  • Action MailerではテキストメールとHTMLメールを両方利用できる。
  • メイラーアクションで定義したインスタンス変数は、他のアクションやビューと同様、メイラーのビューから参照できる。
  • パスワードの再設定は Active Recordオブジェクトではないが、セッションやアカウント有効化の場合と同様に、リソースでモデル化できる。
  • アカウント有効化やパスワード再設定では、ユーザーを有効化したりパスワードを再設定するために一意のURLを作成する。一意のURLには生成したトークンが使用される。
  • メイラーのテストと統合テストは、どちらもUserメイラーの振舞いを確認するのに有用。
  • SendGridを使用するとproduction環境からメールを送信できる。

10.5 演習

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

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

  1. リスト10.57のテンプレートを埋めて、期限切れのパスワード再設定のブランチ (リスト10.52) の統合テストを作成してください (10.57 のコードにあるresponse.bodyは、そのページのHTML本文をすべて返すメソッドです)。期限切れのテスト方法はさまざまですが、リスト10.57でおすすめした手法 (大文字小文字は区別されません) を使えば、レスポンスの本文に「expired」という語があるかどうかをチェックできます。
  2. 現在は、/usersのユーザーインデックスページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、リスト10.589のテンプレートに記入して、この動作を変更してください。なお、ここで使用するActive Recordのwhereメソッドについては、11.3.3でもう少し詳しく説明します。応用問題: /usersと/users/:id両方の統合テストを作成してください。
  3. リスト10.42では、activateメソッドとcreate_reset_digestメソッドの両方でupdate_attributeを呼び出しており、それぞれのアクセスによってデータベーストランザクションが個別に発生してしまう点が残念です。リスト10.59のテンプレートに記入することで、個別のupdate_attribute呼び出しを単一のupdate_columns呼び出しに統合し、データベースアクセスが1回で済むようにしてください。変更後にテストを実行し、GREENになることを確認してください。
リスト10.57: パスワード再設定の期限切れのテスト GREEN test/integration/password_resets_test.rb
require 'test_helper'

class PasswordResetsTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
    @user = users(:michael)
  end
  .
  .
  .
  test "expired token" do
    get new_password_reset_path
    post password_resets_path, password_reset: { email: @user.email }

    @user = assigns(:user)
    @user.update_attribute(:reset_sent_at, 3.hours.ago)
    patch password_reset_path(@user.reset_token),
          email: @user.email,
          user: { password:              "foobar",
                  password_confirmation: "foobar" }
    assert_response :redirect
    follow_redirect!
    assert_match /FILL_IN/i, response.body
  end
end
リスト 10.58: 有効なユーザーだけを表示するコードのテンプレート app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def index
    @users = User.where(activated: FILL_IN).paginate(page: params[:page])
  end

  def show
    @user = User.find(params[:id])
    redirect_to root_url and return unless FILL_IN
  end
  .
  .
  .
end
リスト10.59: update_columnsを使用するテンプレート app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :remember_token, :activation_token, :reset_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_columns(activated: FILL_IN, activated_at: FILL_IN)
  end

  # 有効化用のメールを送信する
  def send_activation_email
    UserMailer.account_activation(self).deliver_now
  end

  # パスワード再設定の属性を設定する
  def create_reset_digest
    self.reset_token = User.new_token
    update_columns(reset_digest:  FILL_IN,
                   reset_sent_at: FILL_IN)
  end

  # パスワード再設定用メールを送信する
  def send_password_reset_email
    UserMailer.password_reset(self).deliver_now
  end

  private

    # メールアドレスをすべて小文字にする
    def downcase_email
      self.email = email.downcase
    end

    # 有効化トークンとダイジェストを作成および代入する
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end

10.6 証明: 期限切れの比較

この説では、10.2.4で用いたパスワード期限切れの期間の比較が正しいことを証明します。最初に、期間を2つ定義します。\( \Delta t_r \)はパスワード再設定メールを送信してからの期間、\( \Delta t_e \)はパスワード再設定の有効な期間 (例: 2時間) と定めます。パスワードの再設定は、メールが送信された時刻から経過した期間が、有効期間よりも長くなった場合に「期限切れ」となります。これを次のように表します。

\begin{equation} \label{eq:time_delta} \Delta t_r > \Delta t_e. \end{equation}

ここで、現在時刻 (訳注: 比較を行った時刻) を\( t_N \)で表し、パスワード再設定メールの送信時刻を\( t_r \)で表し、有効期間が切れる時刻を\( t_e \) (例: 2時間前) と表すと、次の2つの関係式を得ることができます。

\begin{equation} \label{eq:delta_p} \Delta t_r = t_N - t_r \end{equation}

および

\begin{equation} \label{eq:delta_e} \Delta t_e = t_N - t_e. \end{equation}

式(10.2)式(10.3)(10.1)に代入すると、次の結果が得られます。

\[ \begin{array}{rcl} \Delta t_r & > & \Delta t_e \\ t_N - t_r & > & t_N - t_e \\ -t_r & > & -t_e, \end{array} \]

両辺に\( -1 \)をかけると、次の式が得られます。

\begin{equation} \label{eq:time_comparison} t_r < t_e. \end{equation}

(10.4)をRailsコードに置き換え、値を\( t_e = \mathrm{2\ 時間前} \)とすると、リスト10.53password_reset_expired?メソッドと同じコードになります。

def password_reset_expired?
  reset_sent_at < 2.hours.ago
end

10.2.4でも説明したとおり、「<」を「〜より少ない」ではなく「〜より早い時刻」と解釈すれば、「パスワードの再設定は、現在より2時間以上前の時刻に行われた」という言明と一致します。

  1. 細かいことを言えば、9.1のアカウント設定更新機能を使って、おかしなメールアドレスを設定することもできてしまいます。ここで行う実装では、メールによるメールアドレス検証のメリットを (やりすぎない範囲で) 十分享受できるはずです。
  2. せっかくユーザーIDが既にアプリケーションのURLでそのまま使われているのですから、メールアドレスの代わりにユーザーIDを使うという手ももちろんあります。ただ、メールアドレスにしておけば、将来何らかの理由でユーザーIDをぼかしておきたくなる場合に役に立つかもしれません (競合他社があなたのアプリケーションのユーザー数を推測しにくいようにしたい、など)。
  3. こういう場合はupdateアクションにするのではないかとお思いの方もいるでしょう。しかし、有効化リンクはメールでユーザーに送られることを思い出してください。このリンクをクリックすれば、ブラウザで普通にクリックしたのと同じことになり、ブラウザから発行されるのは (updateアクションで使用するPATCHリクエストではなく) 必然的にGETリクエストになります。GETリクエストを受けるにはeditアクションにならざるを得ないわけです。
  4. editアクションを使いたいのですから、コマンドラインでeditと指定すればよいように思えますが、そうすると使いもしないeditビューやテストまで生成されてしまうのです。
  5. たとえば、攻撃者が仮にデータベースにアクセスできてしまうと、攻撃者が作成した新しいアカウントを即座に有効にすることができてしまいます。攻撃者はそのアカウントでゆうゆうとログインし、パスワードを変更してそのアカウントの権限を手に入れることでしょう。
  6. URLのクエリパラメータではキー/値ペアを複数使用することもできます。その場合は、「&」を使用して「/edit?name=Foo%20Bar&email=foo%40example.com」のように区切ります。
  7. これについてもう少し詳しくお知りになりたい場合は、「ruby rails escape url」で検索してみてください。おそらく2通りの手法 (URI::encode(str)CGI::escape(str)) が見つかると思います。両方試してみるとわかると思いますが、実際に動作するのは後者の方です (実はもうひとつ方法があります: ERB::Utilライブラリのurl_encodeメソッドでも同じことができます)。
  8. この場合、パスワードフィールドが空である場合だけを扱います。パスワードの確認フィールドが空の場合は、確認フィールドのバリデーションで検出され、エラーメッセージが表示されるので不要です。ただし、パスワードフィールドとパスワード確認フィールドが両方空だとバリデーションがスキップされてしまいます。
  9. リスト10.58では、&&ではなくandを使用していることにご注意ください。&&とandの動作は「ほぼ」同等ですが、&&演算子の方がandよりも優先順位が高いので、&&だとroot_urlとの論理的な結び付きが強くなりすぎてしまい、不適切です。root_urlをかっこで囲んでこの問題を回避することもできますが、andを使用する方が常道です。
前の章
第10章 アカウント有効化とパスワード再設定 Rails 4.2 (第3版)
次の章