こんにちは!kossyです!
さて、今回はdevise_token_authのset_user_by_tokenメソッドのコードリーディングをしてみたので、
ブログに残してみたいと思います。
偉大なる本家リポジトリはこちら
なお、前提としてSupervisorというdeviseを利用したモデルが定義されていることとします。
devise_token_authのset_user_by_tokenメソッドはとても長い
以下、コード全部載せます。
# user auth def set_user_by_token(mapping = nil) # determine target authentication class rc = resource_class(mapping) # no default user defined return unless rc # gets the headers names, which was set in the initialize file uid_name = DeviseTokenAuth.headers_names[:'uid'] access_token_name = DeviseTokenAuth.headers_names[:'access-token'] client_name = DeviseTokenAuth.headers_names[:'client'] # parse header for values necessary for authentication uid = request.headers[uid_name] || params[uid_name] @token = DeviseTokenAuth::TokenFactory.new unless @token @token.token ||= request.headers[access_token_name] || params[access_token_name] @token.client ||= request.headers[client_name] || params[client_name] # client isn't required, set to 'default' if absent @token.client ||= 'default' # check for an existing user, authenticated via warden/devise, if enabled if DeviseTokenAuth.enable_standard_devise_support devise_warden_user = warden.user(mapping) if devise_warden_user && devise_warden_user.tokens[@token.client].nil? @used_auth_by_token = false @resource = devise_warden_user # REVIEW: The following line _should_ be safe to remove; # the generated token does not get used anywhere. # @resource.create_new_auth_token end end # user has already been found and authenticated return @resource if @resource && @resource.is_a?(rc) # ensure we clear the client unless @token.present? @token.client = nil return end # mitigate timing attacks by finding by uid instead of auth token user = uid && rc.dta_find_by(uid: uid) scope = rc.to_s.underscore.to_sym if user && user.valid_token?(@token.token, @token.client) # sign_in with bypass: true will be deprecated in the next version of Devise if respond_to?(:bypass_sign_in) && DeviseTokenAuth.bypass_sign_in bypass_sign_in(user, scope: scope) else sign_in(scope, user, store: false, event: :fetch, bypass: DeviseTokenAuth.bypass_sign_in) end return @resource = user else # zero all values previously set values @token.client = nil return @resource = nil end end
うん、長い。
なので細かく切って読んでいきます。
まずは28行目のresource_class(mapping)メソッドの中身をみてみます。
resource_class(mapping)
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
コンソールで実行してみました。
$ mapping = Devise.mapping(:supervisor) => #<Devise::Mapping:0x00007fd079be3218 @class_name="Supervisor", @controllers={:sessions=>"devise_token_auth/sessions", :registrations=>"devise_token_auth/registrations", :passwords=>"devise_token_auth/passwords", :confirmations=>"devise_token_auth/confirmations", :unlocks=>"devise_token_auth/unlocks"}, @failure_app=Devise::FailureApp, @format=nil, @klass=#<Devise::Getter:0x00007fd079be30d8 @name="Supervisor">, @modules=[:database_authenticatable, :rememberable, :recoverable, :registerable, :validatable, :lockable, :trackable], @path="supervisor_auth", @path_names={:registration=>"", :new=>"new", :edit=>"edit", :sign_in=>"sign_in", :sign_out=>"sign_out", :password=>"password", :sign_up=>"sign_up", :cancel=>"cancel", :unlock=>"unlock"}, @path_prefix=nil, @router_name=nil, @routes=[:session, :password, :registration, :unlock], @scoped_path="supervisors", @sign_out_via=:delete, @singular=:supervisor, @used_helpers=[:session, :password, :registration, :unlock], @used_routes=[:session, :password, :registration, :unlock]>
Devise::Mappingクラスのインスタンスが返ることがわかりました。
mappingにtoメソッドを呼び出してみます。
$ mapping.to
=> Supervisor(id: integer, ...)
Supervisorクラスがcallされることがわかりました。
なので、例えば
$ mapping.to.find(1) => Supervisor Load (4.6ms) SELECT `supervisors`.* FROM `supervisors` WHERE `supervisors`.`id` = 1 LIMIT 1 => #<Supervisor id: 1, ...>
のように、ActiveRecordのメソッドを使えるようになっています。なので、
rc = resource_class(mapping)
の「rc」は、mappingで指定したActiveRecordクラスが返ることがわかりました。
DeviseTokenAuth.headers_names[:'...']
次に34 ~ 36行目をコンソールで実行してみます。
# gets the headers names, which was set in the initialize file uid_name = DeviseTokenAuth.headers_names[:'uid'] access_token_name = DeviseTokenAuth.headers_names[:'access-token'] client_name = DeviseTokenAuth.headers_names[:'client']
$ DeviseTokenAuth.headers_names[:'uid'] => "uid" $ DeviseTokenAuth.headers_names[:'access-token'] => "access-token" $ DeviseTokenAuth.headers_names[:'client'] => "client"
認証に必要なheader名が文字列で返ることがわかりました。
38行目 ~ 45行名
# parse header for values necessary for authentication uid = request.headers[uid_name] || params[uid_name] @token = DeviseTokenAuth::TokenFactory.new unless @token @token.token ||= request.headers[access_token_name] || params[access_token_name] @token.client ||= request.headers[client_name] || params[client_name] # client isn't required, set to 'default' if absent @token.client ||= 'default'
このコードもコンソールで実行してみましょう。
requestという変数があるので、authenticate_user!が実行されるcontroller内で適当なアクションにbinding.pryを記述して実行します。
$ uid = request.headers[uid_name] || params[uid_name] => "test+1@gmail.com" # 既に@tokenが存在するので、nilが返っている $ @token = DeviseTokenAuth::TokenFactory.new unless @token => nil # @token.tokenが定義済みなので、値の変更は無かった $ @token.token ||= request.headers[access_token_name] || params[access_token_name] => "N5TYQ0V6xBwwjx-lCXS21w" # こちらも@token.clientが定義済み $ @token.client ||= request.headers[client_name] || params[client_name] => "DQt_mQE0RPabC3CwoYv1RQ"
それほど難しいことはしていない印象ですね。
@tokenが未定義であれば、headersから渡ってきた認証用の情報をインスタンス変数のプロパティとして代入しています。
45行目も@token.clientが定義済みだったので、"default"は代入されません。
47行目 ~ 57行目
# check for an existing user, authenticated via warden/devise, if enabled if DeviseTokenAuth.enable_standard_devise_support devise_warden_user = warden.user(mapping) if devise_warden_user && devise_warden_user.tokens[@token.client].nil? @used_auth_by_token = false @resource = devise_warden_user # REVIEW: The following line _should_ be safe to remove; # the generated token does not get used anywhere. # @resource.create_new_auth_token end end
例によってコンソールです。
$ DeviseTokenAuth.enable_standard_devise_support => false
私の環境だとfalseが返りました。
何をしているのかよくわからないので、コメントアウト部分を訳してみます。
check for an existing user, authenticated via warden/devise, if enabled
有効になっている場合は、warden / deviseを介して認証された既存のユーザーを確認します
この値はどこで有効/無効を切り替えるんでしょうか。config/initializers/devise_token_auth.rbを見に行ってみましょう。
# By default, only Bearer Token authentication is implemented out of the box. # If, however, you wish to integrate with legacy Devise authentication, you can # do so by enabling this flag. NOTE: This feature is highly experimental! # config.enable_standard_devise_support = false
ありました。これも訳してみます。
By default, only Bearer Token authentication is implemented out of the box. If, however, you wish to integrate with legacy Devise authentication, you can do so by enabling this flag. NOTE: This feature is highly experimental!
デフォルトでは、ベアラートークン認証のみがすぐに実装されます。 ただし、従来のDevise認証と統合する場合は、このフラグを有効にすることで統合できます。 注:この機能は非常に実験的です!
なるほど、47行目 ~ 57行目はDeviseを既に使用している場合の考慮の話のようですね。「この機能は非常に実験的です!」と書いてあるので、このパラメータをtrueにするのはちと怖いですね、、、
59行目 ~ 70行目
# user has already been found and authenticated return @resource if @resource && @resource.is_a?(rc) # ensure we clear the client unless @token.present? @token.client = nil return end # mitigate timing attacks by finding by uid instead of auth token user = uid && rc.dta_find_by(uid: uid) scope = rc.to_s.underscore.to_sym
こちらもコンソール
# returnすると処理終わるので、if文以降を試しています $ @resource && @resource.is_a?(rc) => true
英語のコメントアウトにもあるとおり、既に認証済みであれば、returnするようにしていますね。
unless文も、@tokenが存在しなければ処理を終わらせるようにしています。
68行目 ~ 85行目
ようやく終わりが見えてきました。
# mitigate timing attacks by finding by uid instead of auth token user = uid && rc.dta_find_by(uid: uid) scope = rc.to_s.underscore.to_sym if user && user.valid_token?(@token.token, @token.client) # sign_in with bypass: true will be deprecated in the next version of Devise if respond_to?(:bypass_sign_in) && DeviseTokenAuth.bypass_sign_in bypass_sign_in(user, scope: scope) else sign_in(scope, user, store: false, event: :fetch, bypass: DeviseTokenAuth.bypass_sign_in) end return @resource = user else # zero all values previously set values @token.client = nil return @resource = nil end end
英語コメントアウトは、
mitigate timing attacks by finding by uid instead of auth token
認証トークンの代わりにuidで検索することにより、タイミング攻撃を軽減します
とのことでした。ロジックを追ってみます。
$ user = uid && rc.dta_find_by(uid: uid) => #<Supervisor id: 2, ...> $ scope = rc.to_s.underscore.to_sym => :supervisor $ user && user.valid_token?(@token.token, @token.client) => true
valid_token?の中身を見てみます。
app/models/devise_token_auth/concerns/user.rb def valid_token?(token, client = 'default') return false unless tokens[client] return true if token_is_current?(token, client) return true if token_can_be_reused?(token, client) # return false if none of the above conditions are met false end
token_is_current?を見る必要がありそう。
def token_is_current?(token, client) # ghetto HashWithIndifferentAccess expiry = tokens[client]['expiry'] || tokens[client][:expiry] token_hash = tokens[client]['token'] || tokens[client][:token] return true if ( # ensure that expiry and token are set expiry && token && # ensure that the token has not yet expired DateTime.strptime(expiry.to_s, '%s') > Time.zone.now && # ensure that the token is valid DeviseTokenAuth::Concerns::User.tokens_match?(token_hash, token) ) end
expiry(tokenの有効期限)とtokenをHash形式にしたものを取得して、現在日時とexpiryを比較、さらにtokenがmatchするかを見てますね。
tokens_match?メソッドも見てみましょう。
# app/models/devise_token_auth/concerns/user.rb def self.tokens_match?(token_hash, token) @token_equality_cache ||= {} key = "#{token_hash}/#{token}" result = @token_equality_cache[key] ||= DeviseTokenAuth::TokenFactory.token_hash_is_token?(token_hash, token) @token_equality_cache = {} if @token_equality_cache.size > 10000 result end
token_hash_is_token?をみに行こう、、、(疲れた)
def self.token_hash_is_token?(token_hash, token) BCrypt::Password.new(token_hash).is_password?(token) rescue StandardError false end
BCryptで暗号化されたパスワードならtrueが返る感じですね。
かなり追いましたが、token_is_current?メソッドはtokenのexpiryを見て、期限以内のものかどうかをチェックするメソッドでした。
名前から何をするメソッドなのかは容易に想像できましたが、どんな処理なのか知りたくなるのはエンジニアの性ですね。。。
token_can_be_reused?も見ないといかん。
# app/models/devise_token_auth/concerns/user.rb # allow batch requests to use the previous token def token_can_be_reused?(token, client) # ghetto HashWithIndifferentAccess updated_at = tokens[client]['updated_at'] || tokens[client][:updated_at] last_token_hash = tokens[client]['last_token'] || tokens[client][:last_token] return true if ( # ensure that the last token and its creation time exist updated_at && last_token_hash && # ensure that previous token falls within the batch buffer throttle time of the last request updated_at.to_time > Time.zone.now - DeviseTokenAuth.batch_request_buffer_throttle && # ensure that the token is valid DeviseTokenAuth::TokenFactory.token_hash_is_token?(last_token_hash, token) ) end
clientのupdated_atが存在し、最新のtokenがあることが前提
DeviseTokenAuth.batch_request_buffer_throttleはなんでしょう。
deive_token_authの設定ファイルを見に行ってみましょうか。
# your_app/config/initializers/devise_token_auth.rb # Sometimes it's necessary to make several requests to the API at the same # time. In this case, each request in the batch will need to share the same # auth token. This setting determines how far apart the requests can be while # still using the same auth token. # config.batch_request_buffer_throttle = 5.seconds
Sometimes it's necessary to make several requests to the API at the same time.
In this case, each request in the batch will need to share the same auth token.
This setting determines how far apart the requests can be while still using the same auth token.APIに対して同時に複数のリクエストを行う必要がある場合があります。
この場合、バッチ内の各リクエストは同じ認証トークンを共有する必要があります。
この設定は、同じ認証トークンを使用しながら、リクエストをどれだけ離すことができるかを決定します。
if user && user.valid_token?(@token.token, @token.client) は、tokenが有効かどうかをあらゆるconfigから検証にしに行く条件判定でしたね、、、
74行目のDeviseTokenAuth.bypass_sign_inを見てみましょう。
docに記載がありました。
By default DeviseTokenAuth will not check user's #active_for_authentication? which includes confirmation check on each call (it will do it only on sign in).
If you want it to be validated on each request (for example, to be able to deactivate logged in users on the fly), set it to false.デフォルトでは、DeviseTokenAuthは、各呼び出しの確認チェックを含むユーザーの#active_for_authenticationをチェックしません(サインイン時にのみチェックします)。
リクエストごとに検証する場合(たとえば、ログインしているユーザーをその場で非アクティブ化できるようにする場合)は、falseに設定します。
リクエストごとに検証しない場合はtrueにすればいい感じですね。
残りは実際のログイン処理のようです。
疲れたのでここまでにします、、、(13000字超えた、こんなん誰が読むのだろうか、、、)
認証周りは自分で実装してはダメな理由がわかりましたね。