静的型付け言語原理主義者による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 の黒魔術を支えているのだと実感できた。コードが壊れやすくなるとは思うが、この自由な世界からプログラミングを始めた人にとっては、静的型付け言語は自由度が低く、毎度ビルドしないといけない非常に扱いづらい言語になるのだろうなあと思えた。