devise_token_authの update password のソースコードを読んでみる

こんにちは!kossyです!




さて、今回はdevise_token_authの update password の仕組みを理解するため、ソースコードを読んでみたのでブログに残してみたいと思います。



バージョン

devise_token_auth 1.1.5




devise_token_authのパスワード更新はどのcontrollerが担う?

devise_token_authのパスワード更新は、DeviseTokenAuth::PasswordsControllerが担っていました。

github.com

まず、editアクションからソースコードを追ってみます。

editアクション

コード全容です。

    # this is where users arrive after visiting the password reset confirmation link
    def edit
      # if a user is not found, return nil
      @resource = resource_class.with_reset_password_token(resource_params[:reset_password_token])

      if @resource && @resource.reset_password_period_valid?
        token = @resource.create_token unless require_client_password_reset_token?

        # ensure that user is confirmed
        @resource.skip_confirmation! if confirmable_enabled? && !@resource.confirmed_at
        # allow user to change password once without current_password
        @resource.allow_password_change = true if recoverable_enabled?

        @resource.save!

        yield @resource if block_given?

        if require_client_password_reset_token?
          redirect_to DeviseTokenAuth::Url.generate(@redirect_url, reset_password_token: resource_params[:reset_password_token])
        else
          redirect_header_options = { reset_password: true }
          redirect_headers = build_redirect_headers(token.token,
                                                    token.client,
                                                    redirect_header_options)
          redirect_to(@resource.build_auth_url(@redirect_url,
                                               redirect_headers))
        end
      else
        render_edit_error
      end
    end

最上部のコメントアウトは、

これは、ユーザーがパスワードリセット確認リンクにアクセスした後に到着する場所です

とのことでした。

before_actionも定義されていました。まずはそこから追ってみましょう。

module DeviseTokenAuth
  class PasswordsController < DeviseTokenAuth::ApplicationController
    before_action :validate_redirect_url_param, only: [:create, :edit]

    # 以下省略

validate_redirect_url_paramメソッドのコードはこちら。

def validate_redirect_url_param
  # give redirect value from params priority
  @redirect_url = params.fetch(
    :redirect_url,
    DeviseTokenAuth.default_password_reset_url
  )

  return render_create_error_missing_redirect_url unless @redirect_url
  return render_error_not_allowed_redirect_url if blacklisted_redirect_url?(@redirect_url)
end

paramsの値からのredirect_urlを優先的に返すとのことです。

DeviseTokenAuth.default_password_reset_urlの中身はなんでしょうか。
コンソールから実行してみます。

$ DeviseTokenAuth.default_password_reset_url
=> nil

私の環境ではnilが返りました。
ここでドキュメントを見に行ってみます。

By default this value is expected to be sent by the client so that the API knows where to redirect users after successful password resets.
If this param is set, the API will redirect to this value when no value is provided by the client.

デフォルトでは、この値はクライアントによって送信されることが期待されているため、パスワードが正常にリセットされた後、APIはユーザーをリダイレクトする場所を認識します。
このパラメーターが設定されている場合、クライアントから値が提供されないと、APIはこの値にリダイレクトします。

出典: https://devise-token-auth.gitbook.io/devise-token-auth/config/initialization

なるほど、基本的にはparamsで送信されてくることを期待しているため、特に設定を加えない限りはnilが返るという話なんですね。

@redirect_urlがnilの場合はrender_create_error_missing_redirect_urlが呼ばれているので、コードを見て見ます。

def render_create_error_missing_redirect_url
  render_error(401, I18n.t('devise_token_auth.passwords.missing_redirect_url'))
end

redirect_urlが見つからないぞ!というエラーをレンダリングするメソッドでした。
render_error_not_allowed_redirect_urlメソッドの方はメソッド名から察するに、許可されていないURLだぞ!というエラーをレンダリングするメソッドでしょう。解説は割愛します。

validate_redirect_url_paramメソッドのリーディングが終わったので、editアクションの中身を見てみます。

@resource = resource_class.with_reset_password_token(resource_params[:reset_password_token])

resource_classとはなんでしょうか。定義箇所は DeviseTokenAuth::ApplicationControllerでした。

def resource_class(m = nil)
  if m
    mapping = Devise.mappings[m]
  else
    mapping = Devise.mappings[resource_name] || Devise.mappings.values.first
  end

  mapping.to
end

以前set_user_by_tokenメソッドのコードリーディングをした時も見かけたコードでした。
kossy-web-engineer.hatenablog.com

