Help us understand the problem. What is going on with this article?

Railsチュートリアル 第11章 アカウントの有効化 - AccountActivationsリソース

前提

  • アカウントの有効化機能は、セッション機能と同様、RESTfulなリソースとしてモデル化する
    • AccountActivationsコントローラーの実装は、Sessionsコントローラーの実装と似た形になる
    • ただし、以下のユースケースから、ここまで実装してきたリソースとは異なる形の実装となる
  • 「有効化リンクはメールでユーザーに送信される。ユーザーが当該リンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というユースケースである
    • Webブラウザでリンクをクリックした場合と同様、このときユーザーから送られるのはGETリクエストである
    • GETリクエストが直接のトリガーになるゆえ、「editアクションで直接RDBの内容が変更される」という実装になる

AccountActivationsコントローラー

AccountActivationsコントローラーの生成

まずはrails generate controllerコマンドにより、AccountActivationsコントローラーを生成するところから始まります。この部分は、Sessionsコントローラーと同じ手順ですね。

# rails generate controller AccountActivations
Running via Spring preloader in process 13245
      create  app/controllers/account_activations_controller.rb
      invoke  erb
      create    app/views/account_activations
      invoke  test_unit
      create    test/controllers/account_activations_controller_test.rb
      invoke  helper
      create    app/helpers/account_activations_helper.rb
      invoke    test_unit
      invoke  assets
      invoke    coffee
      create      app/assets/javascripts/account_activations.coffee
      invoke    scss
      create      app/assets/stylesheets/account_activations.scss

AccountActivationsリソースに関係するルーティング

AccountActivationsリソースのユースケースは、「ユーザーがリンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というものです。当然ながら、「アカウントの有効化プロセスを開始するためのリンク」が必要になってきます。さらに、「RESTfulなリソースとしてモデル化する」という前提があります。というわけで、「アカウントの有効化プロセスを開始するためのリンク」は、「AccountActivationsリソースのeditアクションに紐付けされたリンク」ということになります。

edit_account_activation_url(activation_token, ...)

AccountActivationsリソースのユースケースは、「ユーザーがリンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というものに限られます。(editアクションではなく)リソースそのものへのGETや、(Usersリソースとセットでない)単独でのPOSTDELETEUPDATE、以上のリクエストが送られてくることは想定されません。ゆえに、必要となるルーティングはeditのみに限られます。

resources :account_activations, only: [:edit]

結果、Railsのルーティング(config/routes.rb)は、以下のように変更する必要があります。

config/routes.rb
  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'
    post    '/signup',  to: 'users#create'
    get     '/login',   to: 'sessions#new'
    post    '/login',   to: 'sessions#create'
    delete  '/logout',  to: 'sessions#destroy'
    resources :users
+   resources :account_activations, only: [:edit]
  end

演習 - AccountActivationsコントローラー

1. 現時点でテストスイートを実行するとgreenになることを確認してみましょう。

# rails test
Running via Spring preloader in process 13277
Started with run options --seed 33183

  43/43: [=================================] 100% Time: 00:00:15, Time: 00:00:15

Finished in 15.05852s
43 tests, 183 assertions, 0 failures, 0 errors, 0 skips

確かにテストは成功しますね。

2. 表 11.2の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。

ヒント: 私達はこれからメールで名前付きルートを使います。

「AccountActivationsリソースのeditアクションに対応するURLは、メールに記載するURLとして使われる」というのが前提となる、というのが大きなポイントです。

  • AccountActivationsリソースのeditアクションには、Railsアプリケーションの外部からアクセスできなければならない
  • Railsアプリケーションの外部からリソースにアクセスする場合、完全なURLが必要となる

このあたりが理由でしょうか。

AccountActivationのデータモデル

RDBには、有効化トークンをハッシュ化したものを保存する

「生の有効化トークンをRDBに保存する」という運用は、万が一RDBそのものの内容が漏洩した場合、容易に攻撃に悪用される脆弱な運用です。例えば、「攻撃者が新しく登録されたユーザーの有効化トークンを盗み取り、本来のユーザーが使う前に当該トークンを使ってしまう(そして当該ユーザーとしてログインしてしまう)」という攻撃が行われる危険性が想定できます。

というわけで、パスワードや記憶トークンと同様、有効化トークンについても、「RDBに保存するのはハッシュ化した値」という運用を行うこととします。

実装手法

実装手法は、節6.3におけるpassword仮想属性の実装、ならびに、節9.1におけるremember_token仮想属性の実装と類似しています。今回実装するのは、activation_tokenという仮想属性となります。

user.activation_token

最終的には、以下のようなコードでユーザーの有効化トークンを認証できるようになることを目指します。

