こんにちは!kossyです!
さて、今回はdevise_token_authの update password の仕組みを理解するため、ソースコードを読んでみたのでブログに残してみたいと思います。
バージョン
devise_token_auth 1.1.5
devise_token_authのパスワード更新はどのcontrollerが担う?
devise_token_authのパスワード更新は、DeviseTokenAuth::PasswordsControllerが担っていました。
まず、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の中身も見ます。
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で設定できる値で、「パスワードリセットしてから再設定可能な有効期限」でした。
リセットパスワードの送信日時とパスワードリセットしてから再設定可能な有効期限を比べて、期限内であれば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!で変更を保存します。
失敗した場合は例外を投げていますね。
一通り仕組みについて理解できました。問題に遭遇してから念入りにコードを読むのもいいですが、
ライブラリを使う前にコードに目を通して、仕組みを理解してから使う方がトラブルシューティングにかかる時間が減りそうで、個人的にはおすすめしたいですね。
この記事がどなたかのお役に立てれば幸いです。