$ user = Devise.mapping(:user).to.find(1).class
=> User

Deviseを利用しているモデル名を引いてくることができるメソッドですね。

with_reset_password_tokenの中身も見ます。

github.com

def with_reset_password_token(token)
  reset_password_token = Devise.token_generator.digest(self, :reset_password_token, token)
  to_adapter.find_first(reset_password_token: reset_password_token)
end

まずDevise.token_generatorをコンソールで見てみる。

$ Devise.token_generator
=> #<Devise::TokenGenerator:0x000055cbcd8dea48
 @digest="SHA256",
 @key_generator=
  #<ActiveSupport::CachingKeyGenerator:0x000055cbcd8deae8
   @cache_keys=#<Concurrent::Map:0x000055cbcd8deac0 entries=1 default_proc=nil>,
   @key_generator=
    #<ActiveSupport::KeyGenerator:0x000055cbcd8deb38
     @iterations=65536,
     @secret=
      "d56c1ff7e5d31b3858576fc49c18d842116cb895067c65ecb9429547673cf8783be86fc2d02e4a0bdc584aa654fd225a37184bceb4077db8a41c9c93a2b31481">>>

名前から察するに認証Tokenを作成するクラスのインスタンスが返っていますね。

digestメソッドを見てみます。

def digest(klass, column, value)
  value.present? && OpenSSL::HMAC.hexdigest(@digest, key_for(column), value.to_s)
end

OpenSSL::HMACがいまいちピンときていないので調べます。

HMAC とは MAC(message authentication code, ハッシュ関数(MD5やSHAなど) と鍵の文字列をパラメータとするハッシュ関数)の一種です。

digestメソッドは、渡された digest と key を用いて data の HMAC を計算し、その値をバイナリ文字列として返します。
digest には利用するハッシュ関数を表す文字列("md5", "sha256" など) を渡します。

出典: https://docs.ruby-lang.org/ja/latest/class/OpenSSL=3a=3aHMAC.html

なんとなくですが理解できました。

to_adaptorとfind_firstを試してみます。

