devise_token_authのset_user_by_tokenのソースコードを追ってみた

こんにちは!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字超えた、こんなん誰が読むのだろうか、、、)

認証周りは自分で実装してはダメな理由がわかりましたね。