Open11

deviseの調査メモ

kitabatakekitabatake

Why?

「dev.toから学ぶRails」のコンテンツを作成する時に、deviseについての理解が曖昧だと気づいた。
なので、ちゃんと説明できるように調べてみることにした。

Goal

自分の中で浮かんだ疑問に対して自分の言葉で説明できるようになればOK。

疑問と応答

devise_for って何?

https://zenn.dev/kitabatake/scraps/e319ecf7fc0aaf9c5ec0#comment-011e1ae38b06e4ddacbe

devise_scope って何?囲う必要あるの?

https://zenn.dev/kitabatake/scraps/e319ecf7fc0aaf9c5ec0#comment-415dab989e24d2b55223

current_user って具体的には何をしているの?

https://zenn.dev/kitabatake/scraps/e319ecf7fc0aaf9c5ec0#comment-2e900ed8182c022b7c06

authenticate_user! って具体的には何をしているの?

どのように定義され、参照できるかはcurrent_userと同じ。
定義は下記。

def authenticate_#{mapping}!(opts={})
    opts[:scope] = :#{mapping}
    warden.authenticate!(opts) if !devise_controller? || opts.delete(:force)
end

wardenのauthenticate!メソッド を呼び出している。
current_useではauthenticateメソッドを呼び出していた。

内部の認証方法は同じなので説明は省略。
2つの違いは、authenticate!メソッドは認証に失敗した場合にwardenのfailure_appが呼び出される。
authenticateメソッドの方は単にnilを返す。

failure_appが呼び出されるコードは次のあたり。
https://github.com/wardencommunity/warden/blob/master/lib/warden/manager.rb#L112

sigin_in/sign_outの処理は具体的にどうなってるの?

https://zenn.dev/kitabatake/scraps/e319ecf7fc0aaf9c5ec0#comment-62eabf6cbb29ff7a047f

omniauthの設定までなんでdeviseに対して行う必要があるの?ソーシャルログインはomniauthで完結できないの?

https://zenn.dev/kitabatake/scraps/e319ecf7fc0aaf9c5ec0#comment-d108b675f2eb499c5347

パスワード認証はどのように実装されてるの?

ここまでくればもう説明するまでもないですね。
ログイン認証画面のアクションは devise/sessions/create です。

https://github.com/heartcombo/devise/blob/master/app/controllers/devise/sessions_controller.rb#L18

kitabatakekitabatake

Rack middlewareについて

deviseについて調べ始めると、deviseはwardenという認証周りのRack middlewareをベースとしていることが解った。
なので、rack middlewareがどういったものか調べてみた。

参考にした記事

Rackとは何か

RackはWebアプリケーションを実装するためのミニマムなインターフェースを提供している。
そのインターフェースを実装したプログラム(Rackアプリケーション)があれば、Webアプリケーションとして稼働させることができる。

Rackアプリケーションとは

以下 Rackとは何か から引用:

  • callというメソッドを持っていること
  • callメソッドの引数としてWebサーバからのリクエストを受けること
  • callメソッドは,次の要素を含むレスポンスを返すること
    -ステータスコード
    -レスポンスヘッダ(Hash)
    -レスポンスボディ(Array)

以下が引用先で紹介されているRackアプリケーションのサンプル:

# coding: utf-8

class SimpleApp
  def call(env)
    p env
    case env['REQUEST_METHOD']
    when 'GET'
      [
        200,
        { 'Content-Type' => 'text/html' },
        ['<html><body><form method="POST"><input type="submit" value="見たい?" /></form></body></html>']
      ]
    when 'POST'
      [
        200,
        { 'Content-Type' => 'text/html' },
        ['<html><body>何見てんだよ</body></html>']
      ]
    end
  end
end

このクラスを、Rackの設定ファイルであるconfigu.ruに設定すればWebアプリケーションとして稼働させられる。

# coding: utf-8

require 'simple_app.rb'
run SimpleApp.new

Rack middlewareとは

https://qiita.com/nishio-dens/items/8011842f50995f46eafe
にとても解りやすくまとまっている。

deviseのコードを読んでいる時にenvを使っているなあと思ってたけど、Rack middlewareにとってenvが重要な要素だったということが解った。

まとめ

  • middlewareもRackアプリケーション
  • middlewareは初期化時に次のmiddlewareを設定される(チェーンされている)
  • middlewareのcallメソッド次のような選択肢がある。
    • envを編集して、次のmiddlewareのcallメソッドを呼び出す
      • callのレスポンスをそのまま返す
      • callのレスポンスを編集して返す
    • 次のmiddlewareを使用せずに、レスポンスを返す
kitabatakekitabatake

Wardenについて

https://github.com/wardencommunity/warden
認証機能を実装するRack middleware

