devise_token_authのConfirmationsControllerのshowアクションのコードを実行して理解する

# はじめに

# devise_token_authとは

https://github.com/lynndylanhurley/devise_token_auth

この記事をご覧になる方は既にdevise_token_authについてご存知の方も多いかと思いますが、簡単に紹介します。

devise_token_authはTokenベースの認証機能を提供するGemで、SPAやネイティブアプリ向けで用いられることがあります。
deviseはsessionベースの認証方式なのに対し、devise_token_authはTokenベースの認証方式という点が異なります。

例えばユーザー向けのアプリをネイティブアプリで、サービス提供者側が使う管理画面やWeb版の画面をSPAで、という場合にdevise_token_authを採用するケースが考えられます。

# ConfirmationsControllerのコードを実行

では早速コードを実行しつつ読んでいきます。前提としてdevise_token_authの機能をincludeしたUserモデルがあるとします。

元コードはこちら。

https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/controllers/devise_token_auth/confirmations_controller.rb

## resource_class

7行目でresource_classというメソッドが使われています。

```ruby
@resource = resource_class.confirm_by_token(resource_params[:confirmation_token])
```

定義場所はDeviseTokenAuth::ApplicationControllerで、コードはこちら

```ruby
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
```

https://github.com/lynndylanhurley/devise_token_auth/blob/b1ceb54f33841eafffc31f1e47de378296fa990b/app/controllers/devise_token_auth/application_controller.rb#L44

Devise.mappingsからコンソールで確認してみます。

```ruby
$ Devise.mappings[:user]
=> #<Devise::Mapping:0x00005641aff11ed0
 @class_name="User", ...

$ Devise.mappings.values.first
=> #<Devise::Mapping:0x00005641aff11ed0
 @class_name="User", ...
```

マッピングに関するClassが返却されました。

resource_nameメソッドも見てみます。

定義場所はDeviseTokenAuth::OmniauthCallbacksControllerで、resource_classメソッドが内部で呼ばれていました。

```ruby
def resource_class(mapping = nil)
  if omniauth_params['resource_class']
    omniauth_params['resource_class'].constantize
  elsif params['resource_class']
    params['resource_class'].constantize
  else
    raise 'No resource_class found'
  end
end
```

https://github.com/lynndylanhurley/devise_token_auth/blob/b1ceb54f33841eafffc31f1e47de378296fa990b/app/controllers/devise_token_auth/omniauth_callbacks_controller.rb#L141

omniauth向けのパラメータでresource_classの指定があれば、constantizeメソッドでresource_classを定数化した値を返却し、params['resource_class']があった場合はparams['resource_class']を定数に、どちらにも該当しない場合は例外を返すようにしていました。

resource_classに話を戻します。

resource_classメソッドの返り値はmapping.toとなっているため、toメソッドを実行してみます。

```ruby
$ Devise.mappings[:user].to
=> User (call 'User.connection' to establish a connection) # Userクラスが返る

$ Devise.mappings[:user].to.first
=> #<User id: 1, provider: "email" ... # Userインスタンスが返る
```

コンソールでの検証により、resource_classメソッドの返り値は、ActiveRecordクラスを継承したDeviseを使っているモデルが返却されることがわかりました。

## confirm_by_token

deviseのconfirmableモジュールに定義されているメソッドです。コードはこちら

```ruby
# Find a user by its confirmation token and try to confirm it.
# If no user is found, returns a new user with an error.
# If the user is already confirmed, create an error for the user
# Options must have the confirmation_token
def confirm_by_token(confirmation_token)
  # When the `confirmation_token` parameter is blank, if there are any users with a blank
  # `confirmation_token` in the database, the first one would be confirmed here.
  # The error is being manually added here to ensure no users are confirmed by mistake.
  # This was done in the model for convenience, since validation errors are automatically
  # displayed in the view.
  if confirmation_token.blank?
    confirmable = new
    confirmable.errors.add(:confirmation_token, :blank)
    return confirmable
  end

  confirmable = find_first_by_auth_conditions(confirmation_token: confirmation_token)

  unless confirmable
    confirmation_digest = Devise.token_generator.digest(self, :confirmation_token, confirmation_token)
    confirmable = find_or_initialize_with_error_by(:confirmation_token, confirmation_digest)
  end

  # TODO: replace above lines with
  # confirmable = find_or_initialize_with_error_by(:confirmation_token, confirmation_token)
  # after enough time has passed that Devise clients do not use digested tokens

  confirmable.confirm if confirmable.persisted?
  confirmable
end

```

https://github.com/heartcombo/devise/blob/master/lib/devise/models/confirmable.rb#L350

confirmation_tokenがnilまたは空の場合、newで新しいインスタンスを生成しつつ、errorsにエラーを追加して、インスタンスを返しています。

find_first_by_auth_conditionsメソッドも見てみます。

```ruby
def find_first_by_auth_conditions(tainted_conditions, opts = {})
  to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
end
```

こちらはUserモデルにselfメソッドとして定義してやると、binding.pryで挙動の確認ができます。

