ユーザー認証のテスト(5) -- パスワード情報の保存

2013/09/08

前回から、ユーザー認証を行うクラスメソッド Customer.authenticate の実装を始めました。今回は、その続きです。

エグザンプルを追加

spec/models/customer_spec.rb を次のように修正してください:

# (省略)

describe Customer, '.authenticate' do
  let(:customer) { FactoryGirl.create(:customer, username: 'taro', password: 'correct_password') }

  specify 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do
    result = Customer.authenticate(customer.username, 'correct_password')
    expect(result).to eq(customer)
  end

  specify 'パスワードが一致しない場合はnilを返す' do
    result = Customer.authenticate(customer.username, 'wrong_password')
    expect(result).to be_nil
  end

  specify '該当するユーザー名が存在しない場合はnilを返す' do
    result = Customer.authenticate('hanako', 'any_string')
    expect(result).to be_nil
  end
end

Customer.authenticate のためのエグザンプルグループにエグザンプルを2つ追加しました。意味は説明するまでもないでしょう。specify メソッドの引数に仕様が書いてあります。

テストの実行結果は次の通り:

Failures:

  1) Customer.authenticate パスワードが一致しない場合はnilを返す
     Failure/Error: expect(result).to be_nil
       expected: nil
            got: #<Customer id: 25, username: "taro", family_name: "山田", given_name: "太郎", ...>
     # ./spec/models/customer_spec.rb:77:in `block (2 levels) in <top (required)>'

パスワード情報を保存するためのカラムを追加

パスワードが一致しているかどうかを調べるためには、何らかの方法でパスワードの情報をデータベースに保存する必要があります。例によって保存形式の詳細は決めずに、テストを通すことだけを目標にして実装を進めましょう。

まず、パスワード情報を保存するためのカラム password_digest カラムを customers テーブルに追加します。

db/migrate/..._create_customers.rb を次のように修正してください(5行目を追加)。

class CreateCustomers < ActiveRecord::Migration
  def change
    create_table :customers do |t|
      t.string :username, null: false
      t.string :password_digest
      t.string :family_name, null: false
      t.string :given_name, null: false
      t.string :family_name_kana, null: false
      t.string :given_name_kana, null: false

      t.timestamps
    end
  end
end

パスワードが設定されていない状態も考えられるので、NULL 値を許容することにします。

そして、マイグレーションをやり直します。

$ rake db:migrate:reset
$ rake db:test:prepare

テストを通す

さあ、テストを通しましょう。パスワードの保存形式の検討は後回しにしましたので、最も単純な実装方法を採用します。password_digest カラムに平文のパスワードをそのまま保存するという方法です。

いろんな実装方法があると思いますが、私がたどり着いたのは次のコードです(app/models/customer.rb):

require 'nkf'

class Customer < ActiveRecord::Base
  attr_accessor :password

  validates :family_name, :given_name, :family_name_kana, :given_name_kana,
    presence: true, length: { maximum: 40 }
  validates :family_name, :given_name,
    format: { with: /\A[\p{Han}\p{Hiragana}\p{Katakana}]+\z/, allow_blank: true }
  validates :family_name_kana, :given_name_kana,
    format: { with: /\A\p{Katakana}+\z/, allow_blank: true }

  before_validation do
    self.family_name = NKF.nkf('-w', family_name) if family_name
    self.given_name = NKF.nkf('-w', given_name) if given_name
    self.family_name_kana = NKF.nkf('-wh2', family_name_kana) if family_name_kana
    self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
  end

  before_save do
    self.password_digest = password
  end

  class << self
    def authenticate(username, password)
      customer = find_by_username(username)
      if customer && password == customer.password_digest
        customer
      else
        nil
      end
    end
  end
end

20-22行と27-31行が追加され、26行目の先頭に customer = が挿入されています。

これでテストが通ります。

パスワード情報の保存に関するエグザンプルを追加

続いて、パスワード情報の保存機能の実装に入ります。定番の bcrypt-ruby を採用します。

spec/models/customer_spec.rb を次のように修正してください:

# (省略)

describe Customer, 'password=' do
  let(:customer) { build(:customer, username: 'taro') }

  specify '生成されたpassword_digestは60文字' do
    customer.password = 'any_string'
    customer.save!
    expect(customer.password_digest).not_to be_nil
    expect(customer.password_digest.size).to eq(60)
  end

  specify '空文字を与えるとpassword_digestはnil' do
    customer.password = ''
    customer.save!
    expect(customer.password_digest).to be_nil
  end
