はじめに
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モデルがあるとします。
元コードはこちら。
resource_class
7行目でresource_classというメソッドが使われています。
@resource = resource_class.confirm_by_token(resource_params[:confirmation_token])
定義場所は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
Devise.mappingsからコンソールで確認してみます。
$ 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メソッドが内部で呼ばれていました。
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
omniauth向けのパラメータでresource_classの指定があれば、constantizeメソッドでresource_classを定数化した値を返却し、params['resource_class']があった場合はparams['resource_class']を定数に、どちらにも該当しない場合は例外を返すようにしていました。
resource_classに話を戻します。
resource_classメソッドの返り値はmapping.toとなっているため、toメソッドを実行してみます。
$ 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モジュールに定義されているメソッドです。コードはこちら
# 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
confirmation_tokenがnilまたは空の場合、newで新しいインスタンスを生成しつつ、errorsにエラーを追加して、インスタンスを返しています。
find_first_by_auth_conditionsメソッドも見てみます。
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で挙動の確認ができます。
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
この状態でコンソールで確認します。
# 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メソッドの中身はこちら
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
こちらもUserモデルにselfメソッドとして定義して確認してみます。
# 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メソッドを読んでみます。
# 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
pending_any_confirmationメソッドは、confirmed_atカラムに値がない場合、またはreconfirmableが有効かつ未確認メールアドレスが存在する場合、わたされたブロックを実行し、そうでない場合はerrorsに「既に確認済み」のエラーを格納してfalseを返しています。
confirmation_period_expired?メソッドの中身を見ます。
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で設定できる、認証メールの有効期限の値です。
confirm_withinが設定されていて、かつ既に確認メールが送信済みで、かつ認証メールの有効期限が切れていた場合、trueを返していました。
まとめると、confirmation_period_expired?がtrueの場合、errorsに認証メールの期限が切れている旨のエラーを格納し、falseを返しています。
reconfirmableが有効かつ未確認メールアドレスが存在する場合、skip_reconfirmation!というメソッドが実行されていましたので、こちらもコードを見てみます。
# 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
コメントアウト部分を訳してみます。
再認証メールを送信したくない場合、コードを生成したくない場合は、skip_reconfirmationを呼び出してください。
reconfirmableが有効かつ未確認メールアドレスが存在する場合の中の処理で、再認証メールを送信したくない場合に該当するのでskip_reconfirmation!をコールしているんだと思われます。
あとは各種値を代入しつつsaveメソッドをコールしてデータベースに保存します。
else句の場合は引数のensure_validがtrueの場合、validationを実施してsaveしています。
saveに成功した場合、after_confirmationメソッドが呼ばれます。
# 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メソッドを読んでみます。
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
HTTPのRequestHeaderに載せる用のハッシュを組み立てるメソッドでした。コメントアウト部分にも書いてある通り、将来的に廃止される可能性のあるパラメータも含まれているようです。
実際に実行すると下記のような返り値が得られます。
$ 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
def build_auth_url(base_url, args)
args[:uid] = uid
args[:expiry] = tokens[args[:client_id]]['expiry']
DeviseTokenAuth::Url.generate(base_url, args)
end
Deviseを利用しているモデルのuidやtokenを用いて、DeviseTokenAuthのURLを生成するメソッドでした。
こちらも実行すると以下のような返り値を得られます。
$ 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メソッドを紹介します。
# 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
DeviseTokenAuth.default_confirm_success_urlは、config/initializers/devise_token_auth.rbで設定できる値で、名前の通りデフォルトの認証成功URLを定義することができます。
paramsのredirect_urlがあればその値を、なければdefault_confirm_success_urlを返すようになっています。
まとめ
思ったよりもDeviseの中の処理が多く、devise_token_authのコードを実行して理解する、というよりもdeviseのコードを実行している気分でした。
after_confirmationメソッドはこの記事を書くまで全く知らなかったので、confirmを行った後に独自に処理を追加したくなった時にオーバーライドしたいと思います。