最近は個人アプリ開発だと 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によるハッシュ化、パスワードチェック機能を実装する(デフォルトではxxx
はpassword
) - メモリ上へのパスワードおよび確認用パスワードを一時データを保持するための属性として、
password
、password_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_cost
は ActiveModel::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_ALLOWED
は 72
となっており、これはBCryptの上限 72 bytes(ASCIIなどの1byte文字なら 72文字)が理由となっています。
また、ここで validation_confirmation_of
があることで、 password_confirmation
属性が追加されています。
まとめ
has_secure_password
自体はかなりシンプルな実装ではありますが、Rubyっぽい実装を読むことができるのではないかと思いました。
気になるメソッドやクラス、モジュールの実装を読んでみると新たな発見がありそうですね。