アカウントの有効化
アクションメーラーの流れ
パスワードダイジェストと似ていて、平文をメールで送って、ユーザーに平文付きのurlをクリックしてもらう。getリクエストでそのurlにアクセスさせる。そのurlの中にある平文を参照する。その平文をハッシュ化したものとDBの中身が同じかどうかで判断する。
平文をどこに置くかの違い。頭の中か(パスワード)、ブラウザの中か(クッキー)、インボックスの中か(メール)。
アカウント有効化の流れ
1.ユーザーの初期状態はunactivated(有効化待ち)
2.signup時に有効化トークンとそのダイジェストを生成
3.DBにダイジェストを保存して、有効化トークンをsignupしたユーザーのメールアドレスに送る。
4ユーザーがメール内のリンクをクリックしたら、emailを使ってDB検索し、ダイジェストと比較する。
5.認証が通れば有効化、通らなければhomeを表示
AccountActivationsコントローラを作ろう
$ rails generate controller AccountActivations
ルーティングを設定しよう
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チェックなどが面倒なので、デフォルト値を設定した方が良いということだ。
それを踏まえると、
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モデルに定義してみよう
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なので、
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
また、テスト用のユーザも同じように、
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つ生成される。
UserMailer#account_activation
<%= @greeting %>, find me in app/views/user_mailer/account_activation.text.erb
<h1>UserMailer#account_activation</h1>
<p>
<%= @greeting %>, find me in app/views/user_mailer/account_activation.html.erb
</p>
また、メールにおけるコントローラ的なファイルも作られる。
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メイラーが存在する。
class ApplicationMailer < ActionMailer::Base
default from: "noreply@example.com"
layout 'mailer'
end
Userメイラーの中のメソッドを書き換える
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が代入される仕組みになっている。
アカウントの有効化のメールの文面を変えよう
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) %>
<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にならないよーって話。
送信メールのプレビューを確認しよう
まずは開発環境の設定を変える。
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
# 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だとこうなるが、果たしてうまくいくのか、、、
プレビューメソッドを完成させる
# 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メソッドを実装していく必要がある。
送信メールのテスト
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
この状態だとテストが通らない
このテストがパスするには、テストファイル内のドメイン名を正しく設定する必要があるらしい
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アクションに数行追加するだけで、メイラーをアプリケーションで実際に使うことができます
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アクションを変えたので既存のテストが失敗してしまう。
そこで一時的にテストを成功させるようにコメントアウトする。
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?()メソッドを書き換えると、
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にしたが、テストは失敗してしまう。
このテストを通すために、
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
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アクションでアカウントを有効化する
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の値が一致した時だけ、アカウントを有効化する処理をするよーって話。
有効じゃないユーザーがログインできないようにする。
今のままだと、メールを送ってアカウントの有効化機能を実装したはいいが、有効化してない人でもログインリンクからログインをできてしまうという問題点がある。
なので、有効化してない人はログインさせるようにメソッドを変える必要がある。
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があり、パスワードがあってて、さらに有効化されたユーザーだけがログインできるようにする。
有効化のテストとリファクタリング
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を取得することができる。
リファクタリングをする
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メソッドの呼び出し元が入る。
そして、
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
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の無料のメールサービスが使えるようになる。
そして設定する
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に変える。
で、メールが送られるまでの設定は動画見た方が良い。