プログラミング備忘録

プログラミングの学習状況をメモしています

57,58日目

今日の学習

Ruby on Rails

Railsチュートリアル 第11章

railstutorial.jp

この章では、アカウントを有効化する方法を学んでいく。

  1. 有効化トークンやダイジェストを関連づける
  2. 有効化トークンを含めたリンクをユーザーにメールで送信
  3. ユーザーが送信されたリンクをクリックすることで有効化できるようにする

第12章では、これと似た仕組みを利用してパスワードを再設定できる仕組みを実装する。

まずは、アカウントの有効化ができるよう、Railsチュートリアルの手順に則って実装していくことにする。

AccountActivationsリソース

セッション機能を使い、アカウントの有効化作業をリソースとしてモデル化している。

有効化では、RESTのルール通りだとPATCHリクエストとupdateアクションを利用するべきだが、有効化リンクはメールでユーザーに送信される。

そのため、適切なリクエストはGETであるため、本来ならばupdateアクションだがeditアクションとして実装していく。

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

有効化するためのリンク用に、resourceを利用して名前つきルートを設定する。

resources :account_activations, only: [:edit]

次に、3つのデータモデルを追加する。 rails g migration add_activation_to_users \ と入力し、以下のデータモデルを設定。

  • activation_digest:string
  • activated:boolean
  • activated_at:datetime

前回設定したadmin属性のように、activated属性のデフォルト論理値をfalseにする。

Activationトークンのコールバック

メールアドレスを保存する前に、メールアドレスを全て小文字に変換したときと同じように、ユーザーオブジェクトが作成される前に有効化トークンや有効化ダイジェストが作成される必要がある。

メールアドレスのときは、before_saveコールバックにdowncaseメソッドをバインドした。

before_saveの場合はオブジェクトが保存される直前に働くが、今回はオブジェクトが作成されたときのみコールバックを行って欲しい。

そのため、適切なコールバックはbefore_createになる。以下のように定義する。

before_create :create_activation_digest

これはメソッド参照と呼ばれるもので、このように定義することでRailscreate_activation_digestというメソッドをユーザー作成前に実行してくれるようになる。

このメソッドは有効化トークンとダイジェストを作成し、代入するメソッドで、Userモデルでしか使わないためprivateメソッドとして作成する。

app/models/user.rb

attr_accessor :activation_token

private
  def downcase_email
    email.downcase!
  end

  def create_activation_digest
    self.activation_token = User.new_token
    self.activation_digest = User.digest(activation_token)
  end
アカウント有効化のメール送信

アカウント有効化メールの送信に必要なコードを追加する。このメソッドではAction Mailerライブラリを使ってUserの<u>メイラー</u>メーラーのこと)を追加する。

こちらの記事も併せて読んだ。

Action Mailer でメール送信機能をつくる - Qiita

メイラーは、モデルやコントローラと同様にrails gで生成する。

今回は、account_activationメソッドに加え、パスワードをリセットするためのpassword_resetメソッドも生成する。

rails g mailer UserMailer account_activation password_reset

メイラーを生成すると、ビューのテンプレートが2つずつ生成される。

  • テキストメール用のテンプレート
  • HTMLメール用のテンプレート

また、以下のファイルも追加される。

  • app/mailers/application_mailer.rb
    デフォルトのfromアドレス、メールのフォーマットに対応するレイアウト。
  • app/mailers/user_mailer.rb
    生成時に指定したメソッドが記載されている。宛先も含まれている。
送信メールのテンプレート

まずはfromアドレスのデフォルト値を有効なものに更新する。

user_mailer.rbでは、ユーザーを含むインスタンス変数@userを作成し、ビューで使えるようにして、user.emailにメール送信する。

mail to: user.email

このmailに対して、メールの件名になるsubjectキーを引数として渡す。

mail to: user.email, subject: "Account activation"
def account_activation(user)
  @user = user
  mail to: user.email, subject: "account activation"
end

さらに、有効化リンクをメールに追加する。Railsサーバーでユーザーをメールアドレスで検索し、有効化トークンを認証できるようにしたいので、このリンクにはメールアドレスとトークンの両方を含める必要がある。