user.authenticated?(:activation, token)

なお、authenticated?メソッドそのものの実装にも手を加えていくことになります。

Userモデルの実装内容を変更する

Userモデルには、以下3つの属性を新たに追加していくことになります。

  • activation_digest
    • string型
    • 有効化トークンに対するダイジェスト
  • activated
    • boolean型
    • ユーザーが有効化されたか否か
    • デフォルトではfalseである
  • activated_at属性
    • datetime型
    • ユーザーが有効化された日時

上記属性追加を反映した新たなUserモデルの全体像は、以下のようになります。

User_full.png

新たなUserモデルに対応するマイグレーション

まずはマイグレーションそのものを生成します。

root@705320d4d96d:/var/www/sample_app# rails generate migration add_activation_to_users activation_digest:string activated:boolean activated_at:datetime
Running via Spring preloader in process 13329
      invoke  active_record
      create    db/migrate/[timestamp]_add_activation_to_users.rb

activated属性のデフォルト値をfalseとするため、生成されたマイグレーションを以下のように書き換えていきます。

db/migrate/[timestamp]_add_activation_to_users.rb
  class AddActivationToUsers < ActiveRecord::Migration[5.1]
    def change
      add_column :users, :activation_digest, :string
-     add_column :users, :activated, :boolean
+     add_column :users, :activated, :boolean, default: false
      add_column :users, :activated_at, :datetime
    end
  end

最後はrails db:migrateです。

# rails db:migrate
== [timestamp] AddActivationToUsers: migrating =============================
-- add_column(:users, :activation_digest, :string)
   -> 0.0169s
-- add_column(:users, :activated, :boolean, {:default=>false})
   -> 0.0015s
-- add_column(:users, :activated_at, :datetime)
   -> 0.0018s
== [timestamp] AddActivationToUsers: migrated (0.0205s) ====================

Active Recordのコールバックメソッドにより、ユーザーオブジェクト作成前に有効化トークン・有効化ダイジェストが生成されるようにする

before_createコールバック

「オブジェクトの新規作成時にのみ呼び出される」というコールバックです。引数として、メソッド名を表すシンボルを指定(あるいは実行する処理の内容をブロックとして直接記述)します。

今回は、「有効化トークン・有効化ダイジェストを生成する処理(create_activation_digestメソッドとします)」をbefore_createコールバックの対象とします。

before_create :create_activation_digest

create_activation_digestメソッド

有効化トークンと、有効化トークンに対応するダイジェストを生成する処理の実体を記述するメソッドです。

private

  def create_activation_digest
    self.activation_token = User.new_token
    self.activation_digest = User.digest(activation_token)
  end

privateメソッドとの関係

create_activation_digestの前にprivateメソッドが呼び出されているのは、一つの大きなポイントです。Userモデルのbeforeフィルターで呼び出されるメソッドは、Userモデル内でしか使わないので、Userクラス内でprivateメソッドを呼び出して以降に記述すべきとされています。そういえば、UsersコントローラーにStrong Parametersやbeforeフィルターを実装した際にもprivateメソッドを使いましたよね。

有効化トークンと、有効化トークンに対応するダイジェストを生成する処理の実体

表題記載の処理の実体は、以下2つの処理となります。

self.activation_token = User.new_token
self.activation_digest = User.digest(activation_token)
User#rememberメソッドとの類似点と相違点

類似する処理として、永続cookiesに関する記憶トークンと記憶ダイジェストを生成するUser#rememberメソッドを、Railsチュートリアル本文の第9章で定義していました。

User#remember
def remember
  self.remember_token = User.new_token
  update_attribute(:remember_digest, User.digest(remember_token))
end

User#new_tokenメソッドの使い方は、rememberでもcreate_activation_digestでも同じです。

一方、User#digestメソッドの使い方は、remembercreate_activation_digestで異なります。

  • rememberにおけるUser#digestメソッドは、update_attributeメソッドの引数として呼び出している
    • すでにRDB上に存在するユーザー情報を対象とするため
  • create_activation_digestにおけるUser#digestメソッドは、実行結果をself.activation_digestに代入している
    • まだRDB上に存在しないユーザー情報を対象とするため
    • RDB上にユーザー情報が生成されるのは、create_activation_digestが呼び出されたである

Userモデルへの実装の追加・変更の全体像

Userモデルへの実装の追加・変更の全体像は、以下のようになります。なお、既存の「メールアドレスをすべて小文字にする」という処理も、downcase_emailというメソッドを呼び出す実装に変更しています。

app/models/user.rb
  class User < ApplicationRecord
