静的型付け言語原理主義者によるRailsチュートリアル(第11章)

image

第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 にメール送信する
    • mailsubject として渡している引数はメールの件名
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_959PVoo6b7Anew_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 メソッドを使う
        • やりすぎでは
  • 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 の黒魔術を支えているのだと実感できた。コードが壊れやすくなるとは思うが、この自由な世界からプログラミングを始めた人にとっては、静的型付け言語は自由度が低く、毎度ビルドしないといけない非常に扱いづらい言語になるのだろうなあと思えた。


5542 Words

2020-03-24 13:39 +0000