0
0

More than 3 years have passed since last update.

rails-tutorial第11章

Last updated at Posted at 2020-06-07

アカウントの有効化

アクションメーラーの流れ

パスワードダイジェストと似ていて、平文をメールで送って、ユーザーに平文付きのurlをクリックしてもらう。getリクエストでそのurlにアクセスさせる。そのurlの中にある平文を参照する。その平文をハッシュ化したものとDBの中身が同じかどうかで判断する。

平文をどこに置くかの違い。頭の中か(パスワード)、ブラウザの中か(クッキー)、インボックスの中か(メール)。

アカウント有効化の流れ

1.ユーザーの初期状態はunactivated(有効化待ち)
2.signup時に有効化トークンとそのダイジェストを生成
3.DBにダイジェストを保存して、有効化トークンをsignupしたユーザーのメールアドレスに送る。
4ユーザーがメール内のリンクをクリックしたら、emailを使ってDB検索し、ダイジェストと比較する。
5.認証が通れば有効化、通らなければhomeを表示

AccountActivationsコントローラを作ろう

$ rails generate controller AccountActivations

ルーティングを設定しよう

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

editアクションだけが欲しい時は、only: [:edit]と指定するとeditアクションだけ使えるようになる

これにより以下のurlが出来上がる

edit_account_activation GET    /account_activations/:id/edit(.:format) account_activations#edit

つまり、メールに
/account_activation//edit のurlを乗せて、これをクリックすると、

先ほど作ったコントローラのeditアクションにとび、

さらにparams[:id]でトークンを取得することができる。

Usersモデルにカラムの追加

次に、activation_digest activated activated_atこの3つのカラムを追加しよう

$ rails generate migration add_activation_to_users  activation_digest:string activated:boolean activated_at:datetime

ここで気をつけなければいけないのは、booleanの型はnilチェックなどが面倒なので、デフォルト値を設定した方が良いということだ。
それを踏まえると、

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

コールバック

コントローラで設定したbeforeフィルターのモデルバージョンみたいなもの
メソッド名を指定して、そのメソッドをどこかで定義する

では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

これにより、インスタンスが作成される前にactivation_tokenとactivation_digestのカラムが作成され、データベースに保存される。

サンプルユーザーの生成

rails sをしてUsersページに表示されているユーザーは全てactivatedがtrueなので、

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

これで表示されているユーザーが全て有効化されているユーザーになった。

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

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

メイラーはモデルやコントローラと同様にrails gで作れる。

$ rails generate mailer UserMailer account_activation password_reset

実行したことで、今回必要となるaccount_activationメソッドと、第12章で必要となるpassword_resetメソッドが生成されました。

なお、アカウント有効化に使うテンプレートがtext版とhtml版の2つ生成される。

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

mail toはメールオブジェクトが戻るメソッド。

そのため、出来上がったメールオブジェクトはaccount_activationメソッドを呼び出した側に送られる。

コントローラと同じように、Userメイラーにも親クラスのapplicationメイラーが存在する。

app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: "noreply@example.com"
  layout 'mailer'
end

Userメイラーの中のメソッドを書き換える

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

トークンを渡すにもuserのメールアドレスがわからないとダメなので、

メソッドに引数を渡せるようにしてemailを参照できるようにする。

@user(インスタンス変数)にしているのはメールのviewで使いたいから。

また作りたいurlは以下のようなもの

http://www.example.com/account_activations/q5lt38hQDc_959PVoo6b7A/edit

これを作るには以下のようにする

edit_account_activation_url(@user.activation_token, email: @user.email)

名前付きルートの引数にトークンやemailをオプションで入れている。

今までは引数にインスタンス変数を渡していた。インスタンス変数を渡すとデフォルトでidが代入される仕組みになっている。

アカウントの有効化のメールの文面を変えよう

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

今回は、edit_account_activation_url

urlを使わないとダメらしい。urlにするとドメインも含めた完全なurlが表示されるらしい。

_pathと_urlを調べてみた

root_path => '/'
root_url  => 'http://www.example.com/'

new_path => '/new'
new_url => 'http://www.example.com/new'

_path

・相対パス
・redirect_to以外で使用する。
・link_toでよく使用されるイメージ

_url

・絶対パス
・redirect_toの時にセットで使用する。(HTTPの仕様上、リダイレクトのときに完全なURLが求められるので)

つまり、上の例だと、相対パスになってしまうから、完全なurlにならないよーって話。

送信メールのプレビューを確認しよう

まずは開発環境の設定を変える。

config/environments/development.rb
Rails.application.configure do
  .
  .
  .
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = 'example.com' # ここをコピペすると失敗します。自分の環境に合わせてください。
  config.action_mailer.default_url_options = { host: host, protocol: 'https' }
  .
  .
  .
