deviseでパスワードが正しいか未定義かを確認する方法とvalid_password?のコードリーディング

こんにちは!kossyです!




さて、今回は認証機能を提供するRubyのGemである「devise」を使っている際に、
パスワードが正しいか未定義かを確認する方法と、valid_password?メソッドのコードを読んでみたので、
ブログに残してみたいと思います。





deviseを使って認証機能を提供しているシステムの場合、
CSチームからこんな問い合わせが来たりしませんか?

「そのお客さん、このパスワードで設定したはずなんですけど、ログインできなくて、、、
パスワードがちゃんと設定されてるか試してもらえませんか?」

deviseを普通に使っていればDBにはハッシュ化されたパスワードが保存されるため、
DBを見てもパスワードはわかりません。

しかし、「このパスワード」が正しいかどうかは判定することができます。

valid_password?メソッドを使えば判定ができます。


# パスワードが正しい時
valid_password?("test1234")
=> true

# パスワードが間違っている時
valid_password?("test4321")
=> false

# パスワードが未定義の時
valid_password?("test4321")
=> nil

パスワードが正しければ true が、間違っていれば false が、パスワードが設定されていない(レアケースですが、、、)場合は、 nil が返却されます。

これだけで終わりにするには味気ないので、ソースコードを追ってみましょう。


Devise::Encryptor.compare

valid_password?メソッドの定義はこちらです。

# Verifies whether a password (ie from sign in) is the user password.
def valid_password?(password)
  Devise::Encryptor.compare(self.class, encrypted_password, password)
end

まずはDevise::Encryptor.compareメソッドを追ってみます。

Devise::Encryptor.compareメソッドの定義元はこちら


    def self.compare(klass, hashed_password, password)
      return false if hashed_password.blank?
      bcrypt   = ::BCrypt::Password.new(hashed_password)
      if klass.pepper.present?
        password = "#{password}#{klass.pepper}"
      end
      password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
      Devise.secure_compare(password, hashed_password)
    end
  end

早期リターン文は説明不要ですかね。

BCrypt::Passwordクラスの中身を見てみましょう。
と思ったら外部のGemの処理みたいです。


    def initialize(raw_hash)
      if valid_hash?(raw_hash)
        self.replace(raw_hash)
        @version, @cost, @salt, @checksum = split_hash(self)
      else
        raise Errors::InvalidHash.new("invalid hash")
      end
    end

うーんいまいちわからんので動かしてみましょうか。

bcrypt = BCrypt::Password(User.first.encrypted_password)

bcrypt.version
=> "2a"

bcrypt.cost
=> 12

bcrypt.salt
=> "$2a$12$U5Yc/wGr2vQMBixITdTcxe"

bcrypt.checksum
=> "LHVc35YygLEHakakiOmSOX.1h6zivK6"

この数字の意味するところは

https://yuskamiya.tumblr.com/post/100503173956/bcrypt-blowfish

のブログが詳しかったです。

要はBCrypt::Passwordのinitializeで渡されたパスワードをBCrypt暗号化しているようです。


klass.pepper

次はklass.pepper.present?のコードです。

if klass.pepper.present?
  password = "#{password}#{klass.pepper}"
end

klass.pepperが存在していれば、引数で渡されたpaswordにklass.pepperを結合させて新たなpasswordとしています。

pepperとはなんでしょうか。

平文パスワードにあらかじめ設定されている文字列(pepper)をくっつけ、結合された文字列をハッシュ化する。
さらに、ランダムに生成されたハッシュ(salt)を追加して、encrypted_passwordとする。
平文パスワードに塩こしょうをして暗号化するという洒落。

とのことで、特に設定を行っていないとpepperはかかりません。

残りのコードを読んでみます。

password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt)
Devise.secure_compare(password, hashed_password)

BCrypt::Engine.hash_secretはパスワードのハッシュ化を行っているコードですね。
Devise.secure_compareはちゃんと中身みてみましょう。

  # constant-time comparison algorithm to prevent timing attacks
  def self.secure_compare(a, b)
    return false if a.blank? || b.blank? || a.bytesize != b.bytesize
    l = a.unpack "C#{a.bytesize}"

    res = 0
    b.each_byte { |byte| res |= byte ^ l.shift }
    res == 0
  end

ゴリゴリにRubyのメソッドが使われてますね、、、
「タイミング攻撃を防ぐための一定時間比較アルゴリズム」とコメントアウトがあるので、
セキュアに比較を行うために必要な処理なのだと思います。




deviseの中身は追うのがとても大変ですね、、、見てもいまいちわからんし。
とはいえ、ブラックボックスのままツールを使い続けるのもあまりよろしくないので、
時間を見つけて理解を進めたいなと思います。




勉強になりました。



参考にさせていただいたサイト

この場を借りてお礼を申し上げます。