$  User.to_adapter
=> #<OrmAdapter::ActiveRecord:0x000055cbcd309aa0
 @klass=
  User(id: integer, ...

$ User.to_adapter.find_first
=> #<User id: 1, provider: "email", ...

$ User.to_adapter.find_first(reset_password_token: reset_password_token)
=> #<User id: 1, provider: "email", ...

to_adaptorは現在処理中の認証クラスを呼んで、find_firstは引数を渡さないとテーブルの中で最初のレコードを引いて、引数が渡されるとその引数でテーブルを走査するようです。

なので、

@resource = resource_class.with_reset_password_token(resource_params[:reset_password_token]) 

は、reset_password_tokenを用いて、deviseを利用しているモデルのテーブルを検索し、該当するレコードがあればそれを引いてくる処理でした。

次の行の処理をみます。

if @resource && @resource.reset_password_period_valid?

@resourceが存在し、かつ@resource.reset_password_period_valid?の返り値がtrueであれば、という条件ですね。
reset_password_period_valid?メソッドはどんな処理でしょうか。一旦コンソールで試してみます。

$ user = User.first
$ user.reset_password_period_valid?
=> nil

コンソールから試すとnilが返って、何をしているのかわからんのでコードを読んでみます。

user.method(:reset_password_period_valid?).source_location
=> ["/usr/local/bundle/gems/devise-4.7.3/lib/devise/models/recoverable.rb", 77]

定義場所はdeviseのrecoverable.rbの77行目とのことなので、見に行きます。

devise/recoverable.rb at master · heartcombo/devise · GitHub

def reset_password_period_valid?
  reset_password_sent_at && reset_password_sent_at.utc >= self.class.reset_password_within.ago.utc
end

reset_password_sent_atはdeviseを導入する際に定義されるカラムです。

reset_password_withinはconfigで設定できる値で、「パスワードリセットしてから再設定可能な有効期限」でした。

qiita.com

リセットパスワードの送信日時とパスワードリセットしてから再設定可能な有効期限を比べて、期限内であればtrueを返すというメソッドのようですね。

私の環境で試してnilが返ったのは、そもそもreset_password_sent_atに値が入っていないからでした。試しにreset_password_sent_atを無理やり突っ込んでからreset_password_period_valid?を実行してみます。

# configで6.hoursと設定しているため
$ user.class.reset_password_within
=> 6 hours

# 試しに7時間前に設定
$  user.update!(reset_password_sent_at: Time.now.ago(7.hours))

# 期限切れなのでfalseが返る
$ user.reset_password_period_valid?
=> false

# 5時間前にしてみる
$ user.update!(reset_password_sent_at: Time.now.ago(5.hours))

# 期限内なのでtrueが返る
$ user.reset_password_period_valid?
=> true

完全に動作の理解ができました。なんでも試すのは正義ですね。

まだまだ行きます。

token = @resource.create_token unless require_client_password_reset_token?

require_client_password_reset_token?を読む必要がありそう。

def require_client_password_reset_token?
  DeviseTokenAuth.require_client_password_reset_token
end

configファイルで設定する値の話ですね。Docを見ます。

By default, the password-reset confirmation link redirects to the client with valid session credentials as querystring params.
With this option enabled, the redirect will NOT include the valid session credentials. Instead the redirect will include a password_reset_token querystring param that can be used to reset the users password.
Once the user has reset their password, the password-reset success response headers will contain valid session credentials.

デフォルトでは、パスワードリセット確認リンクは、クエリ文字列パラメータとして有効なセッション資格情報を使用してクライアントにリダイレクトします。
このオプションを有効にすると、リダイレクトに有効なセッション資格情報が含まれなくなります。
代わりに、リダイレクトには、ユーザーのパスワードをリセットするために使用できるpassword_reset_token query string paramが含まれます。
ユーザーがパスワードをリセットすると、パスワードリセット成功応答ヘッダーに有効なセッション資格情報が含まれます。

出典: https://devise-token-auth.gitbook.io/devise-token-auth/config/initialization

リダイレクトに有効なセッション資格情報が含まれなくしたい場合にtrueにするとよさそうですね。

デフォルトはfalseなので、editアクションにアクセスが来たら、create_tokenメソッドが実行されて新たなtokenが生成されるとみていいでしょう。

次の行いきます。

# ensure that user is confirmed
@resource.skip_confirmation! if confirmable_enabled? && !@resource.confirmed_at

!@resource.confirmed_atはconfirmed_atがnilであればtrueが返ります。

confirmable_enabled?メソッドを見てみます。DeviseTokenAuth::ApplicationControllerに定義されてました。

def confirmable_enabled?
  resource_class.devise_modules.include?(:confirmable)
end

deviseを使っているモデルがconfirmableモジュールをincludeしているかどうかを判定してました。

つまり、confirmableモジュールがincludeされていて、かつ確認済みでない場合は、skip_confirmation!が実行されます。

skip_confirmation!メソッドもみます。

devise/confirmable.rb at master · heartcombo/devise · GitHub

def skip_confirmation!
  self.confirmed_at = Time.now.utc
end

confirmed_atに値を入れるだけの割と単純なメソッドでした。
こういうものもメソッドにして1つのメソッドの責務を分割している感じが好きです。

まだまだいきます。

@resource.allow_password_change = true if recoverable_enabled?

recoverable_enabled?は先ほどのコードリーディングのおかげで、何をしているのかの推測が付けられますね。
deviseを使っているモデルがrecoverableモジュールをincludeしていれば、trueが帰るのでしょう。

allow_password_changeはdeviseを導入する際に定義されるカラムの一つです。

ようやく終わりが見えてきました。

if require_client_password_reset_token?
  redirect_to DeviseTokenAuth::Url.generate(@redirect_url, reset_password_token: resource_params[:reset_password_token])
else
  redirect_header_options = { reset_password: true }
  redirect_headers = build_redirect_headers(token.token,
                                            token.client,
                                            redirect_header_options)
  redirect_to(@resource.build_auth_url(@redirect_url,
                                        redirect_headers))
end

DeviseTokenAuth::Url.generateはredirect用のURLを生成するメソッドですね。

build_redirect_headersメソッドをみます。これもDeviseTokenAuth::ApplicationControllerに定義されてました。

def build_redirect_headers(access_token, client, redirect_header_options = {})
  {
    DeviseTokenAuth.headers_names[:"access-token"] => access_token,
    DeviseTokenAuth.headers_names[:"client"] => client,
    :config => params[:config],

    # Legacy parameters which may be removed in a future release.
    # Consider using "client" and "access-token" in client code.
    # See: github.com/lynndylanhurley/devise_token_auth/issues/993
    :client_id => client,
    :token => access_token
  }.merge(redirect_header_options)
end

リダイレクト用のheaderを生成していました。

build_auth_urlを見てみます。

def build_auth_url(base_url, args)
  args[:uid]    = uid
  args[:expiry] = tokens[args[:client_id]]['expiry']

  DeviseTokenAuth::Url.generate(base_url, args)
end

DeviseTokenAuth::Concerns::Userに定義されているので、uidやtokensはdeviseを利用しているモデルに生えたカラムです。

ここでもDeviseTokenAuth::Url.generateが登場していますが、先ほど渡していた引数とはargsの渡し方が異なっていますね。

render_edit_errorは例外を返すメソッドでした。

editアクションのリーディングはここまでで、次に本丸のupdateアクションを見てみます。


updateアクション

コード全容です。

def update
  # make sure user is authorized
  if require_client_password_reset_token? && resource_params[:reset_password_token]
    @resource = resource_class.with_reset_password_token(resource_params[:reset_password_token])
    return render_update_error_unauthorized unless @resource

    @token = @resource.create_token
  else
    @resource = set_user_by_token
  end

  return render_update_error_unauthorized unless @resource

  # make sure account doesn't use oauth2 provider
  unless @resource.provider == 'email'
    return render_update_error_password_not_required
  end

  # ensure that password params were sent
  unless password_resource_params[:password] && password_resource_params[:password_confirmation]
    return render_update_error_missing_password
  end

  if @resource.send(resource_update_method, password_resource_params)
    @resource.allow_password_change = false if recoverable_enabled?
    @resource.save!

    yield @resource if block_given?
    return render_update_success
  else
    return render_update_error
  end
end

疲れてきましたがあと少し頑張ります。

if require_client_password_reset_token? && resource_params[:reset_password_token]
  @resource = resource_class.with_reset_password_token(resource_params[:reset_password_token])
  return render_update_error_unauthorized unless @resource

  @token = @resource.create_token
else
  @resource = set_user_by_token
end

先ほども確認したrequire_client_password_reset_token?が登場しています。
require_client_password_reset_token?がtrueでパスワードリセット用のTokenがあれば、
@resourceが未定義なら例外をレンダリングして、create_tokenしてます。
else句ではset_user_by_tokenを呼んでますね。このメソッドの中身については以前ブログに書いたので、ここでは解説は割愛します。

次!

unless @resource.provider == 'email'
  return render_update_error_password_not_required
end

providerはdeviseを使っているモデルに生えているカラムです。
その値がemailでない場合は、例外を投げています。
make sure account doesn't use oauth2 provider と記載があるので、パスワード変更しようとしているアカウントがoauthを使ってないことを確認する処理ですね。

次が最後の処理です。

if @resource.send(resource_update_method, password_resource_params)
  @resource.allow_password_change = false if recoverable_enabled?
  @resource.save!

  yield @resource if block_given?
  return render_update_success
else
  return render_update_error
end

resource_update_methodの中身をみます。

def resource_update_method
  allow_password_change = recoverable_enabled? && @resource.allow_password_change == true || require_client_password_reset_token?
  if DeviseTokenAuth.check_current_password_before_update == false || allow_password_change
    'update'
  else
    'update_with_password'
  end
end

deviseを使っているモデルがrecoverableモジュールをincludeしていて、かつパスワードの変更が許可されているorrequire_client_password_reset_token?がtrueであれば、
allow_password_change変数にtrueが代入されています。

次の行のDeviseTokenAuth.check_current_password_before_updateはconfigの設定っぽいですね。

If config.check_current_password_before_update is set to :attributes the current_password param is checked before any update,
if it is set to :password the current_password param is checked only if the request updates user password.

config.check_current_password_before_updateが :attributesに設定されている場合、current_password paramは更新前にチェックされ、
:passwordに設定されている場合、current_password paramは、要求がユーザーパスワードを更新する場合にのみチェックされます。

出典: https://devise-token-auth.gitbook.io/devise-token-auth/usage

理解できました。特に設定を加えない場合はfalseが返るようです。

$ DeviseTokenAuth.check_current_password_before_update
=> false

sendメソッドでupdateまたはupdate_with_passwordメソッドを実行して、成功した場合はallow_password_changeをfalseにし、save!で変更を保存します。
失敗した場合は例外を投げていますね。



一通り仕組みについて理解できました。問題に遭遇してから念入りにコードを読むのもいいですが、
ライブラリを使う前にコードに目を通して、仕組みを理解してから使う方がトラブルシューティングにかかる時間が減りそうで、個人的にはおすすめしたいですね。
この記事がどなたかのお役に立てれば幸いです。