静的型付け言語原理主義者によるRailsチュートリアル(第11章)
第11章 アカウントの有効化
- 現時点で新規登録ユーザーは最初からすべての機能にアクセスできるようになっている
- アカウントを有効化するステップを新規登録の途中に差し込むことで、メールアドレスの有効性を確認できるようにする
- 有効化トークンやダイジェストを関連付けておいた状態で
- 有効化トークンを含めたリンクをユーザーにメールで送信し
- ユーザーがそのリンクをクリックすると有効化できるようにする
- アカウントを有効化するステップを新規登録の途中に差し込むことで、メールアドレスの有効性を確認できるようにする
- アカウントを有効化する段取り
- ユーザーの初期状態は unactivated にしておく
- ユーザー登録されたときに有効化トークンとそれに対応する有効化ダイジェストを生成する
- 有効化ダイジェストは DB に保存しておき、有効化トークンはメールアドレスと一緒にユーザーに送信する有効化用メールのリンクに仕込んでおく
- ユーザーがメールのリンクをクリックしたら、アプリケーションはメールアドレスをキーにしてユーザーを探し、DB 内に保存しておいた有効化ダイジェストと比較することでトークンを認証する
- ユーザーを認証できたら、ユーザーのステータスを activated に変更する
11.1 AccountActivationsリソース
- セッション機能を使って、アカウントの有効化という作業をリソースとしてモデル化する
- 有効化トークンや有効化ステータスなどを User モデルに追加する
11.1.1 AccountActivations コントローラ
- AccountActivations リソースを作るためにに、まずは AccountActivations コントローラを生成する
- 有効化のメールには次の URL を含めることになる
edit_account_activation_url(activation_token, ...)
- 上記は
edit
アクションへの名前付きルートが必要になるということ- 名前付きルートを扱えるようにするために、ルーティングにアカウント有効化用の
resources
行を追加する
- 名前付きルートを扱えるようにするために、ルーティングにアカウント有効化用の
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
11.1.2 AccountActivation のデータモデル
- 有効化のメールには一意の有効化トークンが必要
- 仮想的属性を使ってハッシュ化した文字列を DB に保存する
- 続いて
activated
属性を追加して論理値を取るようにする- ユーザーが有効であるかどうかをテストできるようになる
- マイグレーションを実行してデータモデルを更新する
rails generate migration add_activation_to_users activation_digest:string activated:boolean activated_at:datetime
activated
属性のデフォルト論理値をfalse
にしておいてマイグレーションを実行
Activation トークンのコールバック
- ユーザーの新規登録時には必ずアカウントの有効化が必要になるので、有効化トークンや有効化ダイジェストはユーザーオブジェクトが作成される前に作成しておく必要がある
- オブジェクトが作成されたときだけコールバックを呼びたいので
before_create
を定義する - コールバック内で
create_activation_digest
というメソッド参照を定義 - Rails は
create_activation_digest
メソッドを探し、ユーザーを作成する前に実行する - User モデル内でしか使用しないので
private
にする - コールバックでトークンとそれに対応するダイジェストを割り当てるため、記憶トークンや記憶ダイジェストのために作ったメソッドを使い回す
User.new
で新しいユーザーが定義されると、activation_token
属性やactivation_digest
属性が得られるようになる
- オブジェクトが作成されたときだけコールバックを呼びたいので
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 を初期化し、サンプルデータを生成し直して変更を反映させる
11.2 アカウント有効化のメール送信
- アカウント有効化メールの送信に必要なコードを追加する
- Action Mailer ライブラリを使って User のメイラーを追加する
- Users コントローラの
create
アクションで有効化リンクをメール送信するために使用する- よくあるやつ
- メールのテンプレートはビューと同じ要領で定義できる
- テンプレート内に有効化トークンとメールアドレスのリンクを含め使う
11.2.1 送信メールのテンプレート
- メイラーはモデルやコントローラと同様に
rails generate
で生成可能
$ rails generate mailer UserMailer account_activation password_reset
account_activation
メソッドと、password_reset
メソッドが生成される- 便利だなあ。。。相変わらず裏で何やっているか不明だけど
- 生成したメイラーごとにビューのテンプレートが2つずつ生成される
- ひとつはテキストメール用のテンプレート
- ひとつは HTML メール用のテンプレート
- 最初に生成されたテンプレートをカスタマイズして、実際に有効化メールで使えるようにする
class ApplicationMailer < ActionMailer::Base
default from: "noreply@example.com"
layout 'mailer'
end
- 次に、ユーザーを含むインスタンス変数を作成してビューで使えるようにし、
user.email
にメール送信するmail
にsubject
として渡している引数はメールの件名
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
- テンプレートビューは通常のビューと同様に ERB で自由にカスタマイズ可能
- 挨拶文にユーザー名を含め、カスタムの有効化リンクを追加する
- リンクにはメールアドレスとトークンの両方を含める必要がある
- Rails サーバーでユーザーをメールアドレスで検索して有効化トークンを認証できるようにするため
- AccountActivations リソースで有効化をモデル化したので、トークン自体は名前付きルートの引数で使われる
edit_account_activation_url(@user.activation_token, ...)
edit_user_url(user)
- 上記メソッドは次の形式の URL を生成する
http://www.example.com/users/1/edit
- これに対応するアカウント有効化リンクのベース URL は次のようになる
http://www.example.com/account_activations/q5lt38hQDc_959PVoo6b7A/edit
- 上記 URL の
q5lt38hQDc_959PVoo6b7A
はnew_token
メソッドで生成されたもので、URLで使えるように Base64 でエンコードされている- /user/1/edit の 1 のようなユーザー ID と同じ役割を果たす
- 特に AccountActivations コントローラの
edit
アクションではparams
ハッシュでparams[:id]
として参照可能
- クエリパラメータを使ってこの URL にメールアドレスも組み込んでみる
account_activations/q5lt38hQDc_959PVoo6b7A/edit?email=foo%40example.com
- メールアドレスの@記号は URL では使用できない文字のためエスケープされている
- Rails でクエリパラメータを設定するには、名前付きルートに対して次のようなハッシュを追加する
edit_account_activation_url(@user.activation_token, email: @user.email)
- 名前付きルートでクエリパラメータを定義すると、Rails が特殊な文字を自動的にエスケープしてくれる
- コントローラで
params[:email]
からメールアドレスを取り出すときは、自動的にエスケープを解除してくれる- いたれりつくせり
- コントローラで
- 以上を組み合わせてアカウント有効化のテキストビューを作成する
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 ビューも作成する
<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.2 送信メールのプレビュー
- Rails では特殊な URL にアクセスするとメールのメッセージをプレビューすることができる
- な、なんだってー
- アプリケーションの development 環境の設定を変更する必要がある
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
- 自動生成した User メイラーのプレビューファイルの更新も必要
- 自動生成コードのままでは動作しないので、
user
変数が開発用データベースの最初のユーザーになるように定義し、それをUserMailer.account_activation
の引数として渡す
# 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
- 以上でプレビュー可能になる
- 摩訶不思議
11.2.3 送信メールのテスト
- メールプレビューのテストを追加する
- プレビューのテストに違和感
- Rails によってテストは自動生成されている
- おいおいおい
require 'test_helper'
class UserMailerTest < ActionMailer::TestCase
test "account_activation" do
mail = UserMailer.account_activation
assert_equal "Account activation", mail.subject
assert_equal ["to@example.org"], mail.to
assert_equal ["from@example.com"], mail.from
assert_match "Hi", mail.body.encoded
end
test "password_reset" do
mail = UserMailer.password_reset
assert_equal "Password reset", mail.subject
assert_equal ["to@example.org"], mail.to
assert_equal ["from@example.com"], mail.from
assert_match "Hi", mail.body.encoded
end
end
assert_match
という非常に強力なメソッド- 正規表現で文字列をテストできる
- つよい
- テスト用ユーザーのメールアドレスをエスケープすることも可能
- 正規表現で文字列をテストできる
- 現在のメールの実装をテストする
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
11.2.4 ユーザーの 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
- リダイレクト先をプロフィールページからルート URL に変更し、かつユーザーは以前のようにログインしないようになっている
- テストが失敗するので該当箇所をひとまずコメントアウト(あとで直す)
- ユーザーを実際に登録してみると、メールが生成される(送信はされない)
11.3 アカウントを有効化する
- メールが生成できたので、次は AccountActivations コントローラの
edit
アクションを書いていく
11.3.1 authenticated? メソッドの抽象化
- 有効化トークンとメールはそれぞれ
params[:id]
とparams[:email]
で参照できるので、次のようなコードでユーザーを検索して認証する
user = User.find_by(email: params[:email])
if user && user.authenticated?(:activation, params[:id])
authenticated?
メソッドは、アカウント有効化のダイジェストと渡されたトークンが一致するかどうかをチェックする- 以下のメソッドは記憶トークン用なのでまだ正常に動作はしない
# トークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
remember_digest
は User モデルの属性なので、モデル内では次のように置き換えることができる
self.remember_digest
- 今回は、上記コードの remember 部分を変数として扱いたい
- すなわち状況に応じて呼ぶメソッドを切り替えたい
authenticated?
メソッドでは、受け取ったパラメータに応じて呼び出すメソッドを切り替える- メタプログラミング
- 「Rails の一見魔法のような機能(黒魔術とも呼ばれます)の多くは、Ruby のメタプログラミングによって実現されています。」
- 黒魔術(公式)
- 渡されたオブジェクトに「メッセージを送る」ことによって呼び出すメソッドを動的に決めることができる
send
メソッドを使う- やりすぎでは
- 「Rails の一見魔法のような機能(黒魔術とも呼ばれます)の多くは、Ruby のメタプログラミングによって実現されています。」
- メタプログラミング
send
メソッドの仕組みを利用して、authenticated?
メソッドを書き換える
def authenticated?(remember_token)
digest = self.send("remember_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(remember_token)
end
- 各引数を一般化し、文字列の式展開も利用する
def authenticated?(attribute, token)
digest = self.send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
- モデル内にあるコードなので
selef
は省略可能
def authenticated?(attribute, token)
digest = send("#{attribute}_digest")
return false if digest.nil?
BCrypt::Password.new(digest).is_password?(token)
end
- 次のようにして
authenticated?
の従来の振る舞いを再現できる
user.authenticated?(:remember, remember_token)
- 以上を実際の User モデルに適用する
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
- 従来のテストが失敗するので、新しい
authenticated?
メソッドを使用するように修正する current_user
内の抽象化したauthenticated?
メソッドを修正
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
- User テスト内の抽象化した
authenticated?
メソッドを修正
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
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])
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
!user.activated?
は既に有効になっているユーザーを誤って再度有効化しないために必要- 正当であろうとなかろうと、有効化が行われるユーザーはログイン状態になる
- この時点ではユーザーのログイン方法を変更していないため、ユーザーの有効化にはまだなんの意味もない
- ユーザーが有効である場合にのみログインできるように修正する必要がある
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
11.3.3 有効化のテストとリファクタリング
- ユーザー登録のテストにアカウント有効化のテストを追加する
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
- ユーザー操作の一部をコントローラからモデルに移動するリファクタリングを行う
activate
メソッドを作成してユーザーの有効化属性を更新し、send_activation_email
メソッドを作成して有効化メールを送信する
- 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
- ユーザーモデルオブジェクトからメールを送信する
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
11.4 本番環境でのメール送信
- production 環境で実際にメールを送信できるようにしてみる
- SendGrid という Heroku アドオンを利用してアカウントを検証
- Heroku アカウントにクレジットカードを設定する必要があるので割愛
- starter tier というサービスを利用
- production 環境の SMTP に情報を記入する必要がある
- Heroku にデプロイして完了(の体)
11.5 最後に
- アカウント有効化を実装しったので、サンプルアプリケーションのユーザー登録、ログイン、ログアウトの仕組みがほぼ完成した
- 最後はパスワード再設定機能
- アカウント有効かと仕組みは似ている
所感
メールを用いたアカウントの有効化という、Webサービスでよくある実装を行った。複雑な実装をしなければいけないかと思いきや、ここでも Rails に乗っかるだけで簡単に実装できてしまった。裏側はどうなっているのか。。。
この章での醍醐味はやはりメタプログラミングだろう。Ruby のメタプログラミングに初めて触れたのだが、静的型付け言語原理主義者としては、動的にメソッドを切り替えるという言語仕様はなかなか受け入れられるものではない。しかしこの言語仕様が自由度の高いプログラミングを可能にしているのも事実で、Rails の黒魔術を支えているのだと実感できた。コードが壊れやすくなるとは思うが、この自由な世界からプログラミングを始めた人にとっては、静的型付け言語は自由度が低く、毎度ビルドしないといけない非常に扱いづらい言語になるのだろうなあと思えた。