アカウントの有効化
現時点のアプリケーションは、新規登録したユーザーは初めからすべての機能にアクセスできるようになっている。
本記事では、アカウントを有効化するステップを新規登録の途中に差し込むことで、本当にそのメールアドレスの持ち主なのかどうかを確認できるようにしてみる。
これを実現するための大まかな流れは、
- 有効化トークンやダイジェストを関連付けておいた状態で、
- 有効化トークンを含めたリンクをユーザーにメールで送信し、
- ユーザーがそのリンクをクリックすると有効化できるようにする
これらの機能ごとに新しいリソースを作成し、コントローラ/ルーティング/データベース移行の例について1つずつ学んでいく。
最後に、Railsの開発環境や本番環境からメールを実際に送信する方法についても学ぶ。
アカウントを有効化する段取りは、ユーザーログイン、特にユーザーの記憶と似ています。基本的な手順は次のようになる。
- ユーザーの初期状態は「有効化されていない」(unactivated) にしておく。
- ユーザー登録が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する。
- 有効化ダイジェストはデータベースに保存しておき、有効化トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。
- ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた有効化ダイジェストと比較することでトークンを認証する。
- ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated) に変更する。
都合の良いことに、今回実装するアカウント有効化やパスワード再設定の仕組みと、以前に実装したパスワードや記憶トークンの仕組みにはよく似た点が多いので、多くのアイデアを使い回すことができる(具体的にはUser.digestやUser.new_token、改造版のuser.authenticated?メソッドなど)。
それぞれの仕組みの似ている点をまとめてみた(後述するメソッドも含めている)。
検索キー | string | digest | authentication |
---|---|---|---|
password | password_digest | authenticate(password) | |
id | remember_token | remember_digest | authenticated?(:remember, token) |
activation_token | activation_digest | authenticated?(:activation, token) | |
reset_token | reset_digest | authenticated?(:reset, token) |
AccountActivationsリソースでは、アカウント有効化に必要なリソースやデータモデルを作っていく。
またアカウント有効化のメール送信では、メイラー (mailer) を使ってアカウント有効化時のメール送信部分を作っていく。
最後に、アカウントを有効化するでは、上の表で紹介した改良版authenticated?メソッドを使って、実際にアカウントを有効化する部分を実装していく。
AccountActivationsリソース
セッション機能を使って、アカウントの有効化という作業を「リソース」としてモデル化することにする。
アカウントの有効化リソースはActive Recordのモデルとはこの際関係ないので、両者を関連付けることはしない。
その代わりに、この作業に必要なデータ(有効化トークンや有効化ステータスなど)をUserモデルに追加することにする。
なお、アカウント有効化もリソースとして扱いたいのだが、いつもとは少し使い方が異なる。
例えば、有効化用のリンクにアクセスして有効化のステータスを変更する部分では、RESTのルールに従うとPATCHリクエストとupdateアクションになるべき。
しかし、有効化リンクはメールでユーザーに送られるため、ユーザーがこのリンクをクリックすれば、それはブラウザで普通にクリックしたときと同じであり、その場合ブラウザから発行されるのは(updateアクションで使うPATCHリクエストではなく)GETリクエストになってしまう。
このため、ユーザーからのGETリクエストを受けるために、(本来であればupdateのところを)editアクションに変更して使っていく。
AccountActivationsコントローラ
UsersリソースやSessionsリソースのときと同様に、AccountActivationsリソースを作るために、まずはAccountActivationsコントローラを生成してみる。
rails generate controller AccountActivations
有効化のメールには次のURLを含めることになる。
edit_account_activation_url(activation_token, ...)
これは、editアクションへの名前付きルートが必要になるということ。
そこでまずは、名前付きルートを扱えるようにするため、ルーティングにアカウント有効化用のresources行を追加する。
Rails.application.routes.draw do
root 'static_pages#home'
get '/help', to: 'static_pages#help'
get '/about', to: 'static_pages#about'
get '/contact', to: 'static_pages#contact'
get '/signup', to: 'users#new'
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
resources :users
resources :account_activations, only: [:edit]
end
HTTPリクエスト | URL | Action | 名前付きルート |
---|---|---|---|
GET | /account_activation//edit | edit | edit_account_activation_url(token) |
先にアカウント有効化用のデータモデルとメイラーを作っていくが、それが終わったらここで作ったリソースをもとにeditアクションを定義していく。
AccountActivationのデータモデル
有効化のメールには一意の有効化トークンが必要。
パッと思いつくのは、送信メールとデータベースのそれぞれに同じ文字列を置いておく方法。
しかし、この方法では万が一データベースの内容が漏れたとき、多大な被害に繋がってしまう。
例えば、攻撃者がデータベースへのアクセスに成功した場合、新しく登録されたユーザーアカウントの有効化トークンを盗み取り、本来のユーザーが使う前にそのトークンを使ってしまう (そしてそのユーザーとしてログインしてしまう) ケースが考えられる。
このような事態を防ぐために、パスワードの実装や記憶トークンの実装 と同じように仮想的な属性を使ってハッシュ化した文字列をデータベースに保存するようにする。
具体的には、次のように仮想属性の有効化トークンにアクセスし、
user.activation_token
このようなコードでユーザーを認証できるようになる。
user.authenticated?(:activation, token)
(これを行うにはauthenticated?メソッドを改良する必要がある。)
続いて、activated属性を追加して論理値を取るようにする。
これで、自動生成の論理値メソッドと同じような感じで、ユーザーが有効であるかどうかをテストできるようになる。
if user.activated? ...
最後に、使うことはないが、ユーザーを有効にしたときの日時も念のために記録しておく。
次のマイグレーションをコマンドラインで実行する。
rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime
次に、admin属性のときと同様に、activated属性のデフォルトの論理値をfalseにしておく。
class AddActivationToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :activation_digest, :string
add_column :users, :activated, :boolean, default: false
add_column :users, :activated_at, :datetime
end
end
Activationトークンのコールバック
ユーザーが新しい登録を完了するためには必ずアカウントの有効化が必要になるから、有効化トークンや有効化ダイジェストはユーザーオブジェクトが作成される前に作成しておく必要がある。
これによく似た状況では、メールアドレスをデータベースに保存する前に、メールアドレスを全部小文字に変換する必要があった。
あのときは、before_saveコールバックにdowncaseメソッドをバインドしました。
オブジェクトにbefore_saveコールバックを用意しておくと、オブジェクトが保存される直前、オブジェクトの作成時や更新時にそのコールバックが呼び出される。
しかし今回は、オブジェクトが作成されたときだけコールバックを呼び出したい。
それ以外のときには呼び出したくない。
そこでbefore_createコールバックが必要になる。
このコールバックは次のように定義できる。
before_create :create_activation_digest
上のコードはメソッド参照と呼ばれるもので、こうするとRailsはcreate_activation_
digestというメソッドを探し、ユーザーを作成する前に実行するようになる(downcaseメソッド使用時には、before_saveに明示的にブロックを渡していましたが、メソッド参照の方がオススメ)。create_activation_digestメソッド自体はUserモデル内でしか使わないので、外部に公開する必要はない。
privateキーワードを指定して、このメソッドをRuby流に隠蔽する。
private
def create_activation_digest
# 有効化トークンとダイジェストを作成および代入する
end
クラス内でprivateキーワードより下に記述したメソッドは自動的に非公開となる。
今回before_createコールバックを使う目的は、トークンとそれに対応するダイジェストを割り当てるため。
実際の割り当ては次のように行う。
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
このコードでは、記憶トークンや記憶ダイジェストのために作ったメソッドを使いまわしている。
rememberメソッドと比べてみる。
# 永続セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
主な違いは、後者のupdate_attributeの使い方にある。
この違いは、記憶トークンやダイジェストは既にデータベースにいるユーザーのために作成されるのに対し、before_createコールバックの方はユーザーが作成される前に呼び出される。
このコールバックがあることで、User.newで新しいユーザーが定義されると、activation_token属性やactivation_digest属性が得られるようになる。
後者のactivation_digest属性は既にデータベースのカラムとの関連付けができあがっているので、ユーザーが保存されるときに一緒に保存される。
上で説明したことをUserモデルに実装すると以下のようになる。
有効化トークンは本質的に仮のものでなければならないので、このモデルのattr_accessorにもう1つ追加した。
なお、以前に実装したメールアドレスを小文字にするメソッドも、メソッド参照に切り替えている。
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token # activation_token属性の追加
before_save :downcase_email # メソッド参照に切り替え
before_create :create_activation_digest # 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
サンプルユーザーの生成とテスト
サンプルデータとfixtureも更新し、テスト時のサンプルとユーザーを事前に有効化しておく。
なお、Time.zone.nowはRailsの組み込みヘルパーであり、サーバーのタイムゾーンに応じたタイムスタンプを返す。
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
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 %>
malory:
name: Malory 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 %>
いつものようにデータベースを初期化して、サンプルデータを再度生成し直し、変更を反映する。
rails db:migrate:reset
rails db:seed
アカウント有効化のメール送信
データのモデル化が終わったので、今度はアカウント有効化メールの送信に必要なコードを追加する。
このメソッドではAction Mailerライブラリを使ってUserのメイラーを追加する。
このメイラーはUsersコントローラのcreateアクションで有効化リンクをメール送信するために使う。
メイラーの構成はコントローラのアクションとよく似ており、メールのテンプレートをビューと同じ要領で定義できる。
このテンプレートの中に有効化トークンとメールアドレス(= 有効にするアカウントのアドレス)のリンクを含め、使っていく。
送信メールのテンプレート
メイラーは、モデルやコントローラと同様にrails generateで生成できる。
rails generate mailer UserMailer account_activation password_reset
上のコードを実行したことで、今回必要となるaccount_activationメソッドと、後に必要となるpassword_resetメソッドが生成された。
また、生成したメイラーごとに、ビューのテンプレートが2つずつ生成される。
1つはテキストメール用のテンプレート、もう1つはHTMLメール用のテンプレート。
以下のコードは、アカウント有効化に使うテンプレート。
なお、パスワード再設定で使うテンプレートは今後使う。
UserMailer#account_activation
<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
<h1>UserMailer#account_activation</h1>
<p>
<%= @greeting %>, find me in app/views/user_mailer/account_activation.html.erb
</p>
生成されたメイラーの動作を簡単に追ってみる。
デフォルトのfromアドレス(アプリケーション全体で共通)がある。
各メソッドには宛先メールアドレスもある。
メールのフォーマットに対応するメイラーレイアウトも使われている。
なお今回は直接関係ないが、生成されるHTMLメイラーのレイアウトやテキストメイラーのレイアウトはapp/views/layoutsで確認できる。
生成されたコードにはインスタンス変数@greetingも含まれている。
このインスタンス変数は、ちょうど普通のビューでコントローラのインスタンス変数を利用できるのと同じように、メイラービューで利用できる。
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com" # fromアドレス
layout 'mailer' # メイラーレイアウト
end
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
最初に、生成されたテンプレートをカスタマイズして、実際に有効化メールで使えるようにする。
次に、ユーザーを含むインスタンス変数を作成してビューで使えるようにし、user.emailにメール送信する。
mailにsubjectキーを引数として渡している。
この値は、メールの件名にあたる。
class ApplicationMailer < ActionMailer::Base
default from: "noreply@example.com" # 実際に有効化メールで使えるようにする
layout 'mailer'
end
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リソースで有効化をモデル化したので、トークン自体は定義した名前付きルートの引数で使われる。
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メソッドで生成されたもの。
URLで使えるようにBase64でエンコードされている。
これはちょうど/users/1/editの「1」のようなユーザーIDと同じ役割を果たす。
このトークンは、特にAccountActivationsコントローラのeditアクションではparamsハッシュでparams[:id]として参照できる。
クエリパラメータを使って、このURLにメールアドレスもうまく組み込んでみる。
クエリパラメータとは、URLの末尾で疑問符「?」に続けてキーと値のペアを記述したもの。
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]からメールアドレスを取り出すときには、自動的にエスケープを解除してくれる。
ここまでできれば、定義した@userインスタンス変数、editへの名前付きルート、ERBを組み合わせて、必要なリンクを作成できる。
HTMLテンプレートでは、正しいリンクを組立てるためにlink_toメソッドを使っている。
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) %>
<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) %>
送信メールのプレビュー
定義したテンプレートの実際の表示を簡単に確認するために、メールプレビューという裏技を使ってみる。
Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができる。
メールを実際に送信しなくてもよいので大変便利。
これを利用するには、アプリケーションのdevelopment環境の設定に手を加える必要がある。
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, protocol: 'https' }
.
.
.
end
ホスト名 ’example.com’ の部分は、各自のdevelopment環境に合わせて変更する。
例えば、クラウドIDEを使っている場合は、このような設定になる。
host = 'rails-tutorial-mhartl.c9users.io' # クラウドIDE
config.action_mailer.default_url_options = { host: host, protocol: 'https' }
一方、もしローカル環境で開発している場合は、次のようになる。
host = 'localhost:3000' # ローカル環境
config.action_mailer.default_url_options = { host: host, protocol: 'http' }
developmentサーバーを再起動して、development環境のメール設定(config/environments/development.rb)を読み込んだら、次は自動生成したUserメイラーのプレビューファイルの更新が必要。
# 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
定義したaccount_activationの引数には有効なUserオブジェクトを渡す必要があるため、このままでは動かない。
これを回避するために、user変数が開発用データベースの最初のユーザーになるように定義して、それをUserMailer.account_activationの引数として渡す。
このとき、user.activation_tokenの値にも代入している。
アカウント有効化のテキストビュー(app/views/user_mailer/account_activation.text.erb)やアカウント有効化のHTMLビュー
(app/views/user_mailer/account_activation.html.erb)のテンプレートでは、アカウント有効化のトークンが必要なので、代入は省略できない。
なお、activation_tokenは仮の属性でしかないので、データベースのユーザーはこの値を実際には持っていない。
# 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
上のプレビューコードを実装すると、指定のURLでアカウント有効化メールをプレビューできるようになる(クラウドIDEを使っている場合は、localhost:3000の部分を対応するベースURLに置き換える)。
送信メールのテスト
最後に、このメールプレビューのテストも作成して、プレビューをダブルチェックできるようにする。
便利なテスト例がRailsによって自動生成されているので、これを利用すればテストの作成は割と簡単。
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
上のテストでは、assert_matchという非常に強力なメソッドが使われている。
これを使えば、正規表現で文字列をテストできる。
assert_match 'foo', 'foobar' # true
assert_match 'baz', 'foobar' # false
assert_match /\w+/, 'foobar' # true
assert_match /\w+/, '$#!*+@' # false
次のテストでは、assert_matchメソッドを使って名前、有効化トークン、エスケープ済みメールアドレスがメール本文に含まれているかどうかをテストする。
CGI.escape(user.email)
また、上のメソッドを使うと、テスト用のユーザーのメールアドレスをエスケープすることもできる。
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
上のテストコードでは、fixtureユーザーに有効化トークンを追加している。
追加しない場合は空白になる。
なお、生成されたパスワード設定のテストも削除しているが、後にこの箇所は元に戻す。
このテストがパスするには、テストファイル内のドメイン名を正しく設定する必要がある。
Rails.application.configure do
.
.
.
config.action_mailer.delivery_method = :test
config.action_mailer.default_url_options = { host: 'example.com' }
.
.
.
end
ユーザーのcreateアクションを更新
ユーザー登録を行うcreateアクションに数行追加するだけで、メイラーをアプリケーションで実際に使うことができる。
また、登録時のリダイレクトの挙動が変更されている。
変更前は、ユーザーのプロフィールページにリダイレクトしていたが、アカウント有効化を実装するうえでは無意味な動作なので、リダイレクト先をルートURLに変更してある。
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
ダイレクト先をプロフィールページからルートURLに変更し、かつユーザーは以前のようにログインしないようになっている。
したがって、アプリケーションの動作が仮に正しくても、現在のテストスイートはredになる。
そこで、失敗が発生するテストの行をひとまずコメントアウトしておく。
コメントアウトした部分は、アカウント有効化のテストをパスさせるときに元に戻す。
require 'test_helper'
class UsersSignupTest < ActionDispatch::IntegrationTest
test "invalid signup information" do
get signup_path
assert_no_difference 'User.count' do
post users_path, params: { 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 users_path, params: { user: { name: "Example User",
email: "user@example.com",
password: "password",
password_confirmation: "password" } }
end
follow_redirect!
# assert_template 'users/show' # コメントアウトする
# assert is_logged_in? # コメントアウトする
end
end
この状態で実際に新規ユーザーとして登録してみると、リダイレクトされて以下のようなメールが生成される。
ただし、実際にメールが生成されるわけではない。
ここに引用したのはサーバーログに出力されたメール(メールが見えるまで多少スクロールが必要)。
production環境で実際にメール送信する方法については後で説明する。
アカウントを有効化する
メールが生成できたら、今度はAccountActivationsコントローラのeditアクションを書いていく。
また、アクションへのテストを書き、しっかりとテストできていることが確認できたら、AccountActivationsコントローラからUserモデルにコードを移していく作業(リファクタリング)にも取り掛かっていく。
authenticated?メソッドの抽象化
ここで、有効化トークンとメールをそれぞれparams[:id]とparams[:email]で参照できることを思い出してみる。
パスワードのモデルと記憶トークン で学んだことを元に、次のようなコードでユーザーを検索して認証することにする。
user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])
(この後、上の式に論理値を1つ追加する。)
上のコードで使っているauthenticated?メソッドは、アカウント有効化のダイジェストと、渡されたトークンが一致するかどうかをチェックする。
ただし、このメソッドは記憶トークン用なので今は正常に動作しない。
# トークンがダイジェストと一致したら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
今回は、上のコードのrememberの部分をどうにかして変数として扱いたい。
つまり、次のコード例のように、状況に応じて呼び出すメソッドを切り替えたい。
self.FOOBAR_digest
これから実装するauthenticated?メソッドでは、受け取ったパラメータに応じて呼び出すメソッドを切り替える手法を使う。
この一見不思議な手法は「メタプログラミング」と呼ばれている。
メタプログラミングを一言で言うと「プログラムでプログラムを作成する」こと。
メタプログラミングはRubyが有するきわめて強力な機能であり、Railsの一見魔法のような機能(「黒魔術」とも呼ばれる)の多くは、Rubyのメタプログラミングによって実現されている。
ここで重要なのは、sendメソッドの強力きわまる機能。
このメソッドは、渡されたオブジェクトに「メッセージを送る」ことによって、呼び出すメソッドを動的に決めることができる。
例えば、Railsコンソールを開き、Rubyのオブジェクトに対してsendメソッドを実行し、配列の長さを得るとする。
rails console
>> a = [1, 2, 3]
>> a.length
=> 3
>> a.send(:length)
=> 3
>> a.send("length")
=> 3
このときsendを通して渡したシンボル:lengthや文字列"length"は、いずれもlengthメソッドと同じ結果になった。
つまり、どちらもオブジェクトにlengthメソッドを渡しているため、等価。
もう1つの例は、データベースの最初のユーザーが持つ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"
これは、シンボルが式展開されて文字列になったのと同じ仕組み。
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?メソッドを示す。
class User < ApplicationRecord
.
.
.
# トークンがダイジェストと一致したらtrueを返す
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
.
.
.
end
この時点ではテストスイートは、redになる。
テストが失敗する理由は、current_userメソッドとnilダイジェストのテストの両方で、authenticated?が古いままになっており、引数も2つではなくまだ1つのままだから
。
これを解消するため、両者を更新して、新しい一般的なメソッドを使うようにする。
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
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に変わる。
このようなリファクタリングを施すとエラーが発生しやすくなるので、しっかりしたテストスイートが不可欠。
editアクションで有効化
authenticated?が改良されたことで、やっとeditアクションを書く準備ができた。
このアクションは、paramsハッシュで渡されたメールアドレスに対応するユーザーを認証する。
ユーザーが有効であることを確認する中核は、次の部分になる。
if user && !user.activated? && user.authenticated?(:activation, params[:id])
先ほど「1つ論理値を追加します」と言ったのは!user.activated?をここで利用したいから。
このコードは、既に有効になっているユーザーを誤って再度有効化しないために必要。
正当であろうとなかろうと、有効化が行われるとユーザーはログイン状態になる。
もしこのコードがなければ、攻撃者がユーザーの有効化リンクを後から盗みだしてクリックするだけで、本当のユーザーとしてログインできてしまう。
そうした攻撃を防ぐためにこのコードは非常に重要。
上の論理値に基いてユーザーを認証するには、ユーザーを認証してからactivated_atタイムスタンプを更新する必要がある。
user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)
上のコードをeditアクションで使う。
また、有効化トークンが無効だった場合の処理も行う。
トークンが無効になるようなことは実際にはめったにないが、もしそうなった場合はルートURLにリダイレクトされる仕組み。
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
このコードを使うと、メールにあるURLを貼り付けてユーザーを有効化できる。
もちろん、この時点ではユーザーのログイン方法を変更していないので、ユーザーの有効化にはまだ何の意味もない。
ユーザーの有効化が役に立つためには、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する必要がある。
これを行うにはuser.activated?がtrueの場合にのみログインを許可し、そうでない場合はルートURLにリダイレクトしてwarningで警告を表示する。
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
これで、ユーザー有効化機能のおおまかな部分については実装できた(改良すべき点として、有効化されていないユーザーが表示されないようにする必要もあるのだが、これは後に回すことにする)。
テストをもう少し追加し、リファクタリングを少々施せば完了。
有効化のテストとリファクタリング
ここでは、アカウント有効化の統合テストを追加する。
正しい情報でユーザー登録を行った場合のテストは既にあるので、開発したテストに若干手を加えることにする。
追加される行数はそこそこ多いのだが、基本的に素直なので心配はない。
なお、今回追加する行は見落とされがちなので、慎重にコードを変更していく。
require 'test_helper'
class UsersSignupTest < ActionDispatch::IntegrationTest
def setup # 変数deliveriesを初期化する
ActionMailer::Base.deliveries.clear
end
test "invalid signup information" do
get signup_path
assert_no_difference 'User.count' do
post users_path, params: { 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, params: { 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", email: user.email)
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
上のコードは分量が多いように見えますが、本当に重要な部分は次の1行。
assert_equal 1, ActionMailer::Base.deliveries.size
上のコードは、配信されたメッセージがきっかり1つであるかどうかを確認する。
配列deliveriesは変数なので、setupメソッドでこれを初期化しておかないと、並行して行われる他のテストでメールが配信されたときにエラーが発生してしまう。
ちなみにassignsメソッドを使うと対応するアクション内のインスタンス変数にアクセスできるようになる。
例えば、Usersコントローラのcreateアクションでは@userというインスタンス変数が定義されているが、テストでassigns(:user)と書くとこのインスタンス変数にアクセスできるようになる、といった具合。
最後に、コメントアウトしておいた行を、元に戻している。
テストができたので、ユーザー操作の一部をコントローラからモデルに移動するというささやかなリファクタリングを行う準備ができた。
ここでは特に、activateメソッドを作成してユーザーの有効化属性を更新し、send_activation_emailメソッドを作成して有効化メールを送信する。
class User < ApplicationRecord
.
.
.
# アカウントを有効にする
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
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
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
Userモデルでは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)
(userをselfに切り替えるという手もあるが、selfはモデル内では必須ではない。)
また、Userメイラー内の呼び出しでは、@userがselfに変更されている。
-UserMailer.account_activation(@user).deliver_now
+UserMailer.account_activation(self).deliver_now
どんなに簡単なリファクタリングであっても、この手の変更はつい忘れてしまう。
テストをきちんと書いておけば、この種の見落としを検出できる。
Userモデル内のactivateメソッドはupdate_attributeを2回呼び出しているが、これは各行で1回ずつデータベースへ問い合わせしていることになる。
そのため、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみる。
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token
before_save :downcase_email
before_create :create_activation_digest
.
.
.
# アカウントを有効にする
def activate
update_columns(activated: true, activated_at: Time.zone.now)
# update_attribute(:activated, true)
# update_attribute(:activated_at, Time.zone.now)
end
# 有効化用のメールを送信する
def send_activation_email
UserMailer.account_activation(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
現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できる。
しかし考えてみれば、有効でないユーザーは表示する意味がない。
そこで、この動作を変更してみる。
なお、ここで使っているActive Recordのwhereメソッドについては、後でもう少し詳しく説明する。
class UsersController < ApplicationController
.
.
.
def index
@users = User.where(activated: true).paginate(page: params[:page])
end
def show
@user = User.find(params[:id])
redirect_to root_url and return unless @user.activated?
end
.
.
.
end
本番環境でのメール送信
ここまでの実装で、development環境におけるアカウント有効化の流れは完成した。
次は、サンプルアプリケーションの設定を変更し、production環境で実際にメールを送信できるようにしてみる。
具体的には、まずは無料のサービスを利用してメール送信の設定を行い、続いてアプリケーションの設定とデプロイを行う。
本番環境からメール送信するために、「SendGrid」というHerokuアドオンを利用してアカウントを検証する(このアドオンを利用するためにはHerokuアカウントにクレジットカードを設定する必要があるが、アカウント検証では料金は発生しない)。
ここでは、「starter tier」というサービスを使うことにする。
これは、(執筆時点では)1日のメール数が最大400通までという制限があるが、無料で利用することができる。
アドオンをアプリケーションに追加するには、次のコマンドを実行する。
heroku addons:create sendgrid:starter
注: herokuコマンドのバージョンが古いとここで失敗するかもしれない。
その場合は、Heroku Toolbeltを使って最新版に更新するか、次の古い文法のコマンドを試してみる。
heroku addons:add sendgrid:starter
アプリケーションでSendGridアドオンを使うには、production環境のSMTPに情報を記入する必要がある。
また、本番Webサイトのアドレスをhost変数に定義する必要もある。
Rails.application.configure do
.
.
.
config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method = :smtp
host = '<your heroku app>.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
上のメール設定にはSendGridアカウントのuser_nameとpassword設定を記入する行もあるが、そこには記入せず、必ず環境変数「ENV」に設定するよう注意する。
本番運用するアプリケーションでは、暗号化されていないIDやパスワードのような重要なセキュリティ情報は「絶対に」ソースコードに直接書き込んではいけない。
そのような情報は環境変数に記述し、そこからアプリケーションに読み込む必要がある。
今回の場合、そうした変数はSendGridアドオンが自動的に設定してくれるが、環境変数を自分で設定しなければならないときもある。
Herokuの環境変数を表示したい場合は、次のコマンドを実行する。
heroku config:get SENDGRID_USERNAME
heroku config:get SENDGRID_PASSWORD
この時点で、Gitのトピックブランチをmasterにマージしておく。
rails test
git add -A
git commit -m "Add account activation"
git checkout master
git merge account-activation
続いてリモートリポジトリにプッシュし、Herokuにデプロイする。
rails test
git push
git push heroku
heroku run rails db:migrate
Herokuへのデプロイが完了したら、自分が管理しているメールアドレスを使って、production環境でユーザー登録を行ってみる。
実装した有効化メールが配信されるはず。
受信したメールに記されているメールをクリックすると、期待通りアカウントの有効化に成功するはず。