Account activations resource

接下來我們要建立帳號啟動的 resource,這個 resource 不會對應 Active Record model,但會把相關的資料(啟動 token 和啟動狀態)存在 User model 裡。此外,要透過標準的 REST URL 處理帳號啟動的操作。因為啟動連結會更改使用者的啟動狀態,計畫使用 edit action。

或許使用 update action 會更合理,但是啟動連結要在 email 中發送,應該是個普通的點擊動作,發送的請求是 GET,而不是向 update 發送 PATCH 請求。

所以要建立一個 Account Activations controller:

$ rails generate controller AccountActivations --no-test-framework

注意,這裡使用了一個標記(flag)來忽略產生測試文件,因為我們不需要 controller 的測試,之後會使用整合測試。

在啟動的 email 中需要產生以下的網址形式:

edit_account_activation_url(activation_token, ...)

表示我們需要建立一個具名路由,給 edit action 使用:

config/routes.rb

Rails.application.routes.draw do
  root                'static_pages#home'
  get    'help'    => 'static_pages#help'
  get    'about'   => 'static_pages#about'
  get    'contact' => 'static_pages#contact'
  get    'signup'  => 'users#new'
  get    'login'   => 'sessions#new'
  post   'login'   => 'sessions#create'
  delete 'logout'  => 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
end

接下來我們需要一個唯一的 activation token 來啟用帳戶。之前的密碼、remember token 和重設密碼需要考慮很多安全問題,因為如果駭客取得這些資訊就能控制使用者的帳號。帳號啟動不必這麼麻煩,但如果不使用 hash 過的 activation token 帳號也有一定的危險。所以參考 remember token 的做法,我們會公開 token,並在資料庫裡儲存 hash digest。這麼做的話,就可以使用以下方法來獲取 activation token:

user.activation_token

然後使用下列程式碼驗證使用者:

user.authenticated?(:activation, token)

不過之後我們得先修改 authenticated? 方法。

我們也要在 User model 增加一個 boolean 屬性 activated,會自動產生 activated? 方法讓我們檢查使用者是否已啟動:

if user.activated? ...

最後,雖然不會用在本書教學上,但也會增加紀錄啟動的日期和時間,方便日後有需要可以使用,User model 如下:

現在就來建立遷移檔案,把上述說到的屬性都加進 User model 裡:

$ rails generate migration add_activation_to_users \
> activation_digest:string activated:boolean activated_at:datetime

注意這裡有使用「\」折行。

像之前的 admin 屬性一樣,我們要為 activated 屬性加上預設的 boolean 值:false

db/migrate/[timestamp]_add_activation_to_users.rb

class AddActivationToUsers < ActiveRecord::Migration
  def change
    add_column :users, :activation_digest, :string
    add_column :users, :activated, :boolean, default: false
    add_column :users, :activated_at, :datetime
  end
end

然後執行遷移:

$ bundle exec rake db:migrate

由於新註冊的使用者都必須經過啟動驗證,所以我們必須在使用者物件被建立之前,指派 activation token 和 digest 給使用者物件。

之前有遇過類似的例子,也就是在儲存使用者進資料庫前,要先把 email 轉成小寫格式,在那個範例中,我們使用了 before_save callback 和 downcase 方法搭配使用;before_save callback 會自動在物件被儲存前呼叫,包含要建立物件或更新物件(creation and updates)。

不過現在我們只需要物件在被建立之前呼叫 callback,所以會使用 before_create callback:

before_create :create_activation_digest

這叫做 method reference,Rails 會自動找出 create_activation_digest 方法,然後在建立使用者之前,執行這個方法。在之前我們使用 before_save 時,是傳一個 block 給它,不過 method reference 是比較推薦的做法。因為 create_activation_digest 方法只會在 User model 內部使用,不需要公開,所以把這個方法放在 private 底下:

private

  def create_activation_digest
    # Create the token and digest.
  end

所有定義在 private 之後的方法都會自動被隱藏,由以下 console 可見:

$ rails console
>> User.first.create_activation_digest
NoMethodError: private method `create_activation_digest' called for #<User>

before_create callback 的作用是要指派 token 和相對應的 digest,之後會用下列方式實現:

self.activation_token  = User.new_token
self.activation_digest = User.digest(activation_token)

上述的程式碼用到了之前實作 remember token 的 token、digest 方法,我們可以把上述程式碼和 remember 方法比較一下:

# Remembers a user in the database for use in persistent sessions.
def remember
  self.remember_token = User.new_token
  update_attribute(:remember_digest, User.digest(remember_token))
end

不同的地方在於 remember 方法使用了 update_attribute 方法更新屬性值。因為 remember token 和 remember digest 建立的時候,使用者已經存在於資料庫中,而 before_create callback 是發生在建立使用者之前。有了這個 callback 後,使用 User.new 建立使用者時,就會自動產生 activation_tokenactivation_digest 屬性。而且 activation_digest 對應到資料庫裡的欄位,所以儲存使用者時會自動把屬性值存進資料庫。

綜上所述,User model 如下,因為 activation token 是虛擬屬性,所以增加第二個 attr_accessor,注意我們還把 email 轉成小寫的 callback 改成 method reference:

app/models/user.rb

class User < ActiveRecord::Base
  attr_accessor :remember_token, :activation_token
  before_save   :downcase_email
  before_create :create_activation_digest
  validates :name,  presence: true, length: { maximum: 50 }
  .
  .
  .
  private

    # Converts email to all lower-case.
    def downcase_email
      self.email = email.downcase
    end

    # Creates and assigns the activation token and digest.
    def create_activation_digest
      self.activation_token  = User.new_token
      self.activation_digest = User.digest(activation_token)
    end
end

繼續之前,我們要更新 seed data 和 fixtures,這樣樣本資料和測試資料才會在一開始就已經是啟動狀態。Time.zone.now 方法是 Rails 內建的輔助方法,基於伺服器使用的時區,回傳目前的 timestamp:

db/seeds.rb

User.create!(name:  "Example User",
             email: "[email protected]",
             password:              "foobar",
             password_confirmation: "foobar",
             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,
              activated: true,
              activated_at: Time.zone.now)
end

test/fixtures/users.yml

michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
  activated: true
  activated_at: <%= Time.zone.now %>

archer:
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>
  activated: true
  activated_at: <%= Time.zone.now %>

malory:
  name: Malory Archer
  email: boss@example.gov
  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 %>

然後重設資料庫:

$ bundle exec rake db:migrate:reset
$ bundle exec rake db:seed