```ruby
class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable, :confirmable
  include DeviseTokenAuth::Concerns::User

  # 追加
  def self.find_first_by_auth_conditions(tainted_conditions, opts = {})
    binding.pry
    to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
  end
  # ここまで
end
```

この状態でコンソールで確認します。

```ruby
# http://localhost:3000/auth/confirmation?config=default&confirmation_token=nil&redirect_url=http%3A%2F%2Flocalhost%3A3000にアクセス

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

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

$ devise_parameter_filter
=> #<Devise::ParameterFilter:0x00007faf280343a8
 @case_insensitive_keys=[:email],
 @strip_whitespace_keys=[:email]>

$ devise_parameter_filter.filter(tainted_conditions)
=> {:confirmation_token=>nil }

$ to_adapter.find_first(devise_parameter_filter.filter(tainted_conditions).merge(opts))
=> => #<User id: 13, provider: "email", ...
```

引数で渡されたconfirmation_tokenを使ってDeviseを利用しているモデルのテーブルを検索して、レコードが見つかればそのレコードを返していました。

もしfind_first_by_auth_conditionsの返り値がnilの場合、Devise.token_generator.digestメソッドでtokenを生成し、
find_or_initialize_with_error_byメソッドを実行しています。

find_or_initialize_with_error_byメソッドの中身はこちら

```ruby
def find_or_initialize_with_error_by(attribute, value, error = :invalid) #:nodoc:
  find_or_initialize_with_errors([attribute], { attribute => value }, error)
end

# find_or_initialize_with_errorsは以下

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

```

https://github.com/heartcombo/devise/blob/c82e4cf47b02002b2fd7ca31d441cf1043fc634c/lib/devise/models/authenticatable.rb#L284

こちらもUserモデルにselfメソッドとして定義して確認してみます。

```ruby
# http://localhost:3000/auth/confirmation?config=default&confirmation_token=hogehogetokens&redirect_url=http%3A%2F%2Flocalhost%3A3000にアクセス

$ attributes
=> {:confirmation_token=>"hogehogetokens"}

$ attributes.to_h.with_indifferent_access.slice(*required_attributes).delete_if { |key, value| value.blank? }
=> {"confirmation_token"=>"694f1eacaeca206dbd514ece33407559895e7a0d26f78d04458da5dc65d02e85"}

$ user = 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

$ user.errors.details
=> {:confirmation_token=>[{:error=>:invalid}]}
```

メソッド名から想像はできましたが、find_or_initialize_with_errorsは渡された引数が正しければconfirmation_tokenを使ってDeviseを使っているモデルのレコードを返し、そうでなければerrorsに値が入った新規のインスタンスが返却されることがわかりました。

最後にconfirmメソッドを読んでみます。

```ruby
# Confirm a user by setting it's confirmed_at to actual time. If the user
# is already confirmed, add an error to email field. If the user is invalid
# add errors
def confirm(args = {})
  pending_any_confirmation do
    if confirmation_period_expired?
      self.errors.add(:email, :confirmation_period_expired,
        period: Devise::TimeInflector.time_ago_in_words(self.class.confirm_within.ago))
      return false
    end

    self.confirmed_at = Time.now.utc

    saved = if pending_reconfirmation?
      skip_reconfirmation!
      self.email = unconfirmed_email
      self.unconfirmed_email = nil

      # We need to validate in such cases to enforce e-mail uniqueness
      save(validate: true)
    else
      save(validate: args[:ensure_valid] == true)
    end

    after_confirmation if saved
    saved
  end
end

# pending_any_confirmationは以下

def pending_any_confirmation
  if (!confirmed? || pending_reconfirmation?)
    yield
  else
    self.errors.add(:email, :already_confirmed)
    false
  end
end
```

https://github.com/heartcombo/devise/blob/c82e4cf47b02002b2fd7ca31d441cf1043fc634c/lib/devise/models/confirmable.rb#L79

pending_any_confirmationメソッドは、confirmed_atカラムに値がない場合、またはreconfirmableが有効かつ未確認メールアドレスが存在する場合、わたされたブロックを実行し、そうでない場合はerrorsに「既に確認済み」のエラーを格納してfalseを返しています。

confirmation_period_expired?メソッドの中身を見ます。

```ruby
def confirmation_period_expired?
  self.class.confirm_within && self.confirmation_sent_at && (Time.now.utc > self.confirmation_sent_at.utc + self.class.confirm_within)
end
```

confirm_withinはconfig/initializers/devise.rbで設定できる、認証メールの有効期限の値です。

https://qiita.com/linkss/items/9b005ebc2ef81a39afa2#configuration-for-confirmable--%E3%83%A1%E3%83%BC%E3%83%AB%E8%AA%8D%E8%A8%BC

confirm_withinが設定されていて、かつ既に確認メールが送信済みで、かつ認証メールの有効期限が切れていた場合、trueを返していました。

まとめると、confirmation_period_expired?がtrueの場合、errorsに認証メールの期限が切れている旨のエラーを格納し、falseを返しています。