accountActivationsリソースで有効化をモデル化したため、トークン自体はroute.rbで定義した名前付きルートの引数で使われる。

アカウント有効化リンクのベースは以下のようになる。

https://www.example.com/account_activations/new_tokenメソッドで生成されたランダム文字列/edit

new_tokenメソッドで生成された文字列は、/users/1/edit1のような、ユーザーIDと同じ役割を持っている。

この部分のトークンは、AccountActivationsコントローラのeditアクションで、params[:id]として参照することができる。

クエリパラメータを使い、このURLにメールアドレスを組み込む。もしもアドレスが[foo@40example.com]だった場合は、以下のようになる。

account_activations/ランダム文字列/edit?email=foo%40example.com

アドレスの@は、URLで扱えないため%40というエスケープ文字に置き換える。

Railsクエリパラメータを設定するには、名前付きルートに対して以下のようなハッシュを追加する。

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

こうすることで、Rails側が@のような特殊な文字を自動的にエスケープしてくれる。params[:email]でメールアドレスを取り出すときは、自動的にエスケープを解除してくれる。

以上のことを踏まえ、ビューファイルに送信するメールの文面を記述する。

app/views/user_mailer/account_activation.text.erb

# ユーザーの名前を記載
Hi <%= @user.name %>,
# 適当な挨拶文・以下のURLをクリックして有効化を促すメッセージを挿入

# URLを生成
<%= edit_account_activation_url(@user.avtivation_token, email: @user.email) %>

app/views/user_mailer/account_activation.html.erb

# 基本は先ほどと同じだが、htmlタグを使って記述することができる

<h1>Sample App</h1>

link_toを利用して、表示テキストを"Activate"にできる

<%= link_to "Activate", edit_account_activation_url(@user.activation_token,
                                                    email: @user.email) %>
送信メールのプレビュー

先ほど作成したメールの表示を、送信しなくても確認できる方法があるようだ。

これを利用するためには、アプリケーションのdevelopment環境の設定に手を加える必要がある。

config/environments/development.rb

# 最初から記述されている行
config.action_mailer.raise_delivery_errors = false

host = 自分の環境のホストを貼り付ける

hostの部分に代入する値は、ローカル環境の場合は以下のようにする。protocolhttpであることに注意。

host = 'localhost:3000'
config.action_mailer.default_url_options = { host: host, protocol: 'http' }

今回、RailsチュートリアルクラウドIDEで進めていたため、rails sで立ち上げてすぐのURLをコピーして貼り付けた。暗号化を行っているため、protocolの値はhttpsにする。

host = '<hex string>.vfs.cloud9.us-east-2.amazonaws.com/'
config.action_mailer.default_url_options = { host: host, protocol: 'https' }

次に、自動生成されているUserメイラーのプレビューファイルを手直しする。

account_activationはuserを引数に取っており、引数なしでは動かないため、user変数をデータベースの最初のユーザーになるように定義する。

test/mailers/previews/user_mailer_preview.rb

def account_activation
  user = User.first
  user.activation_token = User.new_token
  UserMailer.account_activation(user)
end

以上のプレビューコードを実装することで、指定のURLでアカウント有効化メールをプレビューできるようになる。

実際に、user_mailer_preview.rbに記述されているURLにアクセスしてみると、プレビュー画面を確認することができる。

f:id:hasegawa_note:20210703013721p:plain

また、送信メールのテストも行った。Railsが自動で生成してくれているUserメイラーのテストを少し変更した。

assert_matchメソッドを使い、名前と有効化トークン、エスケープ済みメールアドレスがメール本文に含まれているかどうかをテスト。

userを代入しておき、以下のようにすることでテスト用のユーザーメールアドレスをエスケープすることができる。

assert_match CGI.escape(user.email), mail.body.encoded

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

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

ユーザー登録を行うcreateアクションにメールを送信する動作を追記すると、メイラーをアプリケーションで使用することができるようになる。

app/controllers/users_controller.rb

def create
  @user = User.new(user_params)
  if @user.save
