目前项目遇到个需求,一期用devise做登陆的后台,在做二期的时候需要可以通过一期的cookie,带到用api(grape)做鉴权的子系统中去。这时候需要做的是保证在子系统中能把cookie正确解析出来,而且需要验证解析出来的cookie的信息是否是正确的,这时就需要了解devise依赖的warden是怎么设置用户鉴权cookie值的。周末有时间了解了一下devise和warden实现的细节问题,记录一下这个过程。
基本知识
每个用户访问网站时一般都会有独立的cookie值记录一些信息,cookie可能有多个键值对,可以用一个值来存储后端需要记录当前用户的一些信息(在后端表示的是session,当前情况是session保存到cookie中,当然也可以保存到redis等缓存服务器中)。这个值是后端生成的,然后作为response的header,放到Set-Cookie字段中返回给浏览器,浏览器会把这个值保存到cookie中。然后下次浏览器请求服务器的时候,服务器会解析那串字符串,然后把信息解析出来放到session对象中,这时session中就有之前设置的信息了。
同理,一般我们做用户登陆授权验证,如果不用devise,我们一般是直接把用户的id记录在session里面,记录用户的一次会话是否登录,避免后续的请求也需要输入密码验证用户的身份。利用的原理类似上面所述。其实warden也是做了类似的处理,也是把一些验证数据放到session中去,只不过devise把用户的id和加密密码前30位组成的数组作为value值。然后在浏览器访问服务器时,把那串cookie解析出来。解析出来的数据就是类似[id, authenticatable_salt]这种形式,然后对比是否匹配就可以了。
实现
# ~/.rvm/gems/ruby-2.4.3/gems/devise-4.6.1/lib/devise/rails.rb
config.app_middleware.use Warden::Manager do |config|
Devise.warden_config = config
end
# ~/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/config.rb
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? # @config 注入到上面的块中作为参数,供devise调用
end
Warden是一个Rack Middleware,middleware初始化的时候会把一些config的一些东西作为块的参数,供外部的接口调用。
# /Users/Cain/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/config.rb
def serialize_into_session(*args, &block)
Warden::Manager.serialize_into_session(*args, &block)
end
# ~/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/manager.rb
def serialize_into_session(scope = nil, &block)
method_name = scope.nil? ? :serialize : "#{scope}_serialize"
Warden::SessionSerializer.send :define_method, method_name, &block
end
在Warden::Config中定义的serialize_into_session
方法可以把需要用来标识用户身份的信息系列化到cookie中,这是向外提供的一个接口,供外面去调用。做的事是在Warden::SessionSerializer
中定义一个#{scope}_serialize
的实例方法。在后面存储session时,会调用这个方法去求session的值,方法体就是我们在外部定义的块。在devise中定义的规则如下:
# ~/.rvm/gems/ruby-2.4.3/gems/devise-4.6.1/lib/devise/rails.rb
warden_config.serialize_into_session(mapping.name) do |record|
mapping.to.serialize_into_session(record) # serialize_into_session调用的是下面的方法
end
# ~/.rvm/gems/ruby-2.4.3/gems/devise-4.6.1/lib/devise/models/authenticatable.rb
def serialize_into_session(record)
[record.to_key, record.authenticatable_salt]
end
config中还有一个类似的方法serialize_from_session
,作用和上面类似。只不过这个方法是从session中读取出用户信息的。
知道了初始化时上面两个方法的作用,那warden是如何具体验证用户信息和保存用户信息的呢?其中就用到了叫strategies的东西,那个东西是外部提供给warden,然后warden在需要的时候会用那些strategies去验证用户是否有效,如果有效就调用success!(resource)
这个方法。如用密码登陆用户时,devise是这样做的:
在devise的Devise::SessionsController#create
中调用 warden.authenticate!(auth_options)
去验证用户的有效性。
# ~/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/proxy.rb
def authenticate!(*args)
user, opts = _perform_authentication(*args)
throw(:warden, opts) unless user
user
end
def _perform_authentication(*args)
...
return user, opts if user = user(opts.merge(:scope => scope)) # 这一步为了检验session中是否有用户信息了,有了就直接返回用户信息
_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
def _run_strategies_for(scope, args) #:nodoc:
...
(strategies || args).each do |name|
strategy = _fetch_strategy(name, scope)
next unless strategy && !strategy.performed? && strategy.valid?
strategy._run!
self.winning_strategy = @winning_strategies[scope] = strategy
break if strategy.halted?
end
end
_run_strategies_for
方法中的valid?
和strategy._run!
方法会执行到我们定义在strategy中的valid?和authenticate!方法,在devise中默认会定义好两个strategies,一个是database_authenticatable,一个是rememberable,第一个strategie是为了用密码去验证用户登录是否有效了,第二个是用来记住用户密码时候生成的一个token,保存在浏览器,过期时间比较长,这样在登录授权的那个cookie过期后,还可以用那个token去验证用户的身份。
在 _perform_authentication
方法中,如果strategy执行成功了,则会执行set_user
方法
# ~/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/proxy.rb
def set_user(user, opts = {})
...
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
...
@users[scope]
end
# ~/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/session_serializer.rb
def store(user, scope)
return unless user
method_name = "#{scope}_serialize"
specialized = respond_to?(method_name)
session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user)
end
上面的send(method_name, user)
方法就是调用到了上面说的Warden::SessionSerializer
中定义的一个#{scope}_serialize
的实例方法,这里把系列化的信息存到session去了。
存储后每次要验证cookie中用户身份的有效性,则通过调用fetch方法去获取cookie中存储的用户信息。
def fetch(scope)
key = session[key_for(scope)]
return nil unless key
method_name = "#{scope}_deserialize"
user = respond_to?(method_name) ? send(method_name, key) : deserialize(key)
delete(scope) unless user
user
end
到这里基本的逻辑就比较清楚了,Warden提供一个接口,让用户自定义验证,用什么方式(authenticate!方法),保存到session中的值是什么,读取cookie中的系列值应该怎么去验证是否有效这一些列的要素。然后在Warden内部负责把那些东西设置好,session设置的key,还有需要验证的步骤有哪些。