callメソッドを持つRackアプリケーションはWardern::Managerクラスに実装されている。
https://github.com/wardencommunity/warden/blob/master/lib/warden/manager.rb

scope

認証する対象としてscopeという概念がある。
userとかadminのような。

callメソッド内で行なっていること

  • env['warden'] に認証に使うWarden::Proxyインスタンスをセット

    • deviseから使われるauthenticateメソッドなどが実装されている
  • チェインされた次のRack middlewareのcallメソッドを呼び出して、結果をresult変数に格納

  • 認証に失敗している場合(status == 401)にfailure_appを呼び出す

データの持ち方

session

  • sessionの操作はenv['rack.session']オブジェクト経由で行う
  • sessionのキーは"warden.user.#{scope}.key"

session周りの処理はWarden::SessionSerializerクラスに実装されている
https://github.com/wardencommunity/warden/blob/master/lib/warden/session_serializer.rb

sessionへのserialize, deserializeはconfig.serialize_into_sessionで設定できる。
https://github.com/wardencommunity/warden/blob/master/lib/warden/manager.rb#L69

deviseもこの設定を行なっている。

https://github.com/heartcombo/devise/blob/master/lib/devise.rb#L476
上記から一部抜粋

Devise.mappings.each_value do |mapping|
    warden_config.scope_defaults mapping.name, strategies: mapping.strategies

    warden_config.serialize_into_session(mapping.name) do |record|
      mapping.to.serialize_into_session(record)
    end

    warden_config.serialize_from_session(mapping.name) do |args|
      mapping.to.serialize_from_session(*args)
    end
end

mappingはDevise::Mappingインスタンス。
https://github.com/heartcombo/devise/blob/master/lib/devise/mapping.rb

現状のroutesがどのscopeかを識別するために使われる(たぶん)
mapping.toは、scopeがuserならUserクラスを表す。
serialize_into_sessionメソッドはauthenticatable経由でモデルにincludeされる。
https://github.com/heartcombo/devise/blob/master/lib/devise/models/authenticatable.rb#L237
上記から一部抜粋

def serialize_into_session(record)
    [record.to_key, record.authenticatable_salt]
end

proxy

認証されたuserのデータは proxyインスタンスのusersに設定される。

@users[scope] = user

この値が設定されているscopeは、以降認証されている状態として扱われる。

userのデータ

warden自体はuserの中身に関心がないので、どんなオブジェクトでもok(たぶん)

認証 authenticate

Warden::Proxy.authenticate などで認証ができる。

  • wardenに設定されているstrategiesの中から実行するstrategyを取得( = winning_strategy)

  • winning_strategyがsuccessful?なら認証成功

    • 認証されたユーザーを設定する
    • set_user(winning_strategy.user, opts.merge!(:event => :authentication))

strategyクラス

  • Warden::Strategies::Base にから提供されるparamsなどの値を用いて次のメソッドを実装する
    • valid? 認証するかどうか
    • authenticate! 認証処理
      • 認証が成功した場合は success!(user) のようにsuccess!メソッドに対して認証したuserを渡す

詳しくは下記。
https://github.com/wardencommunity/warden/wiki/Strategies

deviseで用意されているstrategiesは下記。
https://github.com/heartcombo/devise/tree/master/lib/devise/strategies

strategyの実装ファイル内で次のようにwardenに設定されている。

Warden::Strategies.add(:database_authenticatable, Devise::Strategies::DatabaseAuthenticatable)
kitabatakekitabatake

deviseの読み込み、初期化

始点はlib/devise.rb
https://github.com/heartcombo/devise/blob/master/lib/devise.rb

このファイルの下部で各種moduleをrequireしている。

require 'warden'
require 'devise/mapping'
require 'devise/models'
require 'devise/modules'
require 'devise/rails'

このファイルで定義されているmoduleDeviseは、各種configの値やdevise_for経由で設定されたresourcesを保持している。
コアのmoduleといえる。

devise/mapping

https://github.com/heartcombo/devise/blob/master/lib/devise/mapping.rb

resourceごとのroutesやmodulesの設定などを表すもの。
resourceとは、userとかadminとかの認証の対象のことで、wardenのscopeと対応している。

mappingはconfig/routes.rbで定義されるdevise_forによって作成される。
例えば、devise_for :users は"resoure = user"のmappingを作成していると表現できる。

また、mappingは、定義されたroutesでアクセスされた場合に、env["devise.mapping"]に格納される。
DeviseControllerはこのmappingのデータを元にresourceの識別を行う。

devise/rails

devise_scopedevise_forを定義しているlib/devise/rails/routes.rbをrequireしている
https://github.com/heartcombo/devise/blob/master/lib/devise/rails/routes.rb
このファイルを見れば、routes上でどういったAPIが使えるかが大体わかる。

