LoginSignup
0
0

More than 3 years have passed since last update.

Railsチュートリアル11章まとめ

Posted at

この章でやること

本当にそのメールアドレスの持ち主なのかどうかを確認できるようにする。
SMS認証的なやつ
(1)有効化トークンやダイジェストを関連付けておいた状態で、
(2)有効化トークンを含めたリンクをユーザーにメールで送信し、
(3)ユーザーがそのリンクをクリックすると有効化できるようにする

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

今回実装するアカウント有効化や次章でのパスワード再設定の仕組みはよく似た点が多いので、多くのアイデアを使い回すことができる
それぞれの仕組みの似ている点をまとめる

検索キー string digest authentication
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)

今時点意味分からなくても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行を追加する

config/routes.rb
  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にしておく

db/migrate/[timestamp]_add_activation_to_users.rb
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属性は既にデータベースのカラムとの関連付けができあがっているので、ユーザーが保存されるときに一緒に保存される。

実装のコードはこうなる
以前に実装したメールアドレスを小文字にするメソッドもメソッド参照に切り替えている点に注意

app/models/user.rb
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の組み込みヘルパーで、サーバーのタイムゾーンに応じたタイムスタンプを返す。

db/seeds.rb
# メインのサンプルユーザーを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
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 %>

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メソッドを改良してみてください。また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。

user.rb
  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メール用のテンプレートで以下のコードが自動で記載される(後で変更する)

app/views/user_mailer/account_activation.text.erb
UserMailer#account_activation

<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
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>
app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "from@example.com"
  layout 'mailer'
end
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

生成されたメイラーの動作を簡単に追ってみる
application_mailer.rbには、デフォルトのfromアドレスがある
さらにメールのフォーマットに対応するメイラーレイアウトも使われている

user_mailer.rbの各メソッドには宛先メールアドレスもある
Railsチュートリアルでは関係ないが、生成されるHTMLメイラーのレイアウトやテキストメイラーのレイアウトはapp/views/layoutsで確認可能。生成されたコードにはインスタンス変数@greetingも含まれてる

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

app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "noreply@example.com"#メアド変更
  layout 'mailer'
end
app/mailers/user_mailer.rb
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メソッドを使われている。

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) %>
#リンクをセット(名前付きルートに引数を渡してURL生成)
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) %>
#リンクをセット(名前付きルートに引数を渡して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環境の設定に手を加える必要がある

config/environments/development.rb
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メイラーのプレビューファイルの更新をする

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

定義したaccount_activationの引数には有効なUserオブジェクトを渡す必要があるため、上のコードはこのままでは動かない。
これを回避するために、user変数が開発用データベースの最初のユーザーになるように定義し
UserMailer.account_activationの引数として渡す

このときuser.activation_tokenの値にも代入している点に注目
メールのテンプレートでは、アカウント有効化のトークンが必要なので、代入は省略不可。
なお、activation_tokenは仮の属性でしかないので、データベースのユーザーはこの値を実際には持っていない。

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
#この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)

上のメソッドを使うと、テスト用のユーザーのメールアドレスをエスケープすることもできる
※@がなくてもテストできる

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
 #account_activation.html.erbでPタグが長いと失敗するので注意
    assert_match CGI.escape(user.email),  mail.body.encoded
  end
end

なお、このテストではまだ失敗する。

上記テストコードでは、fixtureユーザーに有効化トークンを追加している点に注目。
(user.activation_token = のところ)
追加しない場合は、空白になる。

なお、生成されたパスワード設定のテストも削除しているが、のちに戻す。

このテストをパスさせるには、テストファイル内のドメイン名を正しく設定する必要がある。

config/environments/test.rb
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に変更

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

createアクションの挙動を変えたので、テストが失敗する
失敗が発生するテストの行をひとまずコメントアウトする

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, 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?メソッド

app/models/user.rb
  # トークンがダイジェストと一致したら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つのままだから

これを解消するため、両者を更新して、新しい一般的なメソッドを使うようにする

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
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 に変わる

演習

コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか? また、各トークンに対応するダイジェストの値はどうなっているでしょうか?

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アクションで使う

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

ユーザーの有効化が使われるためには、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する必要がある。
つまりuser.activated?がtrueの場合にのみログインを許可し、そうでない場合はルートURLにリダイレクトしてwarningで警告を表示する

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

演習

コンソールから、11.2.4で生成したメールに含まれているURLを調べてみてください。URL内のどこに有効化トークンが含まれているでしょうか?
先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。また、有効化ステータスがtrueになっていることをコンソールから確認してみてください。

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

アカウント有効化の統合テストを追加していく

test/integration/users_signup_test.rb
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メソッドを作成して有効化メールを送信する

app/models/user.rb
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
app/controllers/users_controller.rb
  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
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

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 の両方に対する統合テストを作成してみましょう。

users.yml
non_activated:
 name: Non Activated
 email: non_activated@example.gov
 password_digest: <%= User.digest('password') %>
 activated: false
 activated_at: <%= Time.zone.now %>
users_controller_test.rb
 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に設定し、利用する

config/environments/production.rb
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エラー中)のためスキップ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0