What is it, naokirin?

Ruby on Rails の has_secure_password のコードを読んでみる

最近は個人アプリ開発だと OpenID Connect などで済ませてしまうかもしれませんが、Ruby on Railsで直接パスワードを記録する際にデフォルトで利用できる has_secure_password メソッドの実装を読んでみました。

読んだのは Rails v5.2.3 になります。

has_secure_password とは

コードを読み始める前に has_secure_password とは何かということについてまとめておきます。

ActiveModel::SecurePassword::ClassMethods

has_secure_password は、 BCrypt なパスワードを設定および認証するメソッドを追加してくれるメソッドです。

仕様としては大まかに以下となります。

  • bcrypt gem を利用して生パスワードをハッシュ化する
  • デフォルトでは xxx_digest という属性に対して自動的にBCryptによるハッシュ化、パスワードチェック機能を実装する(デフォルトでは xxxpassword
  • メモリ上へのパスワードおよび確認用パスワードを一時データを保持するための属性として、 passwordpassword_confirmation を追加する

ちなみにBCryptは、簡単に言うと不可逆で暗号として利用可能なハッシュ生成アルゴリズム、及びそれを利用したパスワードのハッシュ化ライブラリを指します。

それでは、has_secure_password の上記の仕様をどのように達成しているのか見ていきます。

has_secure_password の実装を読んでみる

コードの全体像は以下のリンク先にあります。

https://github/rails/rails/blob/v5.2.3/activemodel/lib/active_model/secure_password.rb

部分部分に区切って読んでいきます。

bcrypt gem を読み込む

まずは bcrypt gem を読み込んでいる部分から読んでみます。

def has_secure_password(options = {})
  # Load bcrypt gem only when has_secure_password is used.
  # This is to avoid ActiveModel (and by extension the entire framework)
  # being dependent on a binary library.
  begin
    require "bcrypt"
  rescue LoadError
    $stderr.puts "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
    raise
  end

has_secure_password 利用時のみ読み込むようになっており、読み込めない場合にはエラーメッセージを表示して、例外を送出しています。

モジュール内などでも特定のメソッドのみで利用されるgemの読み込みは、このような実装もありですね。もちろん、ロード時の処理が重いなどで先読みしておきたいといった場合はこのようなやり方はできないと思います。

メソッドを定義する

次はメソッドを定義する部分ですが、モジュールに切り分けられて、それを include するようになっています。

include InstanceMethodsOnActivation

では、 InstanceMethodsOnActivation モジュールの実装を見てみます。

authenticate メソッド

def authenticate(unencrypted_password)
  BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
end

authenticate メソッドでは、 BCrypt::Password を利用して、生パスワードのテキストとBCryptで変換済みの保存されているデータを比較しています。

比較して合致していない場合は false 、合致していた場合には自身のオブジェクトを返しています。

password, password_confirmation 属性

attr_reader :password

# Encrypts the password into the +password_digest+ attribute, only if the
# new password is not empty.
#
#   class User < ActiveRecord::Base
#     has_secure_password validations: false
#   end
#
#   user = User.new
#   user.password = nil
#   user.password_digest # => nil
#   user.password = 'mUc3m00RsqyRe'
#   user.password_digest # => "$2a$10$4LEA7r4YmNHtvlAvHhsYAeZmk/xeUVtMTYqwIvYY76EW5GUqDiP4."
def password=(unencrypted_password)
  if unencrypted_password.nil?
  self.password_digest = nil
elsif !unencrypted_password.empty?
  @password = unencrypted_password
  cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
  self.password_digest = BCrypt::Password.create(unencrypted_password, cost: cost)
end
end

def password_confirmation=(unencrypted_password)
  @password_confirmation = unencrypted_password
end

attr_reader により、読み取り専用の属性 password を定義しています。

password_confirmation= メソッドは、インスタンス変数 @password_confirmation に代入しているだけです。

password= メソッドでは、パスワードをインスタンス変数に入れていますが、同時にBCryptで変換したパスワードを password_digest に代入しています。

このとき、 cost というものを計算しているので、その点について少し説明します。

BCryptのコストの切り替え

まず、この cost ですが、BCryptにおける、暗号化の計算回数(2cost 回)を示しています。回数が増えるほど解読困難になりますが、処理にかかる時間や負荷は高くなります。

次に、 ActiveModel::SecurePassword.min_cost ですが、 min_cost は通常は false に設定されていますが、 min_costActiveModel::Railtie でテスト環境時のみ、 true に設定されるようになっています。

initializer "active_model.secure_password" do
  ActiveModel::SecurePassword.min_cost = Rails.env.test?
end

Railtie の仕組みについては詳細はここでは説明しませんが、この実装によりテスト時はコストを掛けずにBCryptを実行するようにしています。

妥当性検証

最後に、妥当性検証をオフにしていない限り、パスワードの存在、文字列長、確認用パスワードの入力との比較による妥当性検証を行います。

if options.fetch(:validations, true)
  include ActiveModel::Validations
  
  # This ensures the model has a password by checking whether the password_digest
  # is present, so that this works with both new and existing records. However,
  # when there is an error, the message is added to the password attribute instead
  # so that the error message will make sense to the end-user.
  validate do |record|
    record.errors.add(:password, :blank) unless record.password_digest.present?
  end
  
  validates_length_of :password, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
  validates_confirmation_of :password, allow_blank: true
end

MAX_PASSWORD_LENGTH_ALLOWED72 となっており、これはBCryptの上限 72 bytes(ASCIIなどの1byte文字なら 72文字)が理由となっています。

また、ここで validation_confirmation_of があることで、 password_confirmation 属性が追加されています。

まとめ

has_secure_password 自体はかなりシンプルな実装ではありますが、Rubyっぽい実装を読むことができるのではないかと思いました。

気になるメソッドやクラス、モジュールの実装を読んでみると新たな発見がありそうですね。