はじめに
個人的な理解・備忘録を目的としてます。
筆者自身は動画版Railsチュートリアルで進めているため、アプリ作成中コード・ブランチ名などの若干の違いがありますので参考程度に流し見して頂けたら嬉しいです。
理解不足のため、何かありましたらコメント等ご指摘してくださると幸いです(^_^;)
11.0 本章の目標
本章では、アカウントを有効化するステップを新規登録の途中に差し込むことで、本当にそのメールアドレスの持ち主なのかどうかを確認できるようにする。(仮に誰かがミスって登録しても大丈夫なように)
例.ECサイト登録した場合のユーザー(ユ)とサーバー(サ)のやりとり
1. (ユ)→(サ) 「このemailで登録で」
2. (サ)→(ユ) 「メール送ったから確認して」
3. (ユ)→(サ) 「来たメールのURLクリックしたよ」
4. 【NG】(サ)→(ユ) 「違うからもう一回だね」
4. 【OK】(サ)→(ユ) 「登録するね(DB書き込みするか)」
<実装内容>
(1) 有効化トークンやダイジェストを関連付けておいた状態に
(2) 有効化トークンを含めたリンクをユーザーにメールで送信
(3) ユーザーがそのリンクをクリックすると有効化
また、ユーザーがパスワードを忘れたときにパスワードを再設定できるようにする。
アカウント有効化の段取りは以下の手順(第9章のユーザーの登録に近い)
1. ユーザーの初期デフォルトの状態は「有効化されていない」(unactivated) にしておく
2. ユーザー登録(Signup)が行われたときに、有効化トークンと、それに対応する有効化ダイジェストを生成する
3. 有効化ダイジェストはDBに保存しておき、Signupしたユーザーには(有効化トークンをリンクに仕込んで)メールアドレスにメールを送信
4. ユーザーがメールのリンクをクリックしたら(本人だったら)、アプリケーションはメールアドレスをキーにしてDBでユーザーを探し、DB内に保存しておいた有効化ダイジェストと比較してトークンを認証する
5. ユーザーを認証できたら、ユーザーのステータスを「有効化されていない」から「有効化済み」(activated) に変更する
(認証できなければhomeを表示)
検索キー | string | digest | authentication |
---|---|---|---|
password | password_digest | authenticate(password) | |
id | remember_token | remember_digest | authenticated?(:remember, token) |
activation_token | activation_digest | authenticated?(:activation, token) | |
reset_token | reset_digest | authenticated?(:reset, token) |
(公式より参考)
機能としては10章の時点で基本的な機能実装は完成している。
本章(第11章)+第12章では応用的な部分の実装になる。
11.1 AccountActivationsリソース
まずはGitで新機能用のトピックブランチを作成
$ git checkout -b account-activation
ユーザーを有効にしたときの変更後のデータモデル
(公式より参考)
今回使うのは赤点線部分で、
activation_digest → 有効化トークンと一致させるもの
activated → 有効化されてるか否かを真偽値(1 or 0)で、デフォでは偽(0)、「if user.activated?」などで使う
activated_at → いつ有効化されたか(有効化の機構でなくログとして残す程度)
11.1.1 AccountActivationsコントローラ
AccountActivationsリソースを作るために、AccountActivationsコントローラを生成する
$ rails generate controller AccountActivations
ルーティングにアカウント有効化用のリソース resources行(editアクション)を追加
resources :users
resources :account_activations, only: [:edit]
# (ユーザから) GET /account_activations/:id/edit
# params[:id] <== 有効化トークン入れる
# Controller: params[:id]
11.1.2 AccountActivationのデータモデル
マイグレーションをコマンドラインで実行して先のデータモデル3つを追加すると、3つの属性が新しく追加される
$ rails generate migration add_activation_to_users activation_digest:string activated:boolean activated_at:datetime
同じ内容の2行版(>はシェル側の挿入で入力しない。上の連続が安定かも)
$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime
Running via Spring preloader in process 23013
invoke active_record
create db/migrate/[timestamp ex. 2020...]_add_activation_to_users.rb
アカウント有効化用の属性とインデックスを追加するマイグレーション。デフォルト:falseを追加
class AddActivationToUsers < ActiveRecord::Migration[5.1]
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モデルにアカウント有効化のコードを追加する
class User < ApplicationRecord
attr_accessor :remember_token, :activation_token
before_save :downcase_email
before_create :create_activation_digest
# メールアドレスをすべて小文字にする
def downcase_email
self.email = self.email.downcase
end
# 有効化トークンとダイジェストを作成および代入する
def create_activation_digest
self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
# @user.activation_digest => ハッシュ値が入る
end
サンプルユーザーの生成とテストのため、テスト時のサンプルとユーザーを事前に有効化する。なお、Time.zone.now
はRailsの組み込みヘルパー(特に .zone)であり、サーバーのタイムゾーンに応じたタイムスタンプを返す。
$ ruby -e "puts Time.now"
2020-01-22 02:34:53 +0000
サンプルユーザーを最初から有効に
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
fixtureのユーザーも有効(activated: true)、タイムスタンプの追加
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 %>
DBを初期化&サンプルデータを再度生成し直し、変更を反映
$ rails db:migrate:reset
$ rails db:seed
ここまでで、有効化トークンをダイジェスト(ハッシュ値)にして、DBに保存する流れを実装できた。
11.2 アカウント有効化のメール送信
データのモデル化が終わったので、今度はアカウント有効化メールの送信に必要なコードを追加する。ここではAction Mailerライブラリを使ってUserのメイラーを追加していく。
これはUsersコントローラのcreateアクションで有効化リンクをメール送信するために用いるもので、構成がコントローラのアクションとよく似ているため、メールのテンプレートをビューと同じように定義できる。このテンプレートの中に有効化トークンとメールアドレス (= 有効にするアカウントのアドレス) のリンクを含める。
11.2.1 送信メールのテンプレート
メイラーは、モデルやコントローラと同様にrails generate
で生成できる。
$ rails generate mailer UserMailer account_activation password_reset
※補足
rails generate
mailer(単数:controller等)
UserMailer(その名前つける)
account_activation password_reset (メソッド2つ)
生成されたUserメイラー
class UserMailer < ApplicationMailer
def account_activation
@greeting = "Hi"
mail to: "to@example.org" #=> return mail boject
end
def password_reset
@greeting = "Hi"
mail to: "to@example.org"
end
end
コントローラーのアクションっぽいが、メソッドと呼ぶ方がいい(らしい)。
読み込んだテキストがそれぞれビューとして呼び出される。
アカウント有効化メイラーのテキストビュー (自動生成)
app/views/user_mailer/account_activation.text.erb
User#account_activation
<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
account_activationメソッドの@greetingの文面「Hi!」が送り付けられる。
アカウント有効化メイラーのHTMLビュー (自動生成)
app/views/user_mailer/account_activation.html.erb
<h1>User#account_activation</h1>
<p>
<%= @greeting %>, find me in app/views/user_mailer/account_activation.html.erb
</p>
生成されたApplicationメイラー
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout 'mailer'
end
MVCのモデル的な解釈としてActionメイラーは、
Mail(文面) → View
Mailer → controller
のような位置付けになっている。
(コンソールでメイラーの親クラスを追うとコントローラーに繋がる)
生成されたApplicationメイラー
class ApplicationMailer < ActionMailer::Base
default from: 'from@example.com'
layout 'mailer'
end
fromアドレスのデフォルト値を更新したアプリケーションメイラー
class ApplicationMailer < ActionMailer::Base
default from: "noreply@example.com"
layout 'mailer'
end
アカウント有効化リンクをメール送信する
class UserMailer < ApplicationMailer
def account_activation
@greeting = "Hi"
# (旧) mail to: "to@example.org" #=> return mail boject
#=> app/views/user_mailer/account_activation.text.erb
#=> app/views/user_mailer/account_activation.html.erb
mail to: @user.email #=> mail object
# https://hogehoge.com/account_activation/:id/edit
# :id <= @user.activation_tokenを入れる
end
# 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
メールのテンプレートを実際に有効化メール(返信用)
作りたいメールアドレスはこれ
account_activations/q5lt38hQDc_959PVoo6b7A/edit?email=foo%40example.com
q5lt38hQDc_959PVoo6b7A
はnew_tokenメソッドで生成されたもので、URLで使えるようにBase64でエンコードされている。
エンコード
エンコードとは、「URL(ホームページの住所)で使えない文字を別の表現(形式)に置き換えること」。チュートリアルではBase64
(64進数、すべてのデータをアルファベット(a~z, A~z)と数字(0~9)、一部の記号(+,/)の64文字)のエンコード形式で行う。エンコードしたデータを元の形に戻す行為は「デコード」と呼ぶ。
あ → エンコード → %E3%81%82
%E3%81%82 → デコード → あ
<参考>
エンコード (encode)
URLエンコード・デコード
base64ってなんぞ??理解のために実装してみた
気をつけるべきは、メールのURLが@
でなく%40
担っていること。これは、「エスケープ」と呼ばれる手法で、通常URLでは扱えない文字を扱えるようにするために変換されている。
このメールによってRailsサーバーで行いたいのは以下の処理。
①ユーザーをメールアドレス(finde_byで@user.email)で検索
②有効化トークンを認証(authenticated?で@user.activation_token)
アカウント有効化のテキストビュー
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) %>
アカウント有効化のHTMLビュー
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) %>
ちなみに、コンソールを開いてCGIモジュールのescapeメソッドでメールアドレスや"Don't panic!"の文字列を確認すると、以下のようにエスケープできている。
CGI.escape('foo@example.com')
=> "foo%40example.com"
2.6.3 :002 > CGI.escape("Don't panic!")
=> "Don%27t+panic%21"
11.2.2 送信メールのプレビュー
テンプレートの実際の表示を簡単に確認するために、メールプレビューという手法を使う。Railsでは、特殊なURLにアクセスするとメールのメッセージをその場でプレビューすることができるので、メールを実際に送信しなくてもok。
これを利用するためには、アプリケーションのdevelopment環境の設定を変更する。筆者の環境ではクラウドIDEのため下記、ローカルならlocalhost:3000
。
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = true #=> デフォfalseから変更
config.action_mailer.delivery_method = :test #=> rails場でメールをログとして出す(実際には送らない)
host = '******.vfs.cloud9.ap-northeast-1.amazonaws.com/' # sample_app(preview)のドメイン,https://の後
config.action_mailer.default_url_options = { host: host, protocol: 'https' }
Userメイラープレビュー アカウント有効化のプレビューメソッド
# 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) #=> mail object
end
先ほどのリンクにメイラーを付け足してプレビューで検索確認。指定のURLでアカウント有効化メールをプレビューできるようになった。(サーバーは再起動)
******.vfs.cloud9.ap-northeast-1.amazonaws.com/rails/mailers/user_mailer/account_activation
メール
Accountをクリック
先ほどのtext(メール添付SS下部リンクは省略)。
account_activations/******(平文)/edit?email=example%40railstutorial.org
11.2.3 送信メールのテスト
Railsによって自動生成されているテスト例を使い、メールプレビューのテストも作成してプレビューをダブルチェックする。
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.action_mailer.delivery_method = :test
config.action_mailer.default_url_options = { host: 'example.com' }
これでテストし、通過。
※ メーラーは書き方が少し異なる。
$ rails test:mailers
11.2.4 ユーザーのcreateアクションを更新
ユーザー登録にアカウント有効化を追加する。あとはユーザー登録を行うcreateアクションに数行追加するだけで、メイラーをアプリケーションで実際に使えるようになる。
前回はユーザーのプロフィールページにリダイレクトしていたが、アカウント有効化を実装するうえでは無意味な動作のため、リダイレクト先をルートURLに変更する。
ユーザー登録にアカウント有効化を追加する。
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
アカウント作成 → ログインすると、サーバー(ターミナル)でメールの文面が確認できる。
これが、11.2.3であった:testのもの。
config.action_mailer.delivery_method = :test
ただしテストでは、singnupで引っ掛かりが起きる。これはリダイレクト先をプロフィールページからルートURLに変更し、かつユーザーは以前のようにログインしないようにしているため(機能的に問題なくてもNG)。
失敗するテストを一時的にコメントアウトする
# assert_template 'users/show'
# assert is_logged_in?
11.3 アカウントを有効化する
ここからは先ほどのやりとりのうち、下記を実装。
3. (ユ)→(サ) 「来たメールのURLクリックしたよ」
4. 【NG】(サ)→(ユ) 「違うからもう一回だね」
4. 【OK】(サ)→(ユ) 「登録するね(DB書き込みするか)」
11.3.1 authenticated?メソッドの抽象化
authenticated?メソッドは現在、remember_digestの記憶トークン対応のみのため、これでは今回実装したい有効化トークンも含めて2つ書く手間でDRYとはいえない。
そのため、今回はsendメソッドを使い動的ディスパッチ(メソッドの実行直前に実行するメソッドを呼び出すこと)として抽象化する。
これだけだとよくわからないのでコンソールを開く。
2.6.3 :001 > a = "hogehoge"
=> "hogehoge"
2.6.3 :002 > a.send("length")
=> 8
2.6.3 :003 > a.length
=> 8
sendはメソッド名を文字列にできる(文字列なので式展開ができる)。つまり
hensuu = "activation"
.send("#{hensuu}_digest") #=> akj3a3%4au#(前回)
hensuu = "remember"
.send("#{hensuu}_digest") #=> nil(今回 まだない)
のような切り替えができる。
シンボル表記(:activation)
でもok
抽象化されたauthenticated?メソッド。引数を追加したり
# トークンがダイジェストと一致したらtrueを返す
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
このままではREDのため、
sessions_helperのcurrent_user内でauthenticated?メソッドを抽象化
# 現在ログイン中のユーザーを返す (いる場合)
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id]) #=> signedで復号化
user = User.find_by(id: user_id)
#旧(引数不足) if user && user.authenticated?(cookies[:remember_token])
if user && user.authenticated?(:remember, cookies[:remember_token])
Userテストでも
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?(:remember, '')
end
これでテストは通過
11.3.2 editアクションで有効化
editアクションの実装。paramsハッシュで渡されたメールアドレスに対応するユーザーを認証(有効かどうか?)。
class AccountActivationsController < ApplicationController
def edit
user = User.find_by(email: params[:email])
if user && !user.activated? && user.authenticated?(:activation, params[:id])
#Success => Signup
user.update_attribute(:activated, true)
user.update_attribute(:activated_at, Time.zone.now)
log_in user
flash[:success] = "Account activated!"
redirect_to user
else
#Failure
flash[:danger] = "Invalid activation link"
redirect_to root_url
end
end
end
ユーザーの有効化が役に立つためには、ユーザーが有効である場合にのみログインできるようにログイン方法を変更する必要がある。
そのためには、user.activated?がtrueの場合にのみログインを許可し、そうでない場合はルートURLにリダイレクトしてwarningで警告を表示する。
def create
user = User.find_by(email:params[:session][:email])
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
アクティベーションしてないユーザーがログインしようとすると、通知が出るように。
11.3.3 有効化のテストとリファクタリング
アカウント有効化の統合テストを追加する。内容のベースは「正しい情報でユーザー登録を行った場合」のテスト。
ユーザー登録のテストにアカウント有効化を追加する。
def setup
ActionMailer::Base.deliveries.clear
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_template 'users/show'
assert is_logged_in? # assert is_logged_in? #=> signup 終えた人はログインも終わってるか?
end
assignsメソッド
assignsメソッドとは、「コントローラーのインスタンス変数をテストするメソッド」。引数にはインスタンス変数(@user)をシンボル型(:user)で渡して使う。
参考
Rspecの基礎
これでテストは通過
次はユーザー操作の一部をコントローラからモデルに移動する。
流れとしては、
①activateメソッドを作成してユーザーの有効化属性を更新
②send_activation_emailメソッドを作成して有効化メールを送信
Userモデルにユーザー有効化メソッドを追加する
private
# アカウントを有効にする
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
ユーザーモデルオブジェクト経由でアカウントを有効化する
def edit
user = User.find_by(email: params[:email])
if user && !user.activated? && user.authenticated?(:activation, params[:id])
#Success => Signup
#不要 user.update_attribute(:activated, true)
#不要 user.update_attribute(:activated_at, Time.zone.now)
user.activate
log_in user
flash[:success] = "Account activated!"
redirect_to user
ユーザーモデルオブジェクトからメールを送信する
# POST /users
def create
@user = User.new(user_params)
if @user.save
# Sucess
@user.send_activation_email #=>signup後にactivation_emailを送りつける
#不要 UserMailer.account_activation(@user).deliver_now
flash[:info] = "Please check your email to activate your account."
redirect_to root_url
else
これでテストは通過
11.4 本番環境でのメール送信
SendGridの使い方
本番環境からメール送信するために、「SendGrid」というHerokuアドオン(Webサービス)を利用してアカウントを検証する。このサービスの利用のためにはクレジットカードの登録が必要(1日のメール数が最大400通までという制限があるが無料)。利用の場合は下記リンクにアクセスして入力する。
https://heroku.com/verify
アドオンをアプリケーションに追加するには、次のコマンドを実行。
$ heroku addons:create sendgrid:starter
> heroku: Press any key to open up the browser to login(ログインしたくば、任意のキーを押されよ)
→ Enter押す
> Opening browser to https://cli-auth.heroku.com/...
› Warning: Cannot open browser.
→ リンクからログインする(下記SS)
> Logging in... done
ログインして利用可能状態になった。
アプリケーションでSendGridアドオンを使うには、
1. production環境のSMTPに情報を記入
2. 本番Webサイトのアドレスをhost変数に定義
が必要。
Railsのproduction環境でSendGridを使う設定
Rails.application.configure do
.
.
.
config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method = :smtp
host = '<自分のドメイン>.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
自分のドメインはheokuのアプリ開くかpersonalなどのページから確認。
ちなみに、SendGridの環境変数などを知りたい時は、
$ heroku config:get SENDGRID_USERNAME
> app686...@heroku.com(数字は適当)
$ heroku config:get SENDGRID_PASSWORD
>haiuekuryha436798
などで、herokuのスタータープランなどで作ってくれたメール等確認できる。
※ 注意
ここで重要なのが、
user_nameとpasswordのハッシュに実際の値を記入しないこと
ソースコードに直接機密情報を書き込むのは危険であり(ソースコードがハックされたらout)、そのような情報は環境変数に記述し、そこからアプリケーションに読み込む必要がある。
ここで一応テスト。
テストは通過
ここからはいつも通り、
$ git add -A
$ git commit -m "Finish ch11"
$ git checkout master
$ git merge account-activation
$ rails t
$ git push heroku master
$ heroku pg:reset DATABASE
> 左赤字(アプリ名〇〇_app)
$ heroku run rails db:migrate
$ heroku run rails db:seed
本番環境でアカウント作成して
ちゃんと自分のGmailにメールが送信された
Activate
をクリックでログインに成功。
ちなみに忘れたりしてもう一度Activate
をクリックすると既に有効化されているため、下記のような表示になる。
終了。
Ruby on Rails チュートリアル 第11章 アカウント有効化(AcctionMailer Activation)やSendGridの使い方など