日別アーカイブ: 2021年11月30日

Rails-tutorialのまとめ11.3(アカウントの有効化 主に演習)

その11.2から続く

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])

これから実装する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$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"
>> user.send(:activation_digest)
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"
>> user.send("activation_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"
>> attribute = :activation
>> user.send("#{attribute}_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtR"

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

"#{attribute}_digest"

シンボルと文字列どちらを使った場合でも、上のコードは次のように文字列に変換されます。

"activation_digest"

sendメソッドの動作原理がわかったので、この仕組みを利用してauthenticated?メソッドを書き換えてみましょう。

抽象化されたauthenticated?メソッド red
app/models/user.rb
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
  .
  .
  .
red
rails test

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

 current_user内の抽象化したauthenticated?メソッド red
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 現在ログイン中のユーザーを返す (いる場合)
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(:remember, cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end
Userテスト内の抽象化したauthenticated?メソッド green
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?(:remember, '')
  end
end

上のような変更を加えると、テストは greenに変わります。

rails test

演習

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

created_at: nil, updated_at: nil, password_digest:
"$2a$10$D80rMhemJkoM8JmOU5IGN8YwsOHhBb...",
remember_digest: nil, admin: false, activation_digest: nil,
activated: false, activated_at: nil

2:抽象化したauthenticated?メソッドを使って、先ほどの
各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。

動作確認するだけ

11.3.2 editアクションで有効化

editアクションを書く準備ができました。
このアクションは、paramsハッシュで
渡されたメールアドレスに対応するユーザーを認証します。
ユーザーが有効であることを確認する中核は、次の部分になります。

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

既に有効になっているユーザーを誤って再度有効化しないために必要です

上の論理値に基いてユーザーを認証するには、ユーザーを認証してから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

def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

演習

1:コンソールから、11.2.4で生成したメールに含まれているURLを調べてみてください。URL内のどこに有効化トークンが含まれているでしょうか?

Hi testerer,

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

https://ap-northeast-1.amazonaws.com/account_activations/O0VRZve0xN9l27LNySAnVQ/edit?email=exxample%40gmal.com

----==_mimepart_5e8d6fcbafbc4_11b625d352085248
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 tt,</p>

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

<a href="https://ap-northeast-1.amazonaws.com/account_activations/xliAdIUvihhp2b1S3vD2CA/edit?email=rena%40gma.com">Activate</a>

</body>
</html>

O0VRZve0xN9l27LNySAnVQが有効化トークン

2:先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。
また、有効化ステータスがtrueになっていることを確認してみてください。

rails c で確認するだけ

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

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

class UsersSignupTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear
  end

  test "invalid signup information" do
    get signup_path
    assert_no_difference 'User.count' do
      post users_path, 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つであるかどうかを確認します。

assignsメソッドを使うと対応するアクション内のインスタンス変数にアクセスできるようになります。
例えば、Usersコントローラのcreateアクションでは@user
というインスタンス変数が定義されていますが、
テストでassigns(:user)と
書くとこのインスタンス変数にアクセスできるようになる、といった具合です。

green
rails test
activateメソッドを作成してユーザーの有効化属性を更新し、send_activation_emailメソッドを作成して有効化メールを送信します。
Userモデルにユーザー有効化メソッドを追加する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

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

演習

1:activateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。
テンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう (これでデータベースへの問い合わせが1回で済むようになります)。また、変更後にテストを実行し、 greenになることも確認してください。

user.rb

class User < ApplicationRecord
(中略)
  # アカウントを有効にする
  def activate
    update_columns(activated: true, activated_at: Time.zone.now)
  end
(後略)

2:現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、テンプレートを使って、この動作を変更してみましょう 

users_controller.rb

class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  
  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

(後略)

3:ここまでの演習課題で変更したコードをテストするために、/users と /users/:id の両方に対する統合テストを作成してみましょう。

update_columnsメソッドは、コールバックとバリデーションを実行せずにスキップしますので、コールバックやバリデーションをかける必要がある場合は注意が必要です。

update_columnsを使用するテンプレート
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email
  before_create :create_activation_digest
  .
  .
  .
  # アカウントを有効にする
  def activate
    update_columns(activated: FILL_IN, activated_at: FILL_IN)
  end

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

  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
有効なユーザーだけを表示するコードのテンプレートapp/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def index
    @users = User.where(activated: FILL_IN).paginate(page: params[:page])
  end

  def show
    @user = User.find(params[:id])
    redirect_to root_url and return unless FILL_IN
  end
  .
  .
  .
end

11.4 本番環境でのメール送信

本番環境からメール送信するために、「Mailgun」というHerokuアドオンを利用してアカウントを検証します (このアドオンを利用するためにはHerokuアカウントにクレジットカードを設定する必要がありますが、アカウント検証では料金は発生しません)。

Railsのproduction環境でMailgunを使う設定config/environments/production.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :smtp
  host = '<あなたのHerokuサブドメイン名>.herokuapp.com'
  config.action_mailer.default_url_options = { host: host }
  ActionMailer::Base.smtp_settings = {
    :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,
  }
  .
  .
  .
end
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
#受信メールの認証を行います
heroku addons:open mailgun

演習

1:実際に本番環境でユーザー登録をしてみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

動作確認する。

メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。
また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
ターミナルからheroku logsコマンドを実行してみましょう。

<a href="https://&lt;your heroku app&gt;.herokuapp.com/account_activations/uiXkg8zRNKlluX3pX7-vwg/edit?email=mochikichi%40live.jp">Activate</a>

11章のまとめ

1:アカウント有効化は Active Recordオブジェクトではないが、セッションの場合と同様に、リソースでモデル化できる

2:Railsは、メール送信で扱うAction Mailerのアクションとビューを生成することができる

3:Action MailerではテキストメールとHTMLメールの両方を利用できる

4:メイラーアクションで定義したインスタンス変数は、他のアクションやビューと同様、メイラーのビューから参照できる

5:アカウントを有効化させるために、生成したトークンを使って一意のURLを作る

6:より安全なアカウント有効化のために、ハッシュ化したトークン (ダイジェスト) を使う

7:メイラーのテストと統合テストは、どちらもUserメイラーの振舞いを確認するのに有用

8:SendGridを使うと、production環境からメールを送信できる

その12に続く