学習日記

Ruby on Rails勉強してます

今日やったこと11

第11章 アカウントの有効化

アカウントを有効化するステップを新規登録の途中に差し込むことで、本当にそのメールアドレスの持ち主なのかどうかを確認できるようにする。

アカウントを有効化する手順

1.ユーザーの初期状態は「有効化されていない」(unactivated) にしておく。

2.ユーザー登録が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する。

3.有効化ダイジェストはデータベースに保存しておき、有効化トークンはメールアドレスと一緒に、ユーザーに送信する有効化用メールのリンクに仕込んでおく。

4.ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、データベース内に保存しておいた有効化ダイジェストと比較することでトークンを認証する。

5.ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated) に変更する。

11.1 AccountActivationsリソース

新機能用のトピックブランチを作成。

$ git checkout -b account-activation

まずはAccountActivationsコントローラを生成。

$ rails generate controller AccountActivations

ルーティングにアカウント有効化用のresources行を追加。

config/routes.rb

 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

Userモデルに3つの属性を追加する。

$ 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[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
$ rails db:migrate

Userモデルにアカウント有効化のコードを追加する。

app/models/user.rb

 class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email

  # オブジェクトが作成されたときだけコールバックを呼び出す。
  before_create :create_activation_digest

  validates :name,  presence: true, length: { maximum: 50 }
  .
  .
  .
  private

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

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

サンプルデータとfixtureを更新し、テスト時のサンプルとユーザーを事前に有効化しておく。

db/seeds.rb

 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin:     true,
             activated: true,
             activated_at: Time.zone.now)

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
              email: email,
              password:              password,
              password_confirmation: password,
              activated: true,
              activated_at: Time.zone.now)
end
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


11.2 アカウント有効化のメール送信

Action Mailerライブラリを使ってUserのメイラーを追加する。
メイラーはUsersコントローラのcreateアクションで有効化リンクをメール送信するために使う。
メールのテンプレートの中に有効化トークンとメールアドレス (= 有効にするアカウントのアドレス) のリンクを含めて、ビューと同じ要領で定義できる。

rails generateでメイラーを生成。

$ rails generate mailer UserMailer account_activation password_reset

生成されたメイラーのテンプレートを使えるように変更する。

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
    mail to: user.email, subject: "Account activation"
  end

  def password_reset
    @greeting = "Hi"

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

生成されたビューのテンプレートの2つを変更。

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) %>
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) %>
演習 11.2.1.1
>> CGI.escape("Don't panic!")
=> "Don%27t+panic%21"

送信メールのプレビュー

Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができる。これを利用するために、アプリケーションのdevelopment環境の設定に手を加える。

config/environments/development.rb

 Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = 'localhost:3000' #  自分の環境に合わせて変更する。
  config.action_mailer.default_url_options = { host: host, protocol: 'https' }
  .
  .
  .
end

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
    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でアカウント有効化メールの内容を確認。

http://localhost:3000/rails/mailers/user_mailer/account_activation
http://localhost:3000/rails/mailers/user_mailer/account_activation.txt

Railsによる自動生成された送信メールのテストを変更。

test/mailers/user_mailer_test.rb

 require 'test_helper'

class UserMailerTest < ActionMailer::TestCase

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

テストファイル内のドメイン名を正しく設定する。

config/environments/test.rb

Rails.application.configure do
  .
  .
  .
  config.action_mailer.delivery_method = :test
  config.action_mailer.default_url_options = { host: 'example.com' }
  .
  .
  .
end


ユーザーのcreateアクションを更新する。

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


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

authenticated?メソッドを抽象化する。

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
  .
  .
  .

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

ユーザーの有効化を有効的につかい、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する。

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

アカウント有効化の統合テストを追加する。

test/integration/users_signup_test.rb

require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest

  def setup
    ActionMailer::Base.deliveries.clear # deliveriesは変数なので、setupメソッドでこれを初期化
  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) #assignsメソッドを使うと対応するアクション内のインスタンス変数にアクセスできる
    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

リファクタリングのため、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

  private
    .
    .
    .
end

作成したメソッドを各アクションで使用する。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      @user.send_activation_email # 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 # activateメソッド
      log_in user
      flash[:success] = "Account activated!"
      redirect_to user
    else
      flash[:danger] = "Invalid activation link"
      redirect_to root_url
    end
  end
end