静的型付け言語原理主義者による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 にメール送信する

    • 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