devise-securityのpassword_expirableのコードを読む

こんにちは!kossyです!




さて、今回はdevise-securityのpassword_expirableのソースコードを読んでみたので、
ブログに残してみたいと思います。



環境

devise-security 0.15.0




コメントアウト部分の翻訳

まずはコメントアウト部分を読んでみます。

PasswordExpirable makes passwords expire after a configurable amount of time, or on demand.

PasswordExpirableは、設定可能な時間が経過した後、またはオンデマンドでパスワードを期限切れにします。

Set expire_password_after to the number of seconds a password is valid for (example: +3.months+). 
Setting it to +true+ will allow passwords to be expired on-demand only, and +false+ disables this feature.

expire_password_afterを、パスワードが有効な秒数に設定します(例:3.months)。 
true に設定すると、パスワードはオンデマンドでのみ期限切れになり、falseはこの機能を無効にします。

This is useful to force users to change passwords for complex business reasons.
Call need_change_password to indicate a record needs a new password.

これは、複雑なビジネス上の理由でユーザーにパスワードの変更を強制する場合に役立ちます。
need_change_passwordを呼び出して、レコードに新しいパスワードが必要であることを示します。

・パスワードに有効期限を設定できること
・その設定はconfigファイルで行えること
・有効期限は自由に設定できること
ユースケースについて

が述べられていました。


scope

いくつかのscopeと、before_saveコールバックが定義されていました。

    included do
      scope :with_password_change_requested, -> { where(password_changed_at: nil) }
      scope :without_password_change_requested, -> { where.not(password_changed_at: nil) }
      scope :with_expired_password, -> { where('password_changed_at is NULL OR password_changed_at < ?', expire_password_after.seconds.ago) }
      scope :without_expired_password, -> { without_password_change_requested.where('password_changed_at >= ?', expire_password_after.seconds.ago) }
      before_save :update_password_changed
    end

scopeは特に複雑な処理を行っているわけでは無いようなので、before_saveコールバックに指定された update_password_changeメソッドを見に行きます。

    # Update +password_changed_at+ for new records and changed passwords.
    # @note called as a +before_save+ hook
    def update_password_changed
      if defined?(will_save_change_to_attribute?)
        return unless (new_record? || will_save_change_to_encrypted_password?) && !will_save_change_to_password_changed_at?
      else
        return unless (new_record? || encrypted_password_changed?) && !password_changed_at_changed?
      end

      self.password_changed_at = Time.zone.now
    end

Rails5.1から使うことができるwill_save_change_to_attribute?メソッドがpassword_expirableをincludeしたモデルに定義されているかどうかで、
条件分岐を行っています。なので、実質的にRails 5.1以上かそうでないかで分岐をしているような処理ですね。

新規のレコードまたはencrypted_passwordの値が変わらなかった場合かつ、password_changed_atの値の変更が無かった場合は
処理を途中で終了し、そうでない場合はpassword_changed_atにTime.zone.nowで算出した値を代入しています。

このメソッドがbefore_saveコールバックで呼ばれているので、password_expirableモジュールをincludeしているモデルは、
レコードが保存される前に条件に合致すればpassword_chaned_atにTime.zone.nowの値が入るようになります。

その他のメソッド

  • need_password_change?
    # Is a password change required?
    # @return [Boolean]
    # @return [true] if +password_changed_at+ has not been set or if it is old
    #   enough based on +expire_password_after+ configuration.
    def need_change_password?
      password_change_requested? || password_too_old?
    end

パスワードの変更が必要であればtrueを、そうでなければfalseを返すメソッドです。

具体的な処理は別のメソッドに切り出されているので、見てみます。

  • password_change_requested?
    # When +password_changed_at+ is set to +NULL+ in the database
    # the user is required to change their password.  This only happens
    # on demand or when the column is first added to the table.
    # @return [Boolean]
    def password_change_requested?
      return false unless password_expiration_enabled?
      return false if new_record?

      password_changed_at.nil?
    end

早期returnを使って、新規作成のレコードの場合はfalseを返しています。
password_changed_atがnilであればtrue、そうでなければfalseを返していました。

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

  • password_expiration_enabled?
    # Enabled if configuration +expire_password_after+ is set to an {Integer},
    # {Float}, or {true}
    def password_expiration_enabled?
      expire_password_after.is_a?(1.class) ||
        expire_password_after.is_a?(Float) ||
        expire_password_on_demand?
    end

expire_password_afterで設定されている値が意図したものであるか、を判定していますね。
expire_password_on_demand?はメソッドかと思いますので、見てみます。

  • expire_password_on_demand?
    def expire_password_on_demand?
      expire_password_after.present? && expire_password_after == true
    end

expire_password_afterに値が設定されていて、かつその値がtrueの場合にtrueが返るメソッドですね。

改めてpassword_change_request?メソッドを見ると、

・expire_password_afterで設定されている値が意図したものでない時はfalseを返す
・新規作成のレコードの場合はfalseを返す
・password_changed_atがnilならtrue、値があればfalseを返す

というメソッドでした。

ではpassword_too_old?メソッドを見てみます。

  • password_too_old?
    # Is this password older than the configured expiration timeout?
    # @return [Boolean]
    def password_too_old?
      return false if new_record?
      return false unless password_expiration_enabled?
      return false if expire_password_on_demand?

      password_changed_at < expire_password_after.seconds.ago
    end
    alias password_expired? password_too_old?

新規作成のレコードか、expire_password_afterに意図した値が入っていないか、expire_password_afterが有効になっていると、falseが返ります。

上記の条件に合致しない場合は、password_changed_atがexpire_password_after.seconds.agoよりも過去の場合はtrueが、そうでなければfalseが返ります。

名前の通り、パスワードが古いかどうかを判定するメソッドでしたね。

これでneed_change_password?メソッドの処理を把握することができました。

同名のメソッドで!がついたものと何も無いものがありましたので、その2つのメソッドも見てみます。

    # Clear the +password_changed_at+ field so that the user will be required to
    # update their password.
    # @note Saves the record (without validations)
    # @return [Boolean]
    def need_change_password!
      return unless password_expiration_enabled?

      need_change_password
      save(validate: false)
    end
    alias expire_password! need_change_password!
    alias request_password_change! need_change_password!

    # Clear the +password_changed_at+ field so that the user will be required to
    #   update their password.
    # @note Does not save the record
    # @return [void]
    def need_change_password
      return unless password_expiration_enabled?

      self.password_changed_at = nil
    end
    alias expire_password need_change_password
    alias request_password_change need_change_password

need_change_passwordメソッドはpassword_expirationが有効でなければ処理を途中で終わらせて、
有効ならpassword_changed_atをnilにしていました。

need_password_change!メソッドはneed_password_changeを呼び出して、saveメソッドをバリデーションを実行せずに呼んでいました。

どちらもそれほど複雑なことはしていませんが、メソッドの責務を切り分けて処理を細かくしているのがいいですね。

これでpassword_expirableの内部実装については概ね把握できました。



偉大なる本家リポジトリ

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