warden 是 devise 依赖的一个 Authentication Framework,作为一个框架,warden 解决了下面这些问题
auth result handle
要能够处理验证的结果,验证通过就跳到下面的 action 执行,不然就返回错误或者重定向到登陆界面
scope
比如说要能同时实现 admin 和 user 的验证,一个用户可以同时登陆 admin 账户和 user 账户
strategies
比如先从 session 里找 user_id,然后到 header 里找 token 等等,支持多种命中策略
接下来看看 warden 如何实现这些功能,以及 devise 是如何调用这些功能的
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
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]>}
回到 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 的类变量即可。