# メールをuserに対して送信
    UserMailer.account_activation(@user).deliver_now
    flash[:info] = "Please check your email to activate your account."
# ルートURLにリダイレクトするように変更
    redirect_to root_url
エラー UrlGenerationError

createアクションを編集し、実際にテストやユーザー登録などを行ってみると、エラーが発生する。

テスト時のエラーメッセージ

ActionView::Template::Error:         ActionView::Template::Error: No route matches {:action=>"edit", :controller=>"account_activations", :email=>"user@example.com", :id=>nil}, possible unmatched constraints: [:id]
サーバーのエラーメッセージ
(登録時のメールアドレスは"a@a.com"にした)

ActionController::UrlGenerationError in Users#create
No route matches {:action=>"edit", :controller=>"account_activations", :email=>"a@a.com", :id=>nil}, possible unmatched constraints: [:id]

Ruby on Rails 5 - Rails Tutorial 11章のテストが通りません|teratail

上の方が全く同じ現象だった。before_createで、先ほど作成したcreate_activation_digestを呼び出しておくのを忘れていたからだった。

[Activationトークンのコールバック]の最後のコードは間違っている。attr_accessorしか設定していない。正しくは下のようになる。せっかくなので間違っている上のコードはそのままにしておく。

attr_accessor :activation_token
before_save :downcase_email
before_create :create_activation_digest

無事テストも通り、ユーザーを新規登録するとルートURLにリダイレクトされ、設定したフラッシュも表示された。

実際に作ってみたユーザーの情報。activatedの部分がfalseになっており、有効化のステータスがfalseであることが分かる。

#<User id: 104, name: "test"(中略)
activated: false, activated_at: nil>
アカウントを有効化する

メールが生成できたら、次はAccountActivationsコントローラのeditアクションを作っていく。

テストを行った後に、Userモデルにコードを移すようだ。

authenticated?メソッドの抽象化

有効化トークンとメールは、それぞれparams[:id]params[:email]で参照できる。

authenticated?メソッドを利用するが、今は記憶トークン用のため、アカウント有効化のダイジェストと渡されたトークンが一致するかを検証するように変更する。

def authenticated?(remember_token)
  return false if remember_digest.nil?
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

ここでのremember_digestrememberの部分を、受け取ったパラメータに応じて呼び出すメソッドを切り替えるようにする。

sendメソッド

sendメソッドという、渡されたオブジェクトにメッセージを送り、呼び出すメソッドを動的に決めることができるメソッドを利用する。プログラムでプログラムを作成するような手法を、メタプログラミングと呼ぶ。

>> a = [1, 2, 3]
=> [1, 2, 3]
>> a.length
=> 3
>> a.send(:length)
=> 3
>> a.send("length")
=> 3

sendを通して渡した:length"length"はどちらもlengthメソッドを使用したときと同じ結果になっている。

先ほど、remember_digestrememberの部分を、受け取ったパラメータに応じて変更したいと書いたが、sendメソッドを使ってそれを実現する。

以下はactivation_digest属性を利用した例を、Railsチュートリアルから引用。

>> user.activation_digest
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
>> user.send(:activation_digest)
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"
# attributeにシンボルを代入
>> attribute = :activation
# 式展開を利用する
>> user.send("#{attribute}_digest")
=> "$2a$10$4e6TFzEJAVNyjLv8Q5u22ensMt28qEkx0roaZvtRcp6UZKRM6N9Ae"

この仕組みを利用して、authenticated?メソッドを書き換えていく。

authenticated?メソッドの書き換え
def authenticated?(attribute, token)
  digest = self.send("#{attribute}_digest")
  return false if digest.nil?
  BCrypt::Password.new(digest).is_password?(token)
end

第2引数をtokenとして、他の認証でも使えるようにしてある。

これで、authenticated?メソッドを次のように呼び出すことができるようになった。

user.authenticated?(:remember, remember_token)

メソッドの引数を変更したため、sessions_helper.rbuser_test.rbに記載していたauthenticated?メソッドの引数の整合性が取れなくなり、エラーが起こるので修正する。

11.3.1の演習について引用