-   attr_accessor :remember_token,
+   attr_accessor :remember_token, :activation_token
-   before_save { email.downcase! }
+   before_save :downcase_email
+   before_create :create_activation_digest
    ...略
+
+   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

この時点でテストは全て成功するはず

特に「RDBに格納されるデータにおいて、メールアドレスが全て小文字であるか」というテストが成功することは、再度確認が必要です。

test/models/user_test.rb(57行目)
test "email addresses should be saved as lower-case" do
# rails test test/models/user_test.rb:57
Running via Spring preloader in process 13346
Started with run options --seed 25681

  12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.20554s
1 tests, 1 assertions, 0 failures, 0 errors, 0 skips

「RDBに格納されるデータにおいて、メールアドレスが全て小文字であるか」というテストは、無事成功しました。

続いて全体のテストを実行してみましょう。

# rails test
Running via Spring preloader in process 13361
Started with run options --seed 7529

  43/43: [=================================] 100% Time: 00:00:06, Time: 00:00:06

Finished in 7.00017s
43 tests, 183 assertions, 0 failures, 0 errors, 0 skips

こちらも無事成功しました。

サンプルユーザーの生成とテスト

サンプルユーザーを最初から有効にしておく

db/seeds.rb
  User.create(name:                 "Example User",
              email:                "example@railstutorial.org",
              password:             "foobar",
              password_confirmation: "foobar",
-             admin: true)
+             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)
+                 password_confirmation: password,
+                 activated: true,
+                 activated_at: Time.zone.now)
  end

fixtureのユーザーを最初から有効にしておく

test/fixtures/users.yml
  rhakurei:
    name: Reimu Hakurei
    email: rhakurei@example.com
    password_digest: <%= User.digest('password') %>
    admin: true
+   activated: true
+   activated_at: Time.zone.now

  mkirisame:
    name: Marisa Kirisame
    email: example.example@example.org
    password_digest: <%= User.digest('password') %>
+   activated: true
+   activated_at: Time.zone.now

  skomeiji:
    name: Satori Komeiji
    email: example_example@example.net
    password_digest: <%= User.digest('password') %>
+   activated: true
+   activated_at: Time.zone.now

  rusami:
    name: Renko Usami
    email: example0@example.com
    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
Dropped database 'db/development.sqlite3'
Dropped database 'db/test.sqlite3'
Created database 'db/development.sqlite3'
Created database 'db/test.sqlite3'
...略
== [timestamp] AddActivationToUsers: migrated (0.0165s) ====================

# rails db:seed
# 

「サンプルデータの入力におけるtypo」というのは案外やってしまいがちなので、この場面は結構緊張する場面です。今回は何事もなく完了しました。

演習 - AccountActivationのデータモデル

1. 本項での変更を加えた後、テストスイートがgreenのままになっていることを確認してみましょう。

# rails test
Running via Spring preloader in process 13392
Started with run options --seed 56186

  43/43: [=================================] 100% Time: 00:00:04, Time: 00:00:04

Finished in 4.90673s
43 tests, 183 assertions, 0 failures, 0 errors, 0 skips

特にfixtureの変更時におけるtypoには注意が必要です。

2.1. コンソールからUserクラスのインスタンスを生成し、そのオブジェクトからcreate_activation_digestメソッドを呼び出そうとすると (Privateメソッドなので) NoMethodErrorが発生することを確認してみましょう。

# rails console --sandbox

>> user = User.first

>> user.create_activation_digest
Traceback (most recent call last):
        1: from (irb):2
NoMethodError (private method `create_activation_digest' called for #<User:0x00007fdd8823ae98>)
Did you mean?  restore_activation_digest!

2.2. また、そのUserオブジェクトからダイジェストの値も確認してみましょう。

>> user.activation_digest
=> "$2a$10$Q.bVywQrrgEJC6Mg0IhdXONY5M/0jQYm4/ZEBwhJfxcag7VBBx6S6"

3.1. downcase!メソッドを使って、リスト 11.3downcase_emailメソッドを改良してみてください。

リスト 6.34で、メールアドレスの小文字化にはemail.downcase!という (代入せずに済む) メソッドがあることを知りました。

User#downcase_email
  def downcase_email
-   self.email = email.downcase
+   self.email.downcase!   
  end

3.2. また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。

# rails test test/models/user_test.rb:57
Running via Spring preloader in process 13416
Started with run options --seed 43627

  12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.23544s
1 tests, 1 assertions, 0 failures, 0 errors, 0 skips
# rails test
Running via Spring preloader in process 13429
Started with run options --seed 15928

  43/43: [=================================] 100% Time: 00:00:04, Time: 00:00:04

Finished in 4.22996s
43 tests, 183 assertions, 0 failures, 0 errors, 0 skips
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした