warden のコードリーディング

warden gem のコードリーディングしたのでメモ

warden とは

rack ベースの認証フレームワーク。 devise でお馴染みの gem ですね。

github.com

rack middleware の登録

warden は rack middleware として動作します。 rails とかだと config/application.rb あたりで以下のように rack middleware に登録することで warden を利用することができます。

# https://github.com/wardencommunity/warden/wiki/Setup
module Hoge
  class Application < Rails::Application
    config.use Warden::Manager do |manager|
      manager.default_strategies :password
      manager.failure_app = BadAuthenticationEndsUpHere
    end
  end
end

また、以下のように認証で利用する strategy の実態(認証処理)を登録してあげる必要があります。 Warden::Strategies.add で実態を登録した場合、Warden::Strategies::Base のサブクラスとして実態が定義されます。

# https://github.com/wardencommunity/warden/wiki/Strategies
Warden::Strategies.add(:password) do
  def valid?
    params['username'] || params['password']
  end

  def authenticate!
    u = User.authenticate(params['username'], params['password'])
    u.nil? ? fail!("Could not log in") : success!(u)
  end
end

ブロック内で行なっている処理を理解するためにまずは Warden::Manager を読んでいきます

Warden::Manager の initializer は以下のようになっています。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/manager.rb#L19-L25

def initialize(app, options={})
  default_strategies = options.delete(:default_strategies)

  @app, @config = app, Warden::Config.new(options)
  @config.default_strategies(*default_strategies) if default_strategies
  yield @config if block_given?
end

Warden::Configインスタンスを生成し @config に代入。 ブロックが与えられていれば @config を引数にブロックを実行しています。

middleware 登録の時のブロック引数 manager の正体は Warden::Configインスタンスでした。 続いて ブロック内でやっている manager.default_strategies について理解するために Warden::Config を読みます。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/config.rb#L63-L70

def default_strategies(*strategies)
  opts  = Hash === strategies.last ? strategies.pop : {}
  hash  = self[:default_strategies]
  scope = opts[:scope] || :_all

  hash[scope] = strategies.flatten unless strategies.empty?
  hash[scope] || hash[:_all] || []
end

引数 strategies を配列で受け取り、最後の要素が hash の場合は それを opts に代入しています。 続いて hash = self[:default_strategies] でデフォルトのstrategyを取得しています。 hash = self[:default_strategies] という記法はすこし奇妙に感じますが、Warden::Config が Hash Class のサブクラスだからこのように書けるのですね。

最後に opts から scope を取得し、 hash[scope] に引数 strategies を代入しています。

続いて manager.failure_app = BadAuthenticationEndsUpHere の処理を見ていきます。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/config.rb#L21-L35

def self.hash_accessor(*names) #:nodoc:
  names.each do |name|
    class_eval <<-METHOD, __FILE__, __LINE__ + 1
      def #{name}
        self[:#{name}]
      end
      def #{name}=(value)
        self[:#{name}] = value
      end
    METHOD
  end
end

hash_accessor :failure_app, :default_scope, :intercept_401

hash_accessor により Warden::Config#failure_app= が動的に定義されていて、self[:failure_app] に代入をしています。

ここまでで、 冒頭のようにmiddleware を登録すると Warden::Manager@config は以下の内容になっていることがわかります

# 簡易化のためhash 形式かつ default_strategiesとfailure_app 以外のキーを除外しています
{
  default_strategies: {
    _all: [:password]
  },
  failure_app: BadAuthenticationEndsUpHere
}

middleware の呼び出し

rack middleware は callメソッドによって呼び出されます。早速 Warden::Manager の call メソッドを読んでみます。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/manager.rb#L30-L48
def call(env) # :nodoc:
  return @app.call(env) if env['warden'] && env['warden'].manager != self
  env['warden'] = Proxy.new(env, self)
  result = catch(:warden) do
    env['warden'].on_request
    @app.call(env)
  end

  result ||= {}
  case result
  when Array
    handle_chain_result(result.first, result, env)
  when Hash
    process_unauthenticated(env, result)
  when Rack::Response
    handle_chain_result(result.status, result, env)
  end
end

env['warden']Warden::Proxyインスタンスを代入しています。 なぜ env にいれているかというと、この rack middleware の外からenv['warden'] を参照したいからだと思われます。

catch(:warden) で後続のブロック内の処理から大域脱出をできるようにしています。 ブロックの中を見ていきます。 env['warden'].on_request では詳細省略しますが、warden では以下のようにon_requestコールバックを登録することができ、on_requestコールバックが登録されていればここで実行されます。

Warden::Manager.on_request do |proxy|
  ...
end

コールバックを実行したら後続のmiddlewareおよび rack アプリケーションを実行しています。

認証処理

ここまでで、 Warden::Manager middlewareでは 認証方法(strategy)の指定や env['warden'] に Warden::Proxy のインスタンスを代入するなどの初期化処理をしていることがわかりました。

続いてrack アプリケーション内で実際に認証をする方法を見ていきます。

warden ではアプリケーションの任意の場所で以下のようにすることで認証を行うことができます。

env['warden'].authenticate!

env['warden'] は Warden::Proxy のインスタンスでしたね。では Warden::Proxy#authenticate! を見ていきましょう

# https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L132-L136
def authenticate!(*args)
  user, opts = _perform_authentication(*args)
  throw(:warden, opts) unless user
  user
end

_perform_authentication を実行して戻り値 user が存在しなければ throw(:warden, opts) で大域脱出。user が存在すれば そのまま user を返しています。

_perform_authentication を見ましょう

# https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L328-L343
def _perform_authentication(*args)
  scope, opts = _retrieve_scope_and_opts(args)
  user = nil

  # Look for an existing user in the session for this scope.
  # If there was no user in the session, see if we can get one from the request.
  return user, opts if user = user(opts.merge(:scope => scope))
  _run_strategies_for(scope, args)

  if winning_strategy && winning_strategy.successful?
    opts[:store] = opts.fetch(:store, winning_strategy.store?)
    set_user(winning_strategy.user, opts.merge!(:event => :authentication))
  end

  [@users[scope], opts]
end

_retrieve_scope_and_opts@config から scope と そのscope に対応する optionを取り出しているようです。 middleware 登録時に何も指定していない場合は scope:default それに対応するオプションは 空のhash になります。

続いて user メソッドを opts を引数に呼び出しています。 冒頭のようにmiddleware を登録した場合だと opts の中身は {scope: :default} になります。

user メソッドも割愛しますが、@users に該当 scope のキャッシュがいればそれを返し、なければ session の取り出しを試みます。複数 authenticate! が呼ばれてもパフォーマンスが劣化しないようにキャッシュしたり、一度認証が通ってsession が作成されればsessionが残っていれば再認証する必要がないのでこの処理を挟んでいるようです。

user メソッド を呼び出しても user が存在しなければ後続の _run_strategies_for(scope, args) に続きます。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L353-L376
def _run_strategies_for(scope, args) #:nodoc:
  self.winning_strategy = @winning_strategies[scope]
  return if winning_strategy && winning_strategy.halted?

  # Do not run any strategy if locked
  return if @locked

  if args.empty?
    defaults   = @config[:default_strategies]
    strategies = defaults[scope] || defaults[:_all]
  end

  (strategies || args).each do |name|
    strategy = _fetch_strategy(name, scope)
    next unless strategy && !strategy.performed? && strategy.valid?
    catch(:warden) do
      _update_winning_strategy(strategy, scope)
    end

    strategy._run!
    _update_winning_strategy(strategy, scope)
    break if strategy.halted?
  end
end

winning_strategy は恐らく最後に呼び出したstrategyです。初回は nil にっているはずです。 args は env['warden'].authenticate! の引数でしたね。 今回の場合 authenticate! には何も渡してないので args は空です。 argsが空の場合、@config からstrategies を取り出します 「 rack middleware の登録」の箇所に書いた通り@configは以下のようになっているので、今回の場合 strategies は [:password, :basic] になります

{
  default_strategies: {
    _all: [:password]
  },
  failure_app: BadAuthenticationEndsUpHere
}

続いて取得した strategies をループ回して _fetch_strategy(name, scope) で strategyの実態の取り出し、 strategy._run! でstrategyの実行を行なっています。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L379-L387
_fetch_strategy(name, scope)

def _fetch_strategy(name, scope)
  @strategies[scope][name] ||= if klass = Warden::Strategies[name]
    klass.new(@env, scope)
  elsif @config.silence_missing_strategies?
    nil
  else
    raise "Invalid strategy #{name}"
  end
end

冒頭で登録した strategy の実態を取り出し、そのインスタンスが返されています。 strategy の実態が定義されていない場合は 例外が返されます。 strategy を取得できたら strategy._run! を呼び出します。 strategy は Warden::Strategies::Base のサブクラスとして定義されているので Warden::Strategies::Base#_run! のを見ます。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/strategies/base.rb#L52-L56
def _run! # :nodoc:
  @performed = true
  authenticate!
  self
end

@performed にして authenticate! を呼び出していますね。strategy の authenticate! メソッドは 冒頭で strategy の実態を登録する際に定義した認証処理でしたね。 authenticate! では任意の認証処理を実行して 失敗した場合は fail! 成功した場合は success!(user) を呼び出しています。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/strategies/base.rb#L144-L147
def fail!(message = "Failed to Login")
  halt!
  @message = message
  @result = :failure
end

# https://github.com/wardencommunity/warden/blob/master/lib/warden/strategies/base.rb#L125-L130
def success!(user, message = nil)
  halt!
  @user = user
  @message = message
  @result = :success
end

fail! の場合は helt!@helted を true にして @result:failure を代入しています。 @helted が true の場合だと後続のstrategy が実行されないので、後続のstrategy もトライする場合は fail! ではなくfailで呼び出す必要があります。 success!helt!を実行し、 @user に認証によって取得することができた user を代入しています。

strategy._run! で認証処理を実行した後は_update_winning_strategy(strategy, scope) でwinning_strategyを直前に実行したstrategyに更新します。その後strategy.helted? を確認して true だと 認証が終了したとみなしループを抜けます。

_run_strategies_for の処理を見たので _perform_authentication に戻ります。

_run_strategies_for が終了すると winning_strategy.successful? で直前に実行された認証が成功しているか確認します。

成功していれば set_user(winning_strategy.user, opts.merge!(:event => :authentication)) を実行します。

# https://github.com/wardencommunity/warden/blob/master/lib/warden/proxy.rb#L170-L194
def set_user(user, opts = {})
  scope = (opts[:scope] ||= @config.default_scope)

  # Get the default options from the master configuration for the given scope
  opts = (@config[:scope_defaults][scope] || {}).merge(opts)
  opts[:event] ||= :set_user
  @users[scope] = user

  if opts[:store] != false && opts[:event] != :fetch
    options = env[ENV_SESSION_OPTIONS]
    if options
      if options.frozen?
        env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
      else
        options[:renew] = true
      end
    end
    session_serializer.store(user, scope)
  end

  run_callbacks = opts.fetch(:run_callbacks, true)
  manager._run_callbacks(:after_set_user, user, self, opts) if run_callbacks

  @users[scope]
end

set_user では @usersに認証で取得できたuser のデータをキャッシュし、sessionに書き込んでいます。

set_user の処理が終わると [@users[scope], opts] を返して _perform_authentication の処理は終わりです。

_perform_authentication が終わったので authenticate! に戻ります。

だいぶ離れたのでコードをいかに再掲

def authenticate!(*args)
  user, opts = _perform_authentication(*args)
  throw(:warden, opts) unless user
  user
end

_perform_authentication は strategy で定義した認証が成功したら user は認証によって取得されたデータ。失敗したら nilが返ってきました。

認証が成功した場合は そのまま user を返して終わりです。 認証が失敗した場合は、認証が失敗した場合は throw(:warden, opts) で大域脱出をはかり、 Warden::Manager#call まで処理が戻ります。以下再掲

def call(env) # :nodoc:
  return @app.call(env) if env['warden'] && env['warden'].manager != self

  env['warden'] = Proxy.new(env, self)
  result = catch(:warden) do
    env['warden'].on_request
    @app.call(env)
  end

  result ||= {}
  case result
  when Array
    handle_chain_result(result.first, result, env)
  when Hash
    process_unauthenticated(env, result)
  when Rack::Response
    handle_chain_result(result.status, result, env)
  end
end

認証に成功した場合は result は@app.call(env) の戻り値になります。rack middleware の場合は基本的に Array になります。 認証に失敗した場合は throw(:warden, opts) の opts がresult に入ります。opts は Hash です。

認証が正常に終了した場合は result は Array のはずなので handle_chain_result を実行します。 handle_chain_result は基本的に result をそのまま返しています。

認証が失敗した場合は result は Hash なので process_unauthenticated(env, result) を実行します。

process_unauthenticated では 基本的に 冒頭で登録した failure_app の call メソッドを呼び出します。 failure_app をカスタマイズすることで、認証失敗時の処理を細かくカスタマイズすることができます。

終わりに

長くなったかつ最後の方はちょっと力尽きた感ありましたが、warden のコードを読む機会があったのでメモしておきます