過去に使用されたパスワードの再設定を制限するdevise-securityの「PasswordArchivable」のソースコードを追ってみる

こんにちは!kossyです!




今回は、過去に使用されたパスワードの再設定を制限するdevise-securityの「PasswordArchivable」のソースコードを追う機会があったので、ブログに残してみたいと思います。





偉大なる本家レポジトリ





環境
Ruby 2.6.8
Rails 6.0.4.1
devise-security 0.16.0
DockerCompose 1.27.0



archive_password

ソースコードを見たところ、PasswordArchivableモジュールをincludeすると、関連やコールバック、バリデーションが定義されるようでしたので、まずはコールバックから読んでみます。

      # 以下の3つの関連やコールバック、バリデーションが定義される

      included do
        has_many :old_passwords, class_name: 'OldPassword', as: :password_archivable, dependent: :destroy
        before_update :archive_password, if: :will_save_change_to_encrypted_password?
        validate :validate_password_archive, if: :password_present?
      end

archive_passwordのソースコードは以下です。

private

# Archive the last password before save and delete all to old passwords from archive
# @note we check to see if an old password has already been archived because
#   mongoid will keep re-triggering this callback when we add an old password
def archive_password
  if max_old_passwords.positive?
    return true if old_passwords.where(encrypted_password: encrypted_password_was).exists?

    old_passwords.create!(encrypted_password: encrypted_password_was) if encrypted_password_was.present?
    old_passwords.reorder(created_at: :desc).offset(max_old_passwords).destroy_all
  else
    old_passwords.destroy_all
  end
end

コメントアウト部分を意訳してみます。

Archive the last password before save and delete all to old passwords from archive.

@note we check to see if an old password has already been archived because mongoid will keep re-triggering this callback when we add an old password.

保存する前に最後のパスワードをアーカイブし、アーカイブから古いパスワードをすべて削除します。

古いパスワードを追加すると、mongoidがこのコールバックを再トリガーし続けるため、古いパスワードがすでにアーカイブされているかどうかを確認します。

パスワード更新処理の前に更新前のパスワードをアーカイブ(= old_passwordsテーブルへレコードを追加)し、古いパスワード(= old_passwordsテーブルに記録済みのレコード)を全て削除する処理のようです。

まずはmax_old_passwordsから読んでみます。

# @return [Integer] max number of old passwords to store and check
def max_old_passwords
  case deny_old_passwords
  when true
    [1, archive_count].max
  when false
    0
  else
    deny_old_passwords.to_i
  end
end

def deny_old_passwords
  self.class.deny_old_passwords
end

def archive_count
  self.class.password_archiving_count
end

deny_old_passwordsメソッドは、config/devise_security.rbで設定したフラグを返すメソッドでした。

config/devise-security.rb

  # Deny old passwords (true, false, number_of_old_passwords_to_check)
  # Examples:
  # config.deny_old_passwords = false # allow old passwords
  # config.deny_old_passwords = true # will deny all the old passwords
  # config.deny_old_passwords = 3 # will deny new passwords that matches with the last 3 passwords
  # config.deny_old_passwords = true

trueの場合は全ての旧パスワードの設定をできないようにし、falseの場合は逆に全ての旧パスワードの設定を許可するようになります。
数字を設定した場合は、数字の回数分だけ旧パスワードの設定をできないようにするようです。

一つずつコンソールで試してみます。(前提としてPasswordArchivableをincludeしたUserモデルが定義済みとします)

# deny_old_passwordsが true の場合

$ User.first.update!(password: '12345678')
=> true

$ User.first.update!(password: '87654321')
=> true

$ User.first.update!(password: '12345678')
=> ActiveRecord::RecordInvalid: バリデーションに失敗しました: Passwordは既に使われています
# deny_old_passwordsが false の場合

$ User.first.old_passwords.destroy_all

$ User.first.update!(password: '12345678')
=> true

$ User.first.update!(password: '87654321')
=> true

