パスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみた

こんにちは!kossyです!




今回はパスワードリセット機能を提供するdeviseのrecoverableモジュールのソースコードを追ってみたので、

備忘録としてブログに残してみたいと思います。




環境
Ruby 2.6.9
Rails 6.0.4
MacOS BigSur



createアクション

まずはcreateアクションのソースコードから読んでみます。

github.com

  # 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

github.com

# 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年前のコードが現役で動いていることに感動、、、またまた余談でした。

make User#send_reset_password_instructions to require all authenticat… · heartcombo/devise@850afec · GitHub

さて、recordを引いてくる部分のメソッドを読んでみます。


find_first_by_auth_conditions

github.com

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行目に突入です、、、

github.com

# 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

github.com

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に保存しています。

返り値は先ほど訳したコメントアウトにもある通り、トークン(encryptedされてない生の)が返っています。


send_reset_password_instructions_notification

github.com

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の処理はこちら。

github.com

これでようやく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?というメソッド名からどんな処理か想像はつきますが、見に行ってみましょう。

github.com

# 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をどのように使うかは以下の記事が詳しかったです。

techracho.bpsinc.jp

noticeの値に応じて表示するフラッシュメッセージを変えているようでした。


まとめ

Devise.paranoidは知らない機能でした、、、コード読んでおいてよかった。

個人的には Time.now.utc も気になりました。アプリケーションのタイムゾーン運用によっては軽く問題になるような気もします。

recoverableモジュール入れるだけでパスワードリセットできるやん!viewもちゃちゃっとカスタマイズできるし!とか思ってましたが、

裏でいろんなことをdeviseがやってくれていたということを改めて認識する良い機会になりました。