この記事について
本記事は、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
endController側でエラー発生時の処理を追加
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
end2-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
endcontrollerでチェックメソッドを実行する
※Devise::PasswordsController の update アクションでは、ブロックを渡して 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文字)
復号
- 入力されたパスワードを取得します(例:
ThisIsWeakPassword) - 保存されているパスワードのSaltを取得します(例:
$2a$12$GfOja7i1byocYP7XuANk9O) - 同じBcryptバージョンとコスト係数を使用して、パスワードとSaltからハッシュを生成します
(BCrypt::Engine.hash_secret('ThisIsWeakPassword', "$2a$12$GfOja7i1byocYP7XuANk9O")) - 保存されているハッシュがステップ3で計算されたものと同じかどうかを検証します
($2a$12$GfOja7i1byocYP7XuANk9OqyiE8KJPzG439mk0kZKB1rmggsxOHFu)
