Rails Warden 的代码学习

adamshen · 发布于 2017年04月21日 · 最后由 adamshen 回复于 2017年06月23日 · 3162 次阅读
A908ae
本帖已被设为精华帖!

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的类变量即可。

共收到 3 条回复
De6df3 huacnlee 将本帖设为了精华贴 04月21日 13:56
96

嗨,你好嗎

A908ae
32Besier232 回复

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

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