reconfirmableが有効かつ未確認メールアドレスが存在する場合、skip_reconfirmation!というメソッドが実行されていましたので、こちらもコードを見てみます。

```ruby
# If you don't want reconfirmation to be sent, neither a code
# to be generated, call skip_reconfirmation!
def skip_reconfirmation!
  @bypass_confirmation_postpone = true
end
```

https://github.com/heartcombo/devise/blob/c82e4cf47b02002b2fd7ca31d441cf1043fc634c/lib/devise/models/confirmable.rb#L110

コメントアウト部分を訳してみます。

> 再認証メールを送信したくない場合、コードを生成したくない場合は、skip_reconfirmationを呼び出してください。

reconfirmableが有効かつ未確認メールアドレスが存在する場合の中の処理で、再認証メールを送信したくない場合に該当するのでskip_reconfirmation!をコールしているんだと思われます。

あとは各種値を代入しつつsaveメソッドをコールしてデータベースに保存します。

else句の場合は引数のensure_validがtrueの場合、validationを実施してsaveしています。

saveに成功した場合、after_confirmationメソッドが呼ばれます。

```ruby
# A callback initiated after successfully confirming. This can be
# used to insert your own logic that is only run after the user successfully
# confirms.
#
# Example:
#
#   def after_confirmation
#     self.update_attribute(:invite_code, nil)
#   end
#
def after_confirmation
end
```

> 確認に成功した後にコールバックが開始されます。 これは、ユーザーが正常に確認した後にのみ実行される独自のロジックを挿入するために使用できます。

なるほど、Deviseを使っているモデルでafter_confirmationをオーバーライドすることで、独自の処理を追加できるということですね。

confirmメソッドは認証メールの状況を見つつ、レコードの保存処理を試みるメソッドでした。

かなり寄り道しましたがようやくconfirm_by_tokenのコードリーディングが終わりました。。。

その後のsigned_in?メソッドはwardenの認証処理を呼び出すものだったので、割愛します(読み始めたらキリがなさそう、、、)

## build_redirect_headers

次にbuild_redirect_headersメソッドを読んでみます。

```ruby
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
```

https://github.com/lynndylanhurley/devise_token_auth/blob/b1ceb54f33841eafffc31f1e47de378296fa990b/app/controllers/devise_token_auth/application_controller.rb#L23

HTTPのRequestHeaderに載せる用のハッシュを組み立てるメソッドでした。コメントアウト部分にも書いてある通り、将来的に廃止される可能性のあるパラメータも含まれているようです。

実際に実行すると下記のような返り値が得られます。

```ruby
$ build_redirect_headers(token.token, token.client, redirect_header_options)
=> {"access-token"=>"ouO7YM9EWLX-Ls-Q7k2fEw",
 "client"=>"I_WF1_8GNSqdEBwPWhlSIw",
 :config=>"default",
 :client_id=>"I_WF1_8GNSqdEBwPWhlSIw",
 :token=>"ouO7YM9EWLX-Ls-Q7k2fEw",
 :account_confirmation_success=>true}
```

## build_auth_url

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

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

https://github.com/lynndylanhurley/devise_token_auth/blob/b1ceb54f33841eafffc31f1e47de378296fa990b/app/models/devise_token_auth/concerns/user.rb#L189

Deviseを利用しているモデルのuidやtokenを用いて、DeviseTokenAuthのURLを生成するメソッドでした。

こちらも実行すると以下のような返り値を得られます。

```ruby
$ signed_in_resource.build_auth_url(redirect_url, redirect_headers)
=> "http://localhost:3000?access-token=ouO7YM9EWLX-Ls-Q7k2fEw&account_confirmation_success=true&client=I_WF1_8GNSqdEBwPWhlSIw&client_id=I_WF1_8GNSqdEBwPWhlSIw&config=default&expiry=&token=ouO7YM9EWLX-Ls-Q7k2fEw&uid=test-user%2B1%40example.com"
```
## redirect_url

最後にredirect_urlメソッドを紹介します。

```ruby
# give redirect value from params priority or fall back to default value if provided
def redirect_url
  params.fetch(
    :redirect_url,
    DeviseTokenAuth.default_confirm_success_url
  )
end
```

https://github.com/lynndylanhurley/devise_token_auth/blob/master/app/controllers/devise_token_auth/confirmations_controller.rb#L78

DeviseTokenAuth.default_confirm_success_urlは、config/initializers/devise_token_auth.rbで設定できる値で、名前の通りデフォルトの認証成功URLを定義することができます。

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

paramsのredirect_urlがあればその値を、なければdefault_confirm_success_urlを返すようになっています。

# まとめ

思ったよりもDeviseの中の処理が多く、devise_token_authのコードを実行して理解する、というよりもdeviseのコードを実行している気分でした。

after_confirmationメソッドはこの記事を書くまで全く知らなかったので、confirmを行った後に独自に処理を追加したくなった時にオーバーライドしたいと思います。