end

describe Customer, '.authenticate' do
  # (省略)
end

bcrypt-ruby が生成するパスワード情報は60バイトの文字列であるという情報は事前につかんでいるという想定です。以前に挙げた仕様案には、空のパスワードに関する記述はありませんでしたが、空文字をパスワードに指定しても無視することにします。

テストの実行結果は次の通り:

Failures:

  1) Customer password= 生成されたpassword_digestは60文字
     Failure/Error: expect(customer.password_digest.size).to eq(60)
       
       expected: 60
            got: 10
       
       (compared using ==)
     # ./spec/models/customer_spec.rb:74:in `block (2 levels) in <top (required)>'

  2) Customer password= 空文字を与えるとpassword_digestはnil
     Failure/Error: expect(customer.password_digest).to be_nil
       expected: nil
            got: ""
     # ./spec/models/customer_spec.rb:80:in `block (2 levels) in <top (required)>'

テストを通す

2番目の失敗は簡単に直ります。app/models/customer.rbbefore_save ブロックを次のように修正します:

  before_save do
    self.password_digest = password if password.present?
  end

次に bcrypt-ruby を使用するため、Gemfile から次のような箇所を探して、

# gem 'bcrypt-ruby', '~> 3.0.0'

行頭のコメント記号を除去し、bundle コマンドを実行します。そして、app/models/customer.rb を次のように変更すればテストが通ります:

require 'nkf'
require 'bcrypt'

class Customer < ActiveRecord::Base
  attr_accessor :password

  validates :family_name, :given_name, :family_name_kana, :given_name_kana,
    presence: true, length: { maximum: 40 }
  validates :family_name, :given_name,
    format: { with: /\A[\p{Han}\p{Hiragana}\p{Katakana}]+\z/, allow_blank: true }
  validates :family_name_kana, :given_name_kana,
    format: { with: /\A\p{Katakana}+\z/, allow_blank: true }

  before_validation do
    self.family_name = NKF.nkf('-w', family_name) if family_name
    self.given_name = NKF.nkf('-w', given_name) if given_name
    self.family_name_kana = NKF.nkf('-wh2', family_name_kana) if family_name_kana
    self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
  end

  before_save do
    self.password_digest = BCrypt::Password.create(password) if password.present?
  end

  class << self
    def authenticate(username, password)
      customer = find_by_username(username)
      if customer && BCrypt::Password.new(customer.password_digest) == password
        customer
      else
        nil
      end
    end
  end
end

2行目が挿入されています。また、22行目と28行目が変更されています。

穴を埋める

以上で完成のようですが、実は穴があります。パスワードが未設定の場合はどうなるでしょうか。その場合のエグザンプルがありません。

spec/models/customer_spec.rb を次のように修正してください:

# (省略)

describe Customer, '.authenticate' do
  let(:customer) { FactoryGirl.create(:customer, username: 'taro', password: 'correct_password') }

  specify 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do
    result = Customer.authenticate(customer.username, 'correct_password')
    expect(result).to eq(customer)
  end

  specify 'パスワードが一致しない場合はnilを返す' do
    result = Customer.authenticate(customer.username, 'wrong_password')
    expect(result).to be_nil
  end

  specify '該当するユーザー名が存在しない場合はnilを返す' do
    result = Customer.authenticate('hanako', 'any_password')
    expect(result).to be_nil
  end

  specify 'パスワード未設定のユーザーを拒絶する' do
    customer.update_column(:password_digest, nil)
    result = Customer.authenticate(customer.username, '')
    expect(result).to be_nil
  end
end

案の定、テストが落ちます:

Failures:

  1) Customer.authenticate パスワード未設定のユーザーを拒絶する
     Failure/Error: result = Customer.authenticate(customer.username, '')
     BCrypt::Errors::InvalidHash:
       invalid hash
     # ./app/models/customer.rb:28:in `new'
     # ./app/models/customer.rb:28:in `authenticate'
     # ./spec/models/customer_spec.rb:104:in `block (2 levels) in <top (required)>'

テストを通すには、app/models/customer.rb を次のように変更する必要があります。

require 'nkf'
require 'bcrypt'

class Customer < ActiveRecord::Base
  # (省略)

  class << self
    def authenticate(username, password)
      customer = find_by_username(username)
      if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
        customer
      else
        nil
      end
    end
  end
end

次回は

以上でクラスメソッド Customer.authenticate の実装は完了です。次回は、spec/features/login_and_logout_spec.rb で使用されているスタブを外します。では、また。