らくだ🐫にもできるRailsチュートリアル|11.3 | らくだ🐫のさいと

有効化トークン・ダイジェストは before_create で作成・代入されているため値が入っている 記憶トークン・ダイジェストは有効化された後に代入されるためnil

editアクションで有効化

editアクションでは、paramsハッシュで渡されたメールアドレスに対するユーザーの認証をする。

以下が、ユーザーが有効かどうかを確認するためのコード。

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

!user.activated?は、すでに有効になっているユーザーを再度有効化しないために記述する。もしも有効だった(activatedだった)場合は、!trueがひっくり返り、falseとなりif文が成立しなくなる。

この論理値に基づいてユーザーを認証するには、ユーザーを認証してからactivated_atタイムスタンプを更新する必要がある。

最初にupdate_attributeを使って:activatedfalseからtrueに変えて有効化し、次に:activated_atを現在の時間に更新する。

user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)

ユーザーオブジェクトの二つの要素を更新しているが、update_attributesを使わずにわざわざ二回設定しているのは、こちらを使うとバリデーションが実行されてしまい、パスワードが入力されていないため失敗してしまうのが理由。

(後ほどの演習で、モデルのコールバックやバリデーションが実行されず複数更新できるupdate_columnsにまとめる)

以上の点を踏まえて、editアクションを作成する。

これでユーザーの有効化が行えるようになったため、ログイン条件にユーザーが有効である場合を追加しなければならない。

sessions_controller.rbに、user.activated?trueの場合にのみログインできるようにし、そうでない場合はルートURLにリダイレクトしてフラッシュで警告を表示するようにした。

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
      # 有効でない場合は警告表示しルートURLにリダイレクト
      flash[:warning] = "警告文を表示"
      redirect_to root_url
    end
(以下略)
有効化のテスト

あとはユーザー登録のテストにアカウントの有効化を追加して終了。 目新しい知識は以下の二点。

  • setupでdeliveriesの数を初期化し、配信されたメッセージが1つだけかを確かめる
def setup
# deliveriesは変数なので、初期化する
  ActionMailer::Base.deliveries.clear
end

...

# 送られたメールは1つだけかを確認
assert_equal 1, ActionMailer::Base.deliveries.size
  • assignsメソッドを利用して、対応するアクション内のインスタンス変数にアクセス
user = assigns(:user)

このようにすると、Usersコントローラのcreateアクションにある@userというインスタンス変数にアクセスできるようになる。

ただ、assignsメソッドはRails 5以降はテストでの使用が非推奨となっているようだ。

リファクタリング

activateメソッドを作成し、ユーザーの有効化属性を更新し、send_activation_emailメソッドを作成して有効化メールを送信する。

app/models/user.rb

# アカウントを有効化するメソッド
def activate
# update_columnsを用いてバリデーションを無視しながら更新
  update_columns(activated: true, activated_at: Time.zone.now)
end

# 有効化リンクのメールを送信
def send_activation_email
  UserMailer.account_activation(self).deliver_now
end

users_controller.rbcreateアクションと、account_activations_controller.rbeditアクションの部分を、適宜先ほどのメソッドに置き換える。

また、有効でないユーザーがindexページに表示されないように、whereを使って有効なユーザーのみを表示するようにした。

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
本番環境でのメール送信

これまでの実装で、development環境におけるアカウント有効化が完成した。

production環境で実際にメールを送信できるようにしていく。

本番環境からメールを送信するために、MailgunというHerokuアドオンを利用してアカウントを検証する。

Mailgunアドオンを使う際に、自分のHerokuサブドメイン名が必要だ。サブドメイン名を調べるには、ターミナルで以下のコマンドを使う。

$ heroku domains

Railsチュートリアルの設定に従ってMailgunのHerokuアドオンを追加していく。

$ heroku addons:open mailgun

このコマンドで表示されたURLに移動し、画面左メニューにある[Sending]→[Domains]にある[sandbox]から始まるドメインを選択→右側に入力欄があるので、登録を試すメールアドレスを入力→入力したアドレスにMailGunからメールが来るので承認する