$ User.first.update!(password: '12345678')
=> true
# deny_old_passwordsが 1 の場合

$ User.first.old_passwords.destroy_all

$ User.first.update!(password: '12345678')
=> true

$ User.first.update!(password: '87654321')
=> true

$ User.first.update!(password: '12345678')
=> ActiveRecord::RecordInvalid: バリデーションに失敗しました: Passwordは既に使われています。

$ User.first.update!(password: '87654321')
=> ActiveRecord::RecordInvalid: バリデーションに失敗しました: Passwordは既に使われています。

$ User.first.update!(password: 'test1234')
=> true

$ User.first.update!(password: '87654321')
=> ActiveRecord::RecordInvalid: バリデーションに失敗しました: Passwordは既に使われています

値に応じて挙動が変わることが確認できました。

deny_old_passwordsは、

過去に設定したことのあるパスワードを全て再設定できないようにしたい場合は true
過去に設定したことのあるパスワードでも設定できるようにしたい場合は false
前回設定したパスワードの設定はできないようにしたい場合は 1

を設定すれば良さそうです。

password_archiving_countは、設定した値までパスワードをアーカイブしておく数値で、こちらもconfig/devise-security.rbで設定した値を呼び出すメソッドでした。

ここでmax_old_passwordsを再掲します。

# @return [Integer] max number of old passwords to store and check
def max_old_passwords
  case deny_old_passwords
  when true
    [1, archive_count].max
  when false
    0
  else
    deny_old_passwords.to_i
  end
end

deny_old_passwordsの値に応じて、保持しておくアーカイブしたパスワードの最大値を返すメソッドでしたね。

validate_password_archive

次にvalidate_password_archiveメソッドを見てみます。

def validate_password_archive
  errors.add(:password, :taken_in_past) if will_save_change_to_encrypted_password? && password_archive_included?
end

password_archive_included?メソッドを見る必要がありそうなので見てみる。

def password_archive_included?
  return false unless max_old_passwords.positive?

  old_passwords_including_cur_change = old_passwords.reorder(created_at: :desc).limit(max_old_passwords).pluck(:encrypted_password)
  old_passwords_including_cur_change << encrypted_password_was # include most recent change in list, but don't save it yet!
  old_passwords_including_cur_change.any? do |old_password|
    # NOTE: we deliberately do not do mass assignment here so that users that
    #   rely on `protected_attributes_continued` gem can still use this extension.
    #   See issue #68
    self.class.new.tap { |object| object.encrypted_password = old_password }.valid_password?(password)
  end
end

self.class.new.tap の部分はPRを見に行くと経緯がわかるっぽいので、見に行ってみます。

github.com

この変更に至った経緯はこのコメントが参考になりそう。

This fixes a bug where devise-security skips the password history check in certain cases,
e.g. using the protected_attributes_continued gem and not having :encrypted_password in attr_accessible.

There are other instances in the codebase where there's mass assignment, but this is a start

これにより、devise-securityが特定の場合にパスワード履歴チェックをスキップするバグが修正されます。
例えば、protected_attributes_continuous gemを使用し、attr_accessibleに:encrypted_passwordがない場合などです。

コードベースの中には他にも大量に割り当てられている例がありますが、これはその手始めです。

validate_password_archiveは、encrypted_passwordが更新対象で、かつpassword_archive_included? が true の場合は パスワードをアーカイブしない処理のようでした。



まとめ

configファイル数行とコールバックとバリデーションを読めば一通り処理の流れは追えてしまうmoduleでした。

devise-securityの別のmoduleであるPasswordExpirableと合わせて、「パスワードの定期変更強制機能 + 過去に使用されたパスワードの再設定の制限」を実装する要件があれば採用してもいいのかなと思いました。

とはいえ「パスワードの定期変更強制機能」については、総務省がパスワードの定期的な変更は不要と明言しているので、要件として指定される機会はそれほどないかもと思ったりもしています。。。

www.soumu.go.jp