ユーザー認証のテスト(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.rb
の before_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
で使用されているスタブを外します。では、また。