この章でやること
本当にそのメールアドレスの持ち主なのかどうかを確認できるようにする。
SMS認証的なやつ
(1)有効化トークンやダイジェストを関連付けておいた状態で、
(2)有効化トークンを含めたリンクをユーザーにメールで送信し、
(3)ユーザーがそのリンクをクリックすると有効化できるようにする
アカウントを有効化する基本的な手順は
1. ユーザーの初期状態は「有効化されていない」(unactivated)にしておく。
2. ユーザー登録が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する。
3. 有効化ダイジェストはデータベースに保存しておき、有効化トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく
4. ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた有効化ダイジェストと比較することでトークンを認証する。
5. ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated)に変更する。
今回実装するアカウント有効化や次章でのパスワード再設定の仕組みはよく似た点が多いので、多くのアイデアを使い回すことができる
それぞれの仕組みの似ている点をまとめる
検索キー | 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) |
今時点意味分からなくてもOK
11.1 AccountActivationsリソース
セッション機能を使って、アカウントの有効化という作業を「リソース」としてモデル化する
なお、アカウント有効化もリソースとして扱うが、少し使い方が異なる。
例えば、有効化用のメールのリンクにアクセスして有効化のステータスを変更する場合、RESTのルールに従うと通常はPATCHリクエストとupdateアクションになる。しかし、有効化リンクはメールでユーザーに送られるので、ユーザーがこのリンクをクリックすれば、それはブラウザで普通にクリックしたときと同じであり、その場合ブラウザから発行されるのは(updateアクションで使うPATCHリクエストではなく)GETリクエストになってしまう。
このため、ユーザーからのGETリクエストを受けるために、(本来であればupdateのところを)editアクションに変更して使ってく
作業始める前にGitで新機能用のトピックブランチを作成
$ git checkout -b account-activation
11.1.1 AccountActivationsコントローラ
AccountActivationsリソースを作るために、まずはAccountActivationsコントローラを生成
$ rails generate controller AccountActivations
後ほど詳しく説明があるが、有効化のメールには次のURLを含めることで認証する
edit_account_activation_url(activation_token, ...)
つまり、editアクションへの名前付きルートが必要になるので、
ルーティングにアカウント有効化用のresources行を追加する
resources :users
resources :account_activations, only: [:edit] #リソースを追加
HTTPリクエスト | URL | Action | 名前付きルート |
---|---|---|---|
GET | /account_activation/トークン/edit | edit | edit_account_activation_url(token) |
先にアカウント有効化用のデータモデルとメイラーを作成し、終わったらeditアクションを編集する
演習
現時点でテストスイートを実行すると green になることを確認してみましょう。
→パスする
表 11.2の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。ヒント: 私達はこれからメールで名前付きルートを使います。
→pathは相対パス、urlは絶対パス メールからリンクしてもらうので、絶対パスを使う
11.1.2 AccountActivationのデータモデル
有効化のメールには一意の有効化トークンが必要になる。
そのため、「送信メールとデータベースに同じ文字列を置いておき、比較して認証する」というのが思いつくが、この方法では万が一データベースの内容が漏れたとき、危ない
なので仮想的な属性を使ってハッシュ化した文字列をデータベースに保存するようにして比べる
仮想的な属性は
user.activation_token
で
このようなメソッドでユーザーを認証していく
user.authenticated?(:activation, token)
(まだ使えない)
activated属性をusersテーブルに追加して論理値を取れるようにもする。↓
if user.activated? ...
本チュートリアルで使うことはないが、ユーザーを有効にしたときの日時も念のために記録
次のマイグレーションをコマンドラインで実行してのデータモデルを追加
$ rails generate migration add_activation_to_users \ activation_digest:string activated:boolean activated_at:datetime
次に、activated属性のデフォルトの論理値をfalseにしておく
class AddActivationToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :activation_digest, :string
add_column :users, :activated, :boolean, default: false
add_column :users, :activated_at, :datetime
end
end
$ rails db:migrate
Activationトークンのコールバック
createでユーザーを新規登録する前に、有効化トークンや有効化ダイジェストは作成し、メールを送る必要がある
前もメールアドレスをデータベースに保存する前に、メールアドレスを全部小文字に変換するため、before_save
コールバックにdowncaseメソッドをバインドした。
before_save
コールバックを用意しておくと、オブジェクトが1保存される直前、2オブジェクトの作成時や3更新時にそのコールバックが呼び出されるようになる。
しかし今回は、オブジェクトが作成されたときだけコールバックを呼び出し、それ以外のときには呼び出したくない
そこでbefore_create
コールバックが必要となる
before_create :create_activation_digest
上のコードはメソッド参照と呼ばれ、こうするとRailsはcreate_activation_digestというメソッドを探し、ユーザーを作成する前に実行するようになる
create_activation_digestメソッド
自体はUserモデル内でしか使わないので、外部に公開する必要はない
なので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>
privete下のメソッドは呼び出せなくなる
今回のbefore_createコールバックを使う目的は、トークンとそれに対応するダイジェストを割り当てるため
self.activation_token = User.new_token #User.new_tokenはランダムな文字列を作るメソッドとして定義ずみ
self.activation_digest = User.digest(activation_token)
このコードでは、記憶トークンや記憶ダイジェストのために作ったメソッドを使いまわしている
9章で作成した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属性は既にデータベースのカラムとの関連付けができあがっているので、ユーザーが保存されるときに一緒に保存される。
実装のコードはこうなる
以前に実装したメールアドレスを小文字にするメソッドもメソッド参照に切り替えている点に注意
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token #追加
before_save :downcase_email #メソッド参照
before_create :create_activation_digest
.
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の組み込みヘルパーで、サーバーのタイムゾーンに応じたタイムスタンプを返す。
# メインのサンプルユーザーを1人作成する
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
演習
本項での変更を加えた後、テストスイートが green のままになっていることを確認してみましょう。
コンソールからUserクラスのインスタンスを生成し、そのオブジェクトからcreate_activation_digestメソッドを呼び出そうとすると(Privateメソッドなので)NoMethodErrorが発生することを確認してみましょう。また、そのUserオブジェクトからダイジェストの値も確認してみましょう。
>> user = User.new
>> user.create_activation_digest
NoMethodError: private method `create_activation_digest' called for #<User:0x000000045483b0>
Did you mean? restore_activation_digest!
from (irb):2
>> user.digest
NoMethodError: undefined method `digest' for #<User:0x000000045483b0>
from (irb):3
>> user.activation_digest
=> nil
アクセスできず、nilになる
リスト 6.35で、メールアドレスの小文字化にはemail.downcase!という(代入せずに済む)メソッドがあることを知りました。このメソッドを使って、リスト 11.3のdowncase_emailメソッドを改良してみてください。また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。
def downcase_email
email.downcase! #破壊的メソッドを使えばOK
end
11.2 アカウント有効化のメール送信
データのモデル化が終わった
- 今度は有効化メールの送信に必要なコードを追加していく。
- Action Mailerライブラリを使ってUserのメイラーを追加する
- メイラーはUsersコントローラのcreateアクションで有効化リンクをメール送信するために使う
- メイラーの構成はコントローラのアクションとよく似ており、メールのテンプレートをビューと同じ要領で定義できる
- メールのテンプレートの中に有効化トークンとメールアドレス(= 有効にするアカウントのアドレス)のリンクを含め、使っていく
11.2.1 送信メールのテンプレート
メイラーは、モデルやコントローラと同様に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>
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
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
生成されたメイラーの動作を簡単に追ってみる
application_mailer.rb
には、デフォルトのfromアドレスがある
さらにメールのフォーマットに対応するメイラーレイアウトも使われている
user_mailer.rb
の各メソッドには宛先メールアドレスもある
Railsチュートリアルでは関係ないが、生成されるHTMLメイラーのレイアウトやテキストメイラーのレイアウトはapp/views/layoutsで確認可能。生成されたコードにはインスタンス変数@greetingも含まれてる
では最初に、生成されたテンプレートをカスタマイズして、実際に有効化メールで使えるようにしていく。
次に、ユーザーを含むインスタンス変数を作成してビューで使えるようにし、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 = user
mail to: user.email, subject: "Account activation"#タイトル付きのメールを送る
end
def password_reset#今は関係ない
@greeting = "Hi"
mail to: "to@example.org"
end
end
このテストは現時点では red (account_activationに引数を与えたため)
これからレイアウトするテンプレートビューは、通常のビューと同様ERBで自由にカスタマイズ可能
ここでは挨拶文にユーザー名を含め、カスタムの有効化リンクを追加していく
この後、Railsサーバーでユーザーをメールアドレスで検索して有効化トークンを認証できるようにしたいので、リンクにはメールアドレスとトークンを両方含めておく必要がある
AccountActivationsリソースで有効化をモデル化したので、トークン自体は定義した名前付きルートの引数で使われる
edit_account_activation_url(@user.activation_token, ...)
例えば
edit_user_url(user)
上のメソッドは、絶対パスのuser_urlでurlを生成し、引数のユーザーの編集ページにアクセスする
https://www.example.com/account_activations/q5lt38hQDc_959PVoo6b7A/edit
上の「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では扱えない文字を扱えるようにするために変換されている
※@はURLで使えない
Railsでクエリパラメータを設定するには、名前付きルートに対して次のようなハッシュを追加
edit_account_activation_url(@user.activation_token, email: @user.email)
このようにして名前付きルートでクエリパラメータを定義すると、Railsが特殊な文字を自動的にエスケープしてくれる
取り出し時もコントローラでparams[:email]からメールアドレスを取り出すときには、自動的にエスケープを解除してくれる
ここまでできれば、user_mailerで定義した@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) %>
#リンクをセット(名前付きルートに引数を渡してURL生成)
<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) %>
#リンクをセット(名前付きルートに引数を渡してURL生成)
演習
コンソールを開き、CGIモジュールのescapeメソッド(リスト 11.15)でメールアドレスの文字列をエスケープできることを確認してみましょう。このメソッドで"Don't panic!"をエスケープすると、どんな結果になりますか?
irb(main):005:0> CGI.escape("Don't panic!")
=> "Don%27t+panic%21"
11.2.2 送信メールのプレビュー
メールプレビューを使って、今ほど定義したテンプレートの実際の表示を確認する
Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができる
利用するには、アプリケーションのdevelopment環境の設定に手を加える必要がある
Rails.application.configure do
.
.
.
config.action_mailer.raise_delivery_errors = false
host = 'example.com' # ここをコピペすると失敗します。自分の環境のホストに変えてください。
# クラウドIDEの場合は以下をお使いください
config.action_mailer.default_url_options = { host: host, protocol: 'https' }
# localhostで開発している場合は以下をお使いください
# config.action_mailer.default_url_options = { host: host, protocol: 'http' }
.
.
.
end
ホスト名 'example.com' の部分は、各自のdevelopment環境に合わせて変更
例えば、クラウドIDEでは,自分のブラウザのURLをhostへ代入する
host = '<hex string>.vfs.cloud9.us-east-2.amazonaws.com' # クラウドIDE
config.action_mailer.default_url_options = { host: host, protocol: 'https' }
ローカル環境で開発している場合は、次のように変える
host = 'localhost:3000' # ローカル環境
config.action_mailer.default_url_options = { host: host, protocol: 'http' }
httpsが暗号化なしのhttpに変わっていることに注意
developmentサーバーを再起動して,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
の値にも代入している点に注目
メールのテンプレートでは、アカウント有効化のトークンが必要なので、代入は省略不可。
なお、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
#このURLに接続すると、メールのプレビューがみれる
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
演習
Railsのプレビュー機能を使って、ブラウザから先ほどのメールを表示してみてください。「Date」の欄にはどんな内容が表示されているでしょうか?
→今日の日付が表示されている
11.2.3 送信メールのテスト
最後に、このメールプレビューのテストも作成
自動生成されたTest
```
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#解説 Hiがメール本文にあるか
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 #fooがある
assert_match 'baz', 'foobar' # false #bazない
assert_match /\w+/, 'foobar' # true #文字ある
assert_match /\w+/, '$#!*+@' # false #文字ない
これから書くテストではassert_match
メソッドを使って名前、有効化トークン、エスケープ済みメールアドレスがメール本文に含まれているかどうかをテストする
最後にもう1つ小技を紹介
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
#account_activation.html.erbでPタグが長いと失敗するので注意
assert_match CGI.escape(user.email), mail.body.encoded
end
end
なお、このテストではまだ失敗する。
上記テストコードでは、fixtureユーザーに有効化トークンを追加している点に注目。
(user.activation_token = のところ)
追加しない場合は、空白になる。
なお、生成されたパスワード設定のテストも削除しているが、のちに戻す。
このテストをパスさせるには、テストファイル内のドメイン名を正しく設定する必要がある。
Rails.application.configure do
.
config.action_mailer.delivery_method = :test
config.action_mailer.default_url_options = { host: 'example.com' }
#ここは変に変えなくてOK
.
end
これでテストはパスする
演習
この時点で、テストスイートが green になっていることを確認してみましょう。
→パスしなかったので、テストのコメント注意記載
assert_match user.activation_token, mail.body.encoded
#account_activation.html.erbでPタグが長いと失敗するので注意
日本語で「こちらのリンクをクリックしてください」とか長いとエラーになる
リスト 11.20で使ったCGI.escapeの部分を削除すると、テストが red に変わることを確認してみましょう。
→@マークが邪魔してエラーになる
11.2.4 ユーザーの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
createアクションの挙動を変えたので、テストが失敗する
失敗が発生するテストの行をひとまずコメントアウトする
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
この状態で実際に新規ユーザーとして登録してみると、リダイレクトされてメールが生成される。
ただし、実際にメールが生成されるわけではない
サーバーに以下のようなログが生成される
UserMailer#account_activation: processed outbound mail in 5.1ms
Delivered mail 5d606e97b7a44_28872b106582df988776a@ip-172-31-25-202.mail (3.2ms)
Date: Fri, 23 Aug 2019 22:54:15 +0000
From: noreply@example.com
To: michael@michaelhartl.com
Message-ID: <5d606e97b7a44_28872b106582df988776a@ip-172-31-25-202.mail>
Subject: Account activation
Mime-Version: 1.0
Content-Type: multipart/alternative;
boundary="--==_mimepart_5d606e97b6f16_28872b106582df98876dd";
charset=UTF-8
Content-Transfer-Encoding: 7bit
----==_mimepart_5d606e97b6f16_28872b106582df98876dd
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:
https://0ebe1dc6d40e4a4bb06e0ca7fe138127.vfs.cloud9.us-east-2.
amazonaws.com/account_activations/zdqs6sF7BMiDfXBaC7-6vA/
edit?email=michael%40michaelhartl.com
----==_mimepart_5d606e97b6f16_28872b106582df98876dd
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<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="https://0ebe1dc6d40e4a4bb06e0ca7fe138127.vfs.cloud9.us-east-2.
amazonaws.com/account_activations/zdqs6sF7BMiDfXBaC7-6vA/
edit?email=michael%40michaelhartl.com">Activate</a>
</body>
</html>
----==_mimepart_5d606e97b6f16_28872b106582df98876dd--
演習
新しいユーザーを登録したとき、リダイレクト先が適切なURLに変わったことを確認してみましょう。その後、Railsサーバーのログから送信メールの内容を確認してみてください。有効化トークンの値はどうなっていますか?
コンソールを開き、データベース上にユーザーが作成されたことを確認してみましょう。また、このユーザーはデータベース上にはいますが、有効化のステータスがfalseのままになっていることを確認してください。
>> user = User.find(101)
>> user.activated?
=> false
11.3 アカウントを有効化する
メールが生成できたら、今度はAccountActivationsコントローラのeditアクションを書いていく
アクションへのテストを書き、しっかりとテストできていることが確認できたら、AccountActivationsコントローラからUserモデルにコードを移していく作業(リファクタリング)にも取り掛かっていく
11.3.1 authenticated?メソッドの抽象化
有効化トークンとメールはそれぞれ
params[:id]
params[:email]
で参照できる
以下のコードで
パスワードのモデルと記憶トークンで学んだことを元に、次のようなコードでユーザーを検索して認証することにする。
user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])
ただし、このメソッドは記憶トークン用なので今は正常に動作しない
# トークンがダイジェストと一致したら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?メソッドでは、受け取ったパラメータに応じて呼び出すメソッドを切り替える手法を使う
この手法を「メタプログラミング」と呼ぶ
メタプログラミングを一言で言うと「プログラムでプログラムを作成する」こと
ここで重要なのは、sendメソッドの強力な機能
このメソッドは、渡されたオブジェクトに「メッセージを送る」ことによって、呼び出すメソッドを動的に決めることができる
例
$ rails console
>> a = [1, 2, 3]#aという配列を定義
>> a.length#aの配列の数
=> 3
>> a.send(:length) #aオブジェクトにsend(:length(シンボル))を送る
=> 3 #a.lengthと等価に
>> a.send("length")#aオブジェクトにsend("length"(文字列))を送る
=> 3 #a.lengthと等価に
このときsendを通して渡したシンボル:lengthや文字列"length"は、いずれもlengthメソッドと同じ結果になった。
つまり、どちらもオブジェクトにlengthメソッドを渡しているため、等価。
もう1つ例
>> user = User.first #userに最初のUserインスタンスを代入
>> user.activation_digest #userのactivation_digestカラムの値
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send(:activation_digest)#userに:activation_digestシンボルを送信
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"#同じ結果
>> user.send("activation_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> attribute = :activation
>> user.send("#{attribute}_digest") #文字列の式展開で[変数_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?メソッド
# トークンがダイジェストと一致したらtrueを返す
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
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
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 に変わる
演習
コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか? また、各トークンに対応するダイジェストの値はどうなっているでしょうか?
irb(main):002:0> user=User.create(name:"kiyoma",email:"aaa@com.com",password:"123456",
=> #<User id: 101, name: "kiyoma", email: "aaa@com.com", created_at: "2021-03-01 ...
irb(main):004:0> user.remember_token
=> nil
irb(main):005:0> user.activation_token
=> "5daKKPOcrSWPBx5Dv_tY7Q"
irb(main):006:0> user.remember_digest
=> nil
irb(main):007:0> user.activation_digest
=> "$2a$12$vMNDNH2/c4FhHbxzit/7MOrSa4fvvSKvkUlPWpx/fDe1Cyny571Tq"
リスト 11.26で抽象化したauthenticated?メソッドを使って、先ほどの各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。
irb(main):008:0> user.remember_token = User.new_token
=> "p0dj7V57U5ukEawso3Kk1g"
irb(main):009:0> user.update_attribute(:remember_digest, User.digest(user.remember_tok
en))
=> true
irb(main):010:0> user.authenticated?(:remember,user.remember_token)
=> true
11.3.2 editアクションで有効化
authenticated?の準備ができたので、やっとeditアクションを書く準備ができた。
editアクションは、paramsハッシュで渡されたメールアドレスに対応するユーザーを認証
ユーザーが有効であることを確認する中核は、次の部分
if user && !user.activated? && user.authenticated?(:activation, params[:id])
!user.activated?という記述に注目。このコードは、既に有効になっているユーザーを誤って再度有効化しないために必要。
正当であろうとなかろうと、有効化が行われるとユーザーはログイン状態になる。
もしこのコードがなければ、攻撃者がユーザーの有効化リンクを後から盗みだしてクリックするだけで、本当のユーザーとしてログインできてしまう
上の論理値に基いてユーザーを認証するには、ユーザーを認証してからactivated_atタイムスタンプを更新する必要がある
user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)
上のコードをeditアクションで使う
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
ユーザーの有効化が使われるためには、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する必要がある。
つまり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
演習
コンソールから、11.2.4で生成したメールに含まれているURLを調べてみてください。URL内のどこに有効化トークンが含まれているでしょうか?
先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。また、有効化ステータスがtrueになっていることをコンソールから確認してみてください。
11.3.3 有効化のテストとリファクタリング
アカウント有効化の統合テストを追加していく
require 'test_helper'
class UsersSignupTest < ActionDispatch::IntegrationTest
def setup
ActionMailer::Base.deliveries.clear#deliveriesを初期化 解説
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
リスト 11.33のコードは分量が多いが、本当に重要な部分は次の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
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
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.rb
では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はモデル内では必須ではない)
テストもパスするのでリファクタリングOK
演習
リスト 11.35にあるactivateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 11.39に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう。これでデータベースへの問い合わせが1回で済むようになります(注意!update_columnsは、モデルのコールバックやバリデーションが実行されない点がupdate_attributeと異なります)。また、変更後にテストを実行し、 green になることも確認してください。
# アカウントを有効にする
def activate
update_columns(activated: true, activated_at: Time.zone.now)
end
現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、リスト 11.40のテンプレートを使って、この動作を変更してみましょう9 。なお、ここで使っているActive Recordのwhereメソッドについては、13.3.3でもう少し詳しく説明します。
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
ここまでの演習課題で変更したコードをテストするために、/users と /users/:id の両方に対する統合テストを作成してみましょう。
non_activated:
name: Non Activated
email: non_activated@example.gov
password_digest: <%= User.digest('password') %>
activated: false
activated_at: <%= Time.zone.now %>
test "should not allow the not activated attribute" do
log_in_as (@non_activated_user) # 非有効化ユーザーでログイン
assert_not @non_activated_user.activated? # 有効化でないことを検証
get users_path # /usersを取得
assert_select "a[href=?]", user_path(@non_activated_user), count: 0 # 非有効化ユーザーが表示されていないことを確認
get user_path(@non_activated_user) # 非有効化ユーザーidのページを取得
assert_redirected_to root_url # ルートurlにリダイレクトされればtrue
end
11.4 本番環境でのメール送信
ここまでの実装で、development環境におけるアカウント有効化の流れは完成した。次は、本番環境で実際にメールを送信できるようにしてみる
まずは無料のサービスを利用してメール送信の設定を行い、続いてアプリケーションの設定とデプロイを行う
本番環境からメール送信するために、「Mailgun」というHerokuアドオンを利用してアカウントを検証
本チュートリアルでは、「starter」というプランを使う
アプリケーションでMailgunアドオンを使うには、production環境のSMTPに情報を記入する必要がある
を自分のHerokuのURLに設定し、利用する
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 = {
:port => ENV['MAILGUN_SMTP_PORT'],
:address => ENV['MAILGUN_SMTP_SERVER'],
:user_name => ENV['MAILGUN_SMTP_LOGIN'],
:password => ENV['MAILGUN_SMTP_PASSWORD'],
:domain => host,
:authentication => :plain,
}
この時点で、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
MailgunのHerokuアドオンを追加するために、次のコマンドを実行
$ heroku addons:create mailgun:starter
注: herokuコマンドのバージョンが古いとここで失敗するかも。その場合は、Heroku Toolbeltを使って最新版に更新するか、次の古い文法のコマンドを試す
$ heroku addons:add mailgun:starter
Herokuの環境変数を表示したい場合は、次のコマンドを実行
$ heroku config:get MAILGUN_SMTP_LOGIN
$ heroku config:get MAILGUN_SMTP_PASSWORD
メール設定にはMailgunアカウントのuser_nameとpassword設定を記入する行もあるが、そこには記入せず、必ず環境変数「ENV」に設定するよう十分注意
本番運用するアプリケーションでは、暗号化されていないIDやパスワードのような重要なセキュリティ情報は「絶対に」ソースコードに直接書き込まない。そのような情報は環境変数に記述し、そこからアプリケーションに読み込む。
今回の場合、そうした変数はMailgunアドオンが自動的に設定してくれる
最後に、受信メールの認証を行う
以下のコマンドを打つと、Mailgun ダッシュボードのURLが表示されるのでブラウザで開く
$ heroku addons:open mailgun
MailGun公式ドキュメントに従い、受信するメールアドレスを認証していく。
画面左側の「Sending」→「Domains」のリストにある「sandbox」で始まるサンドボックスドメインを選択。
画面右側の「Authorized Recipients」から受信メールアドレスを認証し、本番環境でのメール送信準備は完了。
HerokuへのデプロイとMailgunのアドオンが完了したら、先ほど受信認証したメールアドレスを使って、production(本番)環境でユーザー登録を行ってみる。
受信したメールに記されているメールをクリックすると、期待通りアカウントの有効化に成功するはず
演習
実際に本番環境でユーザー登録をしてみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?
メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。ヒント: ターミナルからheroku logsコマンドを実行してみましょう。
herokuに接続できないため、(jqueryエラー中)のためスキップ