Rails Warden 的代码学习

adamshen · 2017年04月21日 · 最后由 adamshen 回复于 2017年06月23日 · 7596 次阅读
本帖已被管理员设置为精华贴

warden 是 devise 依赖的一个 Authentication Framework,作为一个框架,warden 解决了下面这些问题

auth result handle

要能够处理验证的结果,验证通过就跳到下面的 action 执行,不然就返回错误或者重定向到登陆界面

scope

比如说要能同时实现 admin 和 user 的验证,一个用户可以同时登陆 admin 账户和 user 账户

strategies

比如先从 session 里找 user_id,然后到 header 里找 token 等等,支持多种命中策略

Warden

接下来看看 warden 如何实现这些功能,以及 devise 是如何调用这些功能的

auth result handle

warden 解决这个问题办法是将自己作为一个 rack middleware 注册在 rails 之前,然后使用 catch 和 throw 在验证未通过时,接管响应流程

# In Warden::Manager

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
    @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

这样一来,不管在 controller 里的任何地方 throw(:warden),该请求就会立即被 warden 接管

warden 在 rack env 里增加了一个 warden 属性,使用 lazy load 的模式给下面一层调用。在 controller 里如果需要用到验证就去 get 这个对象,不然直接不用就行了

devise 会注册一个 helper 方法,然后把 authentication_xxxxx! 方法委托到 env['warden'] 对象上来执行

# In Devise::Controller::Helpers

def warden
  request.env['warden'] or raise MissingWarden
end

def self.define_helpers(mapping) #:nodoc:
  mapping = mapping.name

  class_eval <<-METHODS, __FILE__, __LINE__ + 1
    def authenticate_#{mapping}!(opts={})
      opts[:scope] = :#{mapping}
      warden.authenticate!(opts) if !devise_controller? || opts.delete(:force)
    end

    def #{mapping}_signed_in?
      !!current_#{mapping}
    end

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

    def #{mapping}_session
      current_#{mapping} && warden.session(:#{mapping})
    end
  METHODS

  ActiveSupport.on_load(:action_controller) do
    if respond_to?(:helper_method)
      helper_method "current_#{mapping}", "#{mapping}_signed_in?", "#{mapping}_session"
    end
  end
end

这个对象的 authenticate! 方法会做身份确认,如果没有通过就 throw(:warden)

# In Warden::Proxy

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

对于不同类型的认证失败,需要配置一个 failure_app 来处理后续的流程

对于 devise 来说,这个 failure_app 是 Devise::FailureApp,devise 会把它写入到 warden 的配置里

所以使用用户名密码的策略认证失败(关于策略下面再说)会返回通知,而没有登陆则会重定向到 sign_in 页面

# In Warden::Manager

def call_failure_app(env, options = {})
  if config.failure_app
    options.merge!(:attempted_path => ::Rack::Request.new(env).fullpath)
    env["PATH_INFO"] = "/#{options[:action]}"
    env["warden.options"] = options

    _run_callbacks(:before_failure, env, options)
    config.failure_app.call(env).to_a
  else
    raise "No Failure App provided"
  end
end # call_failure_app

scope

warden 实现 scope 的方式是用一个 hash 来记录不同 scope 的 user 对象,把 scope 的名字用作 key,然后大量的 api 里把 scope 作为可选参数

# In Warden::Proxy

def initialize(env, manager) #:nodoc:
  @env, @users, @winning_strategies, @locked = env, {}, {}, false
  @manager, @config = manager, manager.config.dup
  @strategies = Hash.new { |h,k| h[k] = {} }
  manager._run_callbacks(:on_request, self)
end

def session(scope = @config.default_scope)
  raise NotAuthenticated, "#{scope.inspect} user is not logged in" unless authenticated?(scope)
  raw_session["warden.user.#{scope}.session"] ||= {}
end

def authenticated?(scope = @config.default_scope)
  result = !!user(scope)
  yield if block_given? && result
  result
end

# Same API as authenticated?, but returns false when authenticated.
# :api: public
def unauthenticated?(scope = @config.default_scope)
  result = !authenticated?(scope)
  yield if block_given? && result
  result
end

