パスワードリセット時に前回と同じパスワードを設定できないようにする

この記事について

本記事は、2023年10月に社内で実施した勉強会の内容をもとに、外部向けに加筆・再構成したものです。
記載の内容は執筆当時の情報であり、現在の仕様やベストプラクティスと異なる可能性があります。
実装にあたっては、必ず最新の公式ドキュメントをご確認いただくようお願いいたします。

目次

はじめに

最近担当したプロジェクトにて「パスワードリセット時に前回と同じパスワードを設定できないようにする」という対応を行いました。
一見よくある仕様で簡単に実装できそうに思いましたが、認証にdeviseを使用しているシステムではいくつか注意すべき点がありました。
本記事では、その実装時に得た知見をまとめています。

1. どうやってチェックをするか

今回の仕様を実現するには、「新しく設定しようとしているパスワードが、現在のパスワードと同一でないこと」を確認する必要があります。
当然ですがDB上に保存されているパスワードは暗号化されたパスワード(encrypted_password)なので、当初は「入力されたパスワードを暗号化して、DBに保存しているencrypted_passwordと一致するかをチェックすれば良い」と思っていました。

しかし、この方法はDeviseでは使用できません。
Deviseで暗号化されたパスワードは、同じ文字列であっても毎回異なる値として暗号化される仕組みになっているためです。

irb(main):001:0> user = User.new(password: 'pass')
=> #<User id: nil, email: "", created_at: nil, updated_at: nil, name: nil>
irb(main):002:0> user.encrypted_password
=> "$2a$12$bIna6/B3pRYdzS/xjHEwFunnOnAIl7k.zQaiJ4Xk47PtCfGNE9Nie"
irb(main):003:0> user = User.new(password: 'pass')
=> #<User id: nil, email: "", created_at: nil, updated_at: nil, name: nil>
irb(main):004:0> user.encrypted_password
=> "$2a$12$BiRe3dyCD2sd9gsLPzdKbe4MKPGwBWJBqBNF7qpQQoqxXjJXsrPp2"

deviseには valid_password? というメソッドが用意されており、 これを使うと入力されたパスワードが正しいかをチェックすることができます。

irb(main):001:0> user = User.new(password: 'pass')
=> #<User id: nil, email: "", created_at: nil, updated_at: nil, name: nil>
irb(main):002:0> user.encrypted_password
=> "$2a$12$B.SmKIssGe6aIOSQwXK/Ze.LIK1Y.9c4SsHtbh28rE2IqECGCCA/O"
irb(main):003:0> user.valid_password? 'pass'
=> true
irb(main):004:0> user.valid_password? 'pass0101'
=> false

参考URL:
plataformatec. (n.d.). Devise::Models::DatabaseAuthenticatable: valid_password?. RubyDoc.(2023年10月閲覧)
https://www.rubydoc.info/github/plataformatec/devise/Devise%2FModels%2FDatabaseAuthenticatable:valid_password%3F

この valid_password? を使用し、新しく設定しようとしているパスワードに対して valid_password? = true が返る場合は、現在のパスワードと同一と判断してエラーとするようにしました。

2. どこでチェックするか

今回の仕様を実現するためには、valid_password? を用いて、新しく設定しようとしているパスワードを現在の encrypted_password で検証する必要があります。
入力されたパスワードを元に新しいencrypted_passwordに書き換えられた後では、期待するチェックを行うことができません。

encrypted_password は、password をセットした時点で上書きされてしまいます。

module Devise
  module Models
		module DatabaseAuthenticatable
      extend ActiveSupport::Concern
			・・・
				def password=(new_password)
				  @password = new_password
				  self.encrypted_password = password_digest(@password) if @password.present?
				end

参考URL:
Heartcombo. (n.d.). Devise: lib/devise/models/database_authenticatable.rb (Lines 65–68) [Computer software]. GitHub.(2023年10月閲覧)
https://github.com/heartcombo/devise/blob/main/lib/devise/models/database_authenticatable.rb#L65-L68

2-1. passwordのsetterでチェック

Modelにバリデーションチェックを追加

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  def password=(new_password)
    if new_password.present? && valid_password?(new_password)
      self.errors.add(:password, '現在のパスワードと同じものは、新しいパスワードとして設定できません。')
      raise ActiveRecord::RecordInvalid, self
    end
    super(new_password)
  end
end

Controller側でエラー発生時の処理を追加