end
config/environments/development.rb
# Don't care if the mailer can't send.
  config.action_mailer.raise_delivery_errors = true
  config.action_mailer.delivery_method = :test
  host = '05e957c8363f4c3ca01e26ea91b3c152.vfs.cloud9.us-east-2.amazonaws.com' # ここをコピペすると失敗します。自分の環境に合わせてください。
  config.action_mailer.default_url_options = { host: host, protocol: 'https' }


  config.action_mailer.perform_caching = false

cloud9だとこうなるが、果たしてうまくいくのか、、、

プレビューメソッドを完成させる

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

ちなみにUser.new_tokenメソッドはUserモデルに定義したクラスメソッド。
確かcookieを実装するときに定義した。

これでメールオブジェクトが帰ってくるので、それを確認できますよーってことらしい。

https://05e957c8363f4c3ca01e26ea91b3c152.vfs.cloud9.us-east-2.amazonaws.com/rails/mailers/user_mailer/account_activation
このurlでメールを確認することができた。

メールの文面を開いてurlをクリックすると、今のままだとeditアクションがないのでエラーになってしまう。

なので、次はaccount_activationコントローラのeditメソッドを実装していく必要がある。

送信メールのテスト

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

account_activationメソッドはメイラー生成時にuser.rbに定義された。
@userを渡すとメールを作ってくれる。

Userのcreateアクションを更新しよう

あとはユーザー登録を行う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

ここの注意点としては、
メソッドチェーンで帰ってきたメールオブジェクトをdeliver_nowで送信しているということ。

この状態は、signupするとメールが送られる状態。

ただ、まだAccountactivationのeditアクションを定義していないので、それをする必要がある

また、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

アカウントを有効化する

editアクションでアカウントを有効化する処理を書きたい。
引っ張ってこれる情報は、params[:email]でメールアドレス、params[:id]でトークンの平文。

ただ、authenticated?()メソッドはrememeber_token専用なので、現在認証を行うメソッドがないという問題点がある。

# トークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
  return false if remember_digest.nil?
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

上記のコードのrememberをactivationに変えられたらベストだけどどうすれば?

authenticated?メソッドの抽象化をしよう

つまりメソッド名を動的に決める。動的ディスパッチという

sendメソッドを使うと、

'foobar'.length

'foobar'.send("length")
が同じになる。

これを使って、

user.send("#{auth}_digest")として、
後からauthを代入する形にしてあげれば良い。

またauthに代入するのは文字列だけでなく、:activation
のようにシンボルを渡しても、文字列に変換されて実行される。

イメージは以下

>> user = User.first
>> user.activation_digest
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send(:activation_digest)
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send("activation_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> attribute = :activation
>> user.send("#{attribute}_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"

これを利用して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
  .
  .
  .
end

dryにしたが、テストは失敗してしまう。

このテストを通すために、

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

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

ここで見ていきたいのが、

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

これは一度有効化したユーザーを再度有効化しないようにするために、
userが存在して、有効化されてなく、params[:id]のハッシュ値とDBの値が一致した時だけ、アカウントを有効化する処理をするよーって話。

有効じゃないユーザーがログインできないようにする。

今のままだと、メールを送ってアカウントの有効化機能を実装したはいいが、有効化してない人でもログインリンクからログインをできてしまうという問題点がある。

なので、有効化してない人はログインさせるようにメソッドを変える必要がある。

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

つまり、userがあり、パスワードがあってて、さらに有効化されたユーザーだけがログインできるようにする。

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

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

ここは難しいので一旦無視

assignsメソッドについて

assignsメソッドは簡単にいうと、インスタンス変数にアクセスするためのメソッド。

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

つまり、呼ばれた瞬間のインスタンス変数@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

2つ目のメソッドに入っているselfはsend_activation_emailメソッドの呼び出し元が入る。
そして、

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

これでテストが通る。

本番環境でのメール送信

$ heroku addons:create sendgrid:starter

クレカ登録して上記のコマンドを入れるとsendgritの無料のメールサービスが使えるようになる。

そして設定する

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 = {
    :address        => 'smtp.sendgrid.net',
    :port           => '587',
    :authentication => :plain,
    :user_name      => ENV['SENDGRID_USERNAME'],
    :password       => ENV['SENDGRID_PASSWORD'],
    :domain         => 'heroku.com',
    :enable_starttls_auto => true
  }
  .
  .
  .
end

host = '.herokuapp.com'
ここを自分のherokuappのurlに変える。

で、メールが送られるまでの設定は動画見た方が良い。

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