上面@users就是那个可以包含不同 scope 的 user 对象的 hash,从 session 方法可以看到,不同的 scope 在 session 中是有 namespace 作为区分的,这样就可以从 session 中读出不同 scope 的 user_id

devise 里有 map 的概念,一个 map 对应一个 scope

map 的配置用 Devise::Mapping 来记录,这里不继续展开,devise 会为每个 scope 注册不同的 helper 方法,这样在调用 warden api 的时候,这个 map 对应的 scope 就自动作为参数传给了 warden

Devise.mappings
=> {:user=>
  #<Devise::Mapping:0x007f9e0d5eded8
   @class_name="User",
   @controllers={:sessions=>"devise/sessions", :passwords=>"devise/passwords", :registrations=>"devise/registrations"},
   @failure_app=Devise::FailureApp,
   @format=nil,
   @klass=#<Devise::Getter:0x007f9e0d5eda00 @name="User">,
   @modules=[:database_authenticatable, :rememberable, :recoverable, :registerable, :validatable, :trackable],
   @path="users",
   @path_names={:registration=>"", :new=>"new", :edit=>"edit", :sign_in=>"sign_in", :sign_out=>"sign_out", :password=>"password", :sign_up=>"sign_up", :cancel=>"cancel"},
   @path_prefix=nil,
   @router_name=nil,
   @routes=[:session, :password, :registration],
   @scoped_path="users",
   @sign_out_via=:delete,
   @singular=:user,
   @strategies=[:rememberable, :database_authenticatable],
   @used_helpers=[:session, :password, :registration],
   @used_routes=[:session, :password, :registration]>}

strategies

回到 authenticate! 方法里做检查的部分,看看 warden 如何做身份验证

# In Warden::Proxy

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

这个方法大致的作用是从 session 里找出某个 scope 的 key,如果有的话就根据提前定义的规则根据 key 来找到 user 对象。如果 session 里不存在 key,那么就根据这个 scope 的 strategies 列表来进行 auth,如果有策略命中的话就会返回 user 对象

strategy 存在类变量里

# In Warden::Proxy

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
# In Warden::Strategies

def [](label)
  _strategies[label]
end

# :api: private
def _strategies
  @strategies ||= {}
end

strategy 必须继承自 Warden::Strategies::Base,含有 authenticate! 方法,在 devise 下 2 个默认的 strategy 在 lib/devise/strategies 目录下

这是根据用户名密码校验用户的 strategy 的 auth 方法,检查密码的时候有用到 BCrypt

# In Devise::Strategies::DatabaseAuthenticatable

def authenticate!
    resource  = password.present? && mapping.to.find_for_database_authentication(authentication_hash)
    hashed = false

    if validate(resource){ hashed = true; resource.valid_password?(password) }
      remember_me(resource)
      resource.after_database_authentication
      success!(resource)
    end

    mapping.to.new.password = password if !hashed && Devise.paranoid
    fail(:not_found_in_database) unless resource
end

devise 会给每个 scope 注册好 strategies,这样 warden 就会按照顺序去做检查了

# In Devise

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

总结

devise 依赖于 warden,要看 devise 的代码,最好先把 warden 代码过一遍

warden 是一个 rack app,会生成 env['warden'] 对象,供 Rails Controller 调用

warden 对不同的 scope 可以配置不同的 strategy。在进行 authenticate 的时候,要把 scope 传给 warden(或默认 default_scope)。warden 先查找 session 里的 user_key,并根据 session 里的 user_key 找到 user 对象,如果失败就会取出 scope 的 strategies 列表,挨个命中。

使用 catch 和 throw 可以跳出多层调用栈,有点像 goto 语句。如果未通过身份认证,通过 catch(:warden),warden 会控制响应的后续过程。

warden 的设计很值得学习,作为一个框架,它将 authenticate 的部分问题解决了,并提供了简洁的 api 供上层调用。要完成 authenticate 验证,只需要写 strategy 和 failure_app 类,并将配置写入 warden 的类变量即可。

huacnlee 将本帖设为了精华贴。 04月21日 13:56

嗨,你好嗎

Besier232 回复

怎么了?兄弟。我,很好啊最近。。。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号