class PasswordsController < Devise::PasswordsController
  def update
    super
  rescue ActiveRecord::RecordInvalid => e
    self.resource = e.record
    self.resource.reset_password_token = resource_params[:reset_password_token]
    respond_with self.resource
  end
end

2-2. validateでチェック

検討を重ねた結果、よりシンプルな方法があることに気づきました。
valid_password? 内部で使用されている Devise::Encryptor.compare を直接利用し、バリデーションチェックを行う方法です。

encrypted_password_was を使用して、変更前の encrypted_password と比較します。

Modelにバリデーションチェックを追加

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  validate :valid_reset_password

  def valid_reset_password
    if password.present? && Devise::Encryptor.compare(self.class, encrypted_password_was, password)
      errors.add(:password, '現在のパスワードと同じものは、新しいパスワードとして設定できません。')
    end
  end
end

2-3. パスワードリセットの時だけチェックする方法

Modelでチェックを行うと、「パスワード変更時には現在のパスワードと同じものを設定できない」ように統一できますが、「パスワードリセット時のみ」など、特定のControllerに限定した制御には適していません。

パスワードリセット時のみチェックを行いたい場合は、以下のような実装方法が考えられます。

Modelではチェックメソッドを追加

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  def valid_reset_password
    if password.present? && Devise::Encryptor.compare(self.class, attribute_before_last_save(:encrypted_password), password)
      errors.add(:password, '現在のパスワードと同じものは、新しいパスワードとして設定できません。')
    end
  end
end

controllerでチェックメソッドを実行する

Devise::PasswordsControllerupdate アクションでは、ブロックを渡して resource(= user)に対して処理を行うことができるようです。
ただし、resource はすでに各種データの更新処理を終えた後に呼び出されるため、transaction で処理を囲み、必要に応じてロールバックできるようにしました。

class PasswordsController < Devise::PasswordsController
  def update
    ActiveRecord::Base.transaction do
      super do |resource|
        resource.valid_reset_password 
				if resource.errors.present?
          self.resource.reset_password_token = resource_params[:reset_password_token]
                    return respond_with self.resource
        end
      end
    end
  end
end

調査したところ、既存の gem を利用する方法もありました。

検索ワード例:
DEVISE パスワード 再設定 バリデーション 前回

参考URL:
kossy-web-engineer’s Blog, kossy-web-engineer, Devise パスワード再設定時に前回と同じパスワードを禁止する方法(2023年10月閲覧)
https://kossy-web-engineer.hatenablog.com/entry/2021/10/16/132139

参考URL:
関連gemのリポジトリ(2023年10月閲覧)
https://github.com/devise-security/devise-security

参考URL:
Qiita, kenjiszk, deviseで使用しているbcryptによるencrypted_passwordの挙動(2023年10月閲覧)
https://qiita.com/kenjiszk/items/d38e398bc120cf4bbd1c#ハッシュ値の中身

補足情報:deviseの暗号化と複合化の仕組み

参考URL:
Zenn, Matsukura Yuki, deviseはパスワードをどのように安全に保管しているか? (2023年10月閲覧)https://zenn.dev/minedia/articles/devise-password



この文字列 $2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu は、複数のコンポーネントで構成されています。

  • Scheme (2a) – ハッシュの生成に使用される bcrypt() アルゴリズムのバージョン
  • Cost (12) – ハッシュの作成に使用されるコスト係数
  • Salt (GfOja7i1byocYP7XuANk9O) – パスワードと組み合わせると一意になるランダムな文字列 (22文字)
  • Checksum (qyiE8KJPzG439mk0kZKB1rmggsxOHFu) – 保存されている実際のハッシュ部分 (31文字)

復号

  1. 入力されたパスワードを取得します(例:ThisIsWeakPassword)
  2. 保存されているパスワードのSaltを取得します(例:$2a$12$GfOja7i1byocYP7XuANk9O)
  3. 同じBcryptバージョンとコスト係数を使用して、パスワードとSaltからハッシュを生成します
    (BCrypt::Engine.hash_secret('ThisIsWeakPassword', "$2a$12$GfOja7i1byocYP7XuANk9O"))
  4. 保存されているハッシュがステップ3で計算されたものと同じかどうかを検証します
    ($2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu)
役に立ったらシェアしていただけると嬉しいです
  • URLをコピーしました!
  • URLをコピーしました!
目次