まとめ

  • config/initializer/devise.rbの設定を元に、各種moduleのactivateなどの初期化が行われる
    • この内容はlib/devise.rbに定義されているDevise moduleにまとめられる。
  • routes上のdevise_forによって、Devise::Mappingインスタンスが作成される。
    • Devise moduleのmappingsクラス変数に格納される
  • deviseのroutesにアクセスされた際に、env["devise.mapping"]にDevise::Mappingインスタンスが設定される
  • deviseの各種module, controllerはenv["devise.mapping"]の設定を元に、それぞれの機能を実行する
kitabatakekitabatake

devise_forって何?

https://github.com/heartcombo/devise/blob/master/lib/devise/rails/routes.rb#L226

指定されたresourceに対するdevise向けのroutesを作成するもの。

devise向けのroutesとは、たとえば次のようなもの(resource=userの場合)

  • ログイン画面
    • name: new_user_session
    • url: users/sign_in
    • action: Devise::SessionsController#new
  • ログアウト処理
    • name: destroy_user_session
    • url: users/sign_out
    • action: Devise::SessionsController#destroy

作成されるroutesはmodelのdeviseメソッドで定義されたmoduleに必要なもの。

内部では、devise_scopeメソッドを使ってroutesが作成される。

devise_scopeメソッド経由で作成されたroutesは、実際にアクセスされた際にenv["devise.mapping"]に対して、指定されたscopeのDevise::Mappingインスタンスを設定されるようになっている。

kitabatakekitabatake

devise_scope って何?囲う必要あるの?

https://github.com/heartcombo/devise/blob/master/lib/devise/rails/routes.rb#L363

deviseのroutesをカスタマイズしたいときに使うもの。
devise_forで自動的に作成されるurlを使いたくない時などに。

例えばログイン画面のurlをデフォルトの/users/sign_inではなく、/sign_inに変えたい場合は次のように指定する。

devise_scope :user do
    get "sign_in", to: "devise/sessions#new"
end

また、devise_scopeActionDispatch::Routing::Mapperconstraintsメソッドを使用することで、
実際にroutesにアクセスされた際に、request.env["devise.mapping"]に指定されたscopeのDevise::Mappingインスタンスを設定している。

これによって、各種DeviseControllerを継承したControllerはどのscopeに対して処理するかを識別できる。
逆に言うと、複数のscopeに対して同じurlを指定することはできない。
なぜなら、Controllerがどのscopeに対する処理なのかが識別できなくなるから。

# NG 複数のscopeで同じurlを指定してしまっている
devise_scope :user do
    get "sign_in", to: "devise/sessions#new"
end
devise_scope :admin do
    get "sign_in", to: "devise/sessions#new"
end

囲う必要ってあるの?

devise_scopeで囲わずに、DeviseControllerを継承したアクションにroutesを設定すると、アクセス時にmappingが見つからないのでエラーになる。
https://github.com/heartcombo/devise/blob/master/app/controllers/devise_controller.rb#L65

kitabatakekitabatake

current_user って具体的には何をしているの?

まず、どのように定義され、参照できるようになっているのか

Devise::Controllers::Helperdefine_helpers メソッドで動的に定義されている。
define_helpersメソッドはDevisemoduleのadd_mappingメソッドから呼び出される。
add_mappingメソッドはdevise_forメソッド内から呼び出されるので、current_userメソッドはRailsのroutesが初期化されるタイミングで定義されている。

HelperをActionControllerにincludeしているのは下記。
https://github.com/heartcombo/devise/blob/master/lib/devise.rb#L448

def self.include_helpers(scope)
    ActiveSupport.on_load(:action_controller) do
      include scope::Helpers if defined?(scope::Helpers)
      include scope::UrlHelpers
    end

    ActiveSupport.on_load(:action_view) do
      include scope::UrlHelpers
    end
  end

このように、Devise::Controllers::Helper で定義されているメソッドはControllerから参照でき、
Devise::Controllers::UrlHelper で定義されているメソッドはControllerとViewどちらからも参照できる。

では、具体的に何をしているのか?

動的に定義されるメソッドのコードは下記:

def current_#{mapping}
    @current_#{mapping} ||= warden.authenticate(scope: :#{mapping})
end

単純にwardenのauthenticateメソッドを呼び出している。
wardenのauthenticateメソッドの詳細は
https://zenn.dev/kitabatake/scraps/e319ecf7fc0aaf9c5ec0#comment-244b8dc55de3b82d14e3
の表題"認証 authenticate"のところにも書いてある。

warden.authenticateの処理

  1. usersプロパティに値が入っていればそれを返す
  2. sessionにuserのデータがあるかを探しに行く。データがあれば、deserializeして、usersプロパティにセットして、返す。
  3. 有効なstrategiesを実行し、認証が成功したら、そのuserを返す
  • 有効かどうかの判定の多くはreques.paramのデータに認証に必要なkeyがあるかで判断されるので、認証用のアクション以外では基本的に有効とはならない
  • 例えばdatabase_authenticatable strategyでは、emailのkeyがあるかなど。
