こんにちは!kossyです!
今回はパスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみたので、
備忘録としてブログに残してみたいと思います。
環境
Ruby 2.6.9
Rails 6.0.4
MacOS BigSur
createアクション
まずはcreateアクションのソースコードから読んでみます。
# POST /resource/password def create self.resource = resource_class.send_reset_password_instructions(resource_params) yield resource if block_given? if successfully_sent?(resource) respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name)) else respond_with(resource) end end
resource_classは User のように、deviseを使って認証を行うモデルのクラスが返ります。
send_reset_password_instructionsはどんな処理でしょうか。
send_reset_password_instructions
# Attempt to find a user by its email. If a record is found, send new # password instructions to it. If user is not found, returns a new user # with an email not found error. # Attributes must contain the user's email # ↓Google翻訳先生に訳してもらいました。↓ # メールでユーザーを見つけようとします。 # レコードが見つかった場合は、そのレコードに新しいパスワードの指示を送信します。 # ユーザーが見つからない場合は、メールが見つかりませんというエラーで新しいユーザーを返します。 # 属性にはユーザーのメールアドレスが含まれている必要があります def send_reset_password_instructions(attributes = {}) recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found) recoverable.send_reset_password_instructions if recoverable.persisted? recoverable end
コメントアウト部分で挙動について丁寧に説明されているので、内部で呼んでいるメソッドについては読まなくてもいいかなと思いつつ、
念のため目を通してみます。
find_or_initialize_with_errors
# Find or initialize a record with group of attributes based on a list of required attributes. # 必要な属性のリストに基づいて、属性のグループを使用してレコードを検索または初期化します。 def find_or_initialize_with_errors(required_attributes, attributes, error = :invalid) #:nodoc: attributes.try(:permit!) attributes = attributes.to_h.with_indifferent_access .slice(*required_attributes) .delete_if { |key, value| value.blank? } if attributes.size == required_attributes.size record = find_first_by_auth_conditions(attributes) and return record end new(devise_parameter_filter.filter(attributes)).tap do |record| required_attributes.each do |key| record.errors.add(key, attributes[key].blank? ? :blank : error) end end end
pry-byebugのstepメソッドで処理の内部に入って、適宜処理を実行して挙動の把握を試みます。
$ attributes = attributes.to_h.with_indifferent_access.slice(*required_attributes).delete_if { |key, value| value.blank? } => {"email"=>"sample@example.com"} $ record = find_first_by_auth_conditions(attributes) and return record => nil $ record = new(devise_parameter_filter.filter(attributes)).tap do |record| required_attributes.each do |key| record.errors.add(key, attributes[key].blank? ? :blank : error) end end $ record => #<User id: nil, email: "keisuke.koshikawa@gmail.com", unique_session_id: nil, created_at: nil, updated_at: nil> $ record.errors => #<ActiveModel::Errors:0x00007f8eec050488 @base=#<User id: nil, email: "keisuke.koshikawa@gmail.com", unique_session_id: nil, created_at: nil, updated_at: nil>, @details={:email=>[{:error=>:not_found}]}, @messages={:email=>["not found"]}>
attrを加工して必要なattrと数が一致していればレコードを引いてきて、適宜errorsオブジェクトにエラーを格納していました。
個人的には加工とエラー格納をprivateメソッドにしてメソッドの粒度を細かくしたいなと読んでて思いましたが余談ですね、、、
しかも12年前のコードが現役で動いていることに感動、、、またまた余談でした。
さて、recordを引いてくる部分のメソッドを読んでみます。
find_first_by_auth_conditions
def find_first_by_auth_conditions(tainted_conditions, opts = {}) to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts)) end
こちらもstepメソッドで適宜実行してみます。
$ tainted_conditions => {"email"=>"sample@example.com"} $ to_adapter => #<OrmAdapter::ActiveRecord:0x00007f8ee0094540 @klass= User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime)> $ devise_parameter_filter => #<Devise::ParameterFilter:0x00007f8ee011b810 @case_insensitive_keys=[:email], @strip_whitespace_keys=[:email]> $ devise_parameter_filter.filter(tainted_conditions) => {"email"=>"sample@example.com"} $ to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts)) => nil From: /usr/local/bundle/gems/orm_adapter-0.5.0/lib/orm_adapter/adapters/active_record.rb:22 OrmAdapter::ActiveRecord#find_first: 21: def find_first(options = {}) => 22: construct_relation(klass, options).first 23: end $ klass => User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, unique_session_id: string, created_at: datetime, updated_at: datetime) $ options => {"email"=>"sample@example.com"} $ construct_relation(klass, options) User Load (5.0ms) SELECT "users".* FROM "users" WHERE "users"."email" = $1 [["email", "sample@example.com"]] => [] From: /usr/local/bundle/gems/orm_adapter-0.5.0/lib/orm_adapter/adapters/active_record.rb:42 OrmAdapter::ActiveRecord#construct_relation: 41: def construct_relation(relation, options) 42: conditions, order, limit, offset = extract_conditions!(options) 43: 44: relation = relation.where(conditions_to_fields(conditions)) 45: relation = relation.order(order_clause(order)) if order.any? 46: relation = relation.limit(limit) if limit 47: relation = relation.offset(offset) if offset 48: =>49: relation 50: end $ relation User Load (5.0ms) SELECT "users".* FROM "users" WHERE "users"."email" = $1 [["email", "sample@example.com"]] => []
最終的にdeviseのコードではないところのコードになりましたが、、、
find_first_by_auth_conditionsメソッドは引数でユーザーの情報を検索するメソッドでした。
find_or_initialize_with_errorsメソッドの処理もこれで一通り把握できました。
send_reset_password_instructions
ようやくself.send_reset_password_instructionsメソッドの2行目に突入です、、、
# Resets reset password token and send reset password instructions by email. # Returns the token sent in the e-mail. # リセットパスワードトークンをリセットし、パスワードのリセット手順を電子メールで送信します。 # 電子メールで送信されたトークンが返り値になります。 def send_reset_password_instructions token = set_reset_password_token send_reset_password_instructions_notification(token) token end
set_reset_password_token
def set_reset_password_token raw, enc = Devise.token_generator.generate(self.class, :reset_password_token) self.reset_password_token = enc self.reset_password_sent_at = Time.now.utc save(validate: false) raw end
encryptedされたtokenをreset_password_tokenとし、パスワードリセットメール送信日時を設定し、バリデーションを実行せずにレコードをDBに保存しています。
send_reset_password_instructions_notification
def send_reset_password_instructions_notification(token) send_devise_notification(:reset_password_instructions, token, {}) end
ここからはstepメソッドで処理を追ってみます。
From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/recoverable.rb:99 Devise::Models::Recoverable#send_reset_password_instructions_notification: 98: def send_reset_password_instructions_notification(token) => 99: send_devise_notification(:reset_password_instructions, token, {}) 100: end $ step From: /usr/local/bundle/gems/devise-4.8.0/lib/devise/models/authenticatable.rb:201 Devise::Models::Authenticatable#send_devise_notification: 200: def send_devise_notification(notification, *args) => 201: message = devise_mailer.send(notification, self, *args) 202: # Remove once we move to Rails 4.2+ only. 203: if message.respond_to?(:deliver_now) 204: message.deliver_now 205: else 206: message.deliver 207: end 208: end $ message = devise_mailer.send(notification, self, *args) => #<ActionMailer::MessageDelivery:0x2b0923acff60>
DeviseのMailerを呼ぶ処理を実行していました。以下のメソッドが実行されるようです。
def reset_password_instructions(record, token, opts = {}) @token = token devise_mail(record, :reset_password_instructions, opts) end
devise_mailの処理はこちら。
これでようやくsend_reset_password_instructionsメソッドも挙動の把握ができました、、、
successfully_sent?
controllerの処理に戻ってこれました、、、もう一度createアクションを記載します。
# POST /resource/password def create self.resource = resource_class.send_reset_password_instructions(resource_params) yield resource if block_given? if successfully_sent?(resource) respond_with({}, location: after_sending_reset_password_instructions_path_for(resource_name)) else respond_with(resource) end end
successfully_sent?というメソッド名からどんな処理か想像はつきますが、見に行ってみましょう。
# Helper for use after calling send_*_instructions methods on a resource. # If we are in paranoid mode, we always act as if the resource was valid # and instructions were sent. # リソースでsend_ * _instructionsメソッドを呼び出した後に使用するヘルパー。 # パラノイドモードの場合、リソースが有効であり、指示が送信されたかのように常に動作します。 def successfully_sent?(resource) notice = if Devise.paranoid resource.errors.clear :send_paranoid_instructions elsif resource.errors.empty? :send_instructions end if notice set_flash_message! :notice, notice true end end
Devise.paranoidをどのように使うかは以下の記事が詳しかったです。
noticeの値に応じて表示するフラッシュメッセージを変えているようでした。