kitabatakekitabatake

sigin_in/sign_outの処理は具体的にどうなってるの?

sign_in

代表的なアクションはdevise/sessions/createだと思う。

https://github.com/heartcombo/devise/blob/master/app/controllers/devise/sessions_controller.rb#L18

まず、wadernのauthenticate!メソッドを呼び出して、結果をresourceに格納する:

self.resource = warden.authenticate!(auth_options)

感嘆符付きのauthenticate!メソッドなので、認証に失敗した場合は、failure_appを実行する処理に遷移するので、以降の処理は実行されない。

認証に成功した場合は、次に、Devise::Controllers::SignInOutmoduleに定義されているsign_inメソッドを呼び出している。
https://github.com/heartcombo/devise/blob/master/lib/devise/controllers/sign_in_out.rb#L33

sign_inメソッドの内部ではwardenのset_userメソッドを呼び出している。
authenticate!メソッドでも内部でset_userを呼び出しているの被っている処理はありそう。

sign_out

アクションはdevise/seissions/destroy
https://github.com/heartcombo/devise/blob/master/app/controllers/devise/sessions_controller.rb#L27

Devise::Controllers::SignInOutmoduleに定義されているsign_outメソッドを呼び出している。
https://github.com/heartcombo/devise/blob/master/lib/devise/controllers/sign_in_out.rb#L80

sign_outメソッドはwardenのlogoutメソッドを呼び出している。
https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L266

logoutメソッド内で、usersプロパティ、sessionから対象のscopeのuserデータを削除している。

まとめ

どちらも主な処理はwardenの対応するメソッドを呼び出している感じ。
deviseの認証系のコードはwardenをベースにしているから、結構薄くなっている感じ。

認証系以外のmoduleの方が重たそう。

kitabatakekitabatake

omniauthの設定までなんでdeviseに対して行う必要があるの?ソーシャルログインはomniauthで完結できないの?

この質問は、deviseがomniauth周りの連携で何をやっているかを明確にすれば良さそう。

config

使用するサードパーティのproviiderと、providerごとのapp_id, secretなどの設定ができる。

routes

各scopeとproviderごとに、認証用とcallbackのroutesを作ってくださっている。
https://github.com/heartcombo/devise/blob/master/lib/devise/rails/routes.rb#L446

strategyのmiddleware設定

providerごとのstrategyのmiddlewareを設定してくださっている。
https://github.com/heartcombo/devise/blob/master/lib/devise/rails.rb#L25

controller

https://github.com/heartcombo/devise/blob/master/app/controllers/devise/omniauth_callbacks_controller.rb

omniauthのベースとして用意されているcontrollerはそれほど重たくない。
ただ、sign_inなどのdeviseのhelper系のメソッドを使えるのは大きそう。

まとめ

omniauthの機能をdeviseと共に自然に、簡単に使えるように細かいことをやってくださっている。
ちょこっとconfigを書くだけで、ここまで簡単にソーシャルログインを実装できるのの大部分はdeviseの仕事のおかげ。

kitabatakekitabatake

モデルへの各種moduleの機能の読み込み

Devise::Models#deviseメソッドに使いたいmoduleを指定することで、モデルに各moduleに必要な機能を読み込んでいる。
Authenticatableモジュールは認証用のベースのものとして、デフォルトでincludeされる。

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

Devise::Model自体は、ActiveSupport.on_loadを使ってActiveRecordに対してextendされている。

https://github.com/heartcombo/devise/blob/master/lib/devise/orm/active_record.rb

初期化時の実行順序としては、

  1. Model#deviseメソッドが実行されて、Modelに対してmodulesが設定される
  2. devise_forメソッドで、対象のModelのmodules設定を元にroutesを作成する

moduleについてのメモ

  • authenticatable
    • Comfirmableの active_for_authentication? がいい例?
  • database_authenticatable
  • confirmable
  • registable
    • modelは薄く、routesが主。
  • Recoverable
    • Confirmableのpasswardバージョン的な
  • Rememberable
    • strategyとセット
      • strategyはデフォルトでセットされて、valid?の判定時にrememberableモジュールが使われているかどうかを見ている
    • remember_me!を呼び出すことで、tokenと時間が入れ込まれる
  • Trackable
    • track処理は lib/devise/hooks/trackable.rb 場で、wardenのafter_set_user経由で呼び出されるようになっている
  • Timeoutable
    • 使っているのは lib/devise/hooks/timeoutable.rb 場で wardenのafter_set_userから。
  • Lockable
    • active_for_authentication?
    • 時間経過でのunlock処理は valid_for_authentication? 上で行なっている