Devise 源码浅析
前言
Devise 是 ruby 世界中最常见的 gem 之一,是用于在 web 请求中做身份验证的,他的设计非常精妙,今天我们来尝试看看 devise 是如何设计的。
如何使用 Devise
devise 的使用重点是为 rails 的 MVC 三层中各引入 devise 相关的 magic 方法,比如我们今天以 User 模型为例
为 controller 层和 view 层引入 devise,需要在
config/routes.rb
中引入devise_for :users
为 model 层引入 devise,需要在
app/models/user.rb
中引入devise :database_authenticatable
上述我们引入了
database_authenticatable
模块,而 devise 中共有十个模块可由我们按需引入
devise_for
整个 devise 的核心之一就是 devise_for
方法,如果没有调用这个方法,就不会生成可供我们在 controller 层 和 view 层使用的 helper
def devise_for(*resources)
...
...
resources.each do |resource|
mapping = Devise.add_mapping(resource, options)
begin
raise_no_devise_method_error!(mapping.class_name) unless mapping.to.respond_to?(:devise)
rescue NameError => e
raise unless mapping.class_name == resource.to_s.classify
warn "[WARNING] You provided devise_for #{resource.inspect} but there is " \
"no model #{mapping.class_name} defined in your application"
next
rescue NoMethodError => e
raise unless e.message.include?("undefined method `devise'")
raise_no_devise_method_error!(mapping.class_name)
end
...
...
end
end
devise_for
方法在调用之后会对每一个传入的值比如 users
调用 Devise.add_mapping
,而在这里就引入一个概念:Devise
中的 mapping
。Devise
为了支持多种账户的验证,比如 user
和 admin
账户都可以登录,在内部使用了 mapping
进行区分,每一种账户对应一个 mapping
,也对应了后面会讲的 Warden
中的 scope
。我们可以对每一个 mapping
来设置不同的策略,比如允许 user
账户进行注册,不允许 admin
账户进行注册等,这样就解决了同一个系统中不同账户验证逻辑需要写两套的问题
Devise.add_mapping
# Small method that adds a mapping to Devise.
def self.add_mapping(resource, options)
mapping = Devise::Mapping.new(resource, options)
@@mappings[mapping.name] = mapping
@@default_scope ||= mapping.name
@@helpers.each { |h| h.define_helpers(mapping) }
mapping
end
当调用 Devise.add_mapping
时
会生成一个
Devise::Mapping
对象,这个对象就是上面说的mapping
在生成之后会将这个对象放入
Devise.mappings
映射中,方便后续的取用然后将默认验证账户
scope
设置为第一个调用add_mapping
方法的值为
mapping
定义帮助方法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
比如 user ,会生成
authenticate_user!
、user_signed_in?
、current_user
、user_session
四个帮助方法,而authenticate_user!
就是我们实现验证的重要方法
authenticate_user!
在我们的实际应用中,当我们需要对某个 controller
增加验证,不登录就无法访问时,我们需要添加 before_action :authenticate_user!
,比如:
class ApplicationController < ActionController::Base
before_action :authenticate_user!
end
然后每个请求到来时,就会调用 authenticate_user!
方法对其进行校验
# 为了方便,我将源码中元编程生成的代码换成了普通的 ruby 代码
def authenticate_user!(opts={})
opts[:scope] = :user
warden.authenticate!(opts) if !devise_controller? || opts.delete(:force)
end
上述代码可以看出,authenticate_user!
方法实际上是 warden.authenticate!
方法的代理,所以我们要研究 devise
是如何验证的,就需要查看 warden
是什么
def warden
request.env['warden'] or raise MissingWarden
end
class MissingWarden < StandardError
def initialize
super "Devise could not find the `Warden::Proxy` instance on your request environment.\n" + \
"Make sure that your application is loading Devise and Warden as expected and that " + \
"the `Warden::Manager` middleware is present in your middleware stack.\n" + \
"If you are seeing this on one of your tests, ensure that your tests are either " + \
"executing the Rails middleware stack or that your tests are using the `Devise::Test::ControllerHelpers` " + \
"module to inject the `request.env['warden']` object for you."
end
end
warden
方法从 requset 的 env 中取出,那这个 warden 是如何被注入的呢,其实 MissingWarden
错误中已经给了我们不少提示,request.env['warden']
是一个 Warden::Proxy
对象,而这个对象又是由 Warden::Manager
中间件注入
rack 和 middleware
几乎所有的 Ruby Web 框架都是一个 rack 的应用,而 rails 就是一个 rack 应用加一堆 middleware 的集合,我们可以通过 rails middleware
来查看
$ rails middleware
use Webpacker::DevServerProxy
use Raven::Rack
use Rack::Cors
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use RequestStore::Middleware
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
use Warden::Manager
use ExceptionNotification::Rack
use Rack::Attack
run ApplictionName::Application.routes
use
后面跟随的就是 rails 启动的 middlreware 名称,最后包含的就是 rack ApplictionName::Application.routes
,也就是说在 Rails 中所有的请求在经过中间件之后都会先有一个路由表来处理,路由会根据一定的规则将请求交给其他控制器处理。
中间件的调用其实类似于柯里化
# config.ru
use MiddleWare1
use MiddleWare2
run RackApp
# equals to
MiddleWare1.new(MiddleWare2.new(RackApp)))
具体的 middleware 相关就不延展了。而在上面我们可以看到 Warden::Manager
中间件已被启用,这就是 devise
验证的基础
Warden
Warden::Manager
module Warden
class Manager
extend Warden::Hooks
attr_accessor :config
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
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
end
Warden::Manager
中最重要的两个方法就是 initialize
,call
initialize
:当一个 rack 应用被启用时,会对每一个中间件调用new
初始化中间件,并传入一个 app 和一个哈希 optionscall
:当每个请求到来时,会依次调用中间件的call
方法对请求进行处理,call
方法接受一个参数 env,并在结束时将 env 返回env
是一个三元数组,按照 rack 协议,分别代表[HTTP 状态码, HTTP Headers, 响应体]
所以当请求到来时,Warden::Manager#call
方法被调用,env['warden']
被赋值为 Warden::Proxy
的一个对象,这样在 authenticate_user!
方法中就可以使用 warden.authenticate!
进行验证
这里的核心重点是 result = catch(:warden)
,call
方法会在应用 throw(:warden)
时接管整个后续响应,这时候会根据 result
类型进行处理,通常会进入 process_unauthenticated
def process_unauthenticated(env, options={})
options[:action] ||= begin
opts = config[:scope_defaults][config.default_scope] || {}
opts[:action] || 'unauthenticated'
end
proxy = env['warden']
result = options[:result] || proxy.result
case result
when :redirect
body = proxy.message || "You are being redirected to #{proxy.headers['Location']}"
[proxy.status, proxy.headers, [body]]
when :custom
proxy.custom_response
else
options[:message] ||= proxy.message
call_failure_app(env, options)
end
end
根据 options
中 :result
的不同区分处理方式,通常会调用 call_failure_app
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
然后调用 config.failure_app.call
进行处理,这个 config.failure_app
通常是 Devise::FailureApp
,当然我们也可以自定义失败处理方式
Devise::FailureApp
的处理方式根据配置可以是重定向登录页或者返回401响应码及具体的失败信息,具体的实现和源码可以自行研究
Warden::Proxy
authenticate!
def authenticate!(*args)
user, opts = _perform_authentication(*args)
throw(:warden, opts) unless user
user
end
执行 authenticate!
时,会调用 _perform_authentication
进行验证
- 如果 user 没有被返回的话就会抛出
throw(:warden)
中断请求的执行,将后续处理移交Warden:Manager
- 如果 user 被返回,则通过验证,进行对应的
controller#action
def _perform_authentication(*args)
scope, opts = _retrieve_scope_and_opts(args)
user = nil
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
在 _perform_authentication
方法中
首先会调用
user
,这个方法会反序列化 session 然后查看 session 中是否有该 scope 的用户信息- 如果有,则通过验证,进行对应的
controller#action
- 如果没有,进行第二步
- 如果有,则通过验证,进行对应的
然后调用
_run_strategies_for
,对预定策略strategies
依次进行校验# Run the strategies for a given scope 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? strategy._run! self.winning_strategy = @winning_strategies[scope] = strategy break if strategy.halted? end end
而这需要获取 scope 对应配置了哪些 strategies,默认查找
Warden::Config
中配置的default_strategies
if args.empty? defaults = @config[:default_strategies] strategies = defaults[scope] || defaults[:_all] end
devise
前面 讲了在 model 层需要定义 devise
方法,这个方法的目的就是引入模块
def devise(*modules)
options = modules.extract_options!.dup
selected_modules = modules.map(&:to_sym).uniq.sort_by do |s|
Devise::ALL.index(s) || -1 # follow Devise::ALL order
end
devise_modules_hook! do
include Devise::Models::Authenticatable
selected_modules.each do |m|
mod = Devise::Models.const_get(m.to_s.classify)
if mod.const_defined?("ClassMethods")
class_mod = mod.const_get("ClassMethods")
extend class_mod
if class_mod.respond_to?(:available_configs)
available_configs = class_mod.available_configs
available_configs.each do |config|
next unless options.key?(config)
send(:"#{config}=", options.delete(config))
end
end
end
include mod
end
self.devise_modules |= selected_modules
options.each { |key, value| send(:"#{key}=", value) }
end
end
这个方法主要是将各个模块中的定义的实例方法 inclue
到 model 中,将类方法 extend
到 model 中。还有一个重点是为 model 定义了一个 User.devise_modules
方法
strategies
module Devise
class Mapping
def modules
@modules ||= to.respond_to?(:devise_modules) ? to.devise_modules : []
end
def strategies
@strategies ||= STRATEGIES.values_at(*self.modules).compact.uniq.reverse
end
end
end
每一个 mapping 对象 都会有一个 #strategies
方法,判断 User.devise_modules
是否包含 Devise::STRATEGIES
中的模块
当应用启动路由初始化完成后调用会调用 Devise.configure_warden!
方法
def self.configure_warden! #:nodoc:
@@warden_configured ||= begin
warden_config.failure_app = Devise::Delegator.new
warden_config.default_scope = Devise.default_scope
warden_config.intercept_401 = false
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
@@warden_config_blocks.map { |block| block.call Devise.warden_config }
true
end
end
这个方法会将每一个 mapping 对象 的 #strategies
定义到对应的 scope_defaults
中。
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
def scope_defaults(scope, opts = {})
if strategies = opts.delete(:strategies)
default_strategies(strategies, :scope => scope)
end
...
end
scope_defaults
方法又会将 strategies
最终存储在 default_strategies
,故 验证时最终会查找 default_strategies
Devise::Strategies
在 Devise 中默认有两个策略
Devise::Strategies::DatabaseAuthenticatable
Devise::Strategies::Rememberable
DatabaseAuthenticatable
lib/devise/strategies/database_authenticatable.rb
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
unless resource
Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database)
end
end
database_authenticatable 策略首先会从数据库中查找是否有这个用户,然后会校验密码是否正确,中间会用 BCrypt 进行密码的哈希运算比对。
Rememberable
lib/devise/strategies/rememberable.rb
def authenticate!
resource = mapping.to.serialize_from_cookie(*remember_cookie)
unless resource
cookies.delete(remember_key)
return pass
end
if validate(resource)
remember_me(resource) if extend_remember_me?(resource)
resource.after_remembered
success!(resource)
end
end
rememberable 策略会从 cookie 中获取用户的 id 以及 remember_token 和过期时间,判断用户的 token 是否匹配以及是否过期
自定义验证策略
我们也可以自定义 strategy
- strategy 必须继承自
Warden::Strategies::Base
,每一个 strategy 必须实现#authenticate!
方法 - 调用
Warden::Strategies.add
方法,将 strategy 注册到 Warden 中 - 在 controller 中调用
before_action :authenticate_user!, [自定义策略名]
进行验证
总结
devise 通过将自身模块化,实现了功能的解藕,当我们需要使用某些模块时,只需要 devise
导入就行。在验证方面 devise 默认提供两个策略,而这些策略的执行是由 devise 中抽象出来的一个中间件 warden 来处理,又进一步解藕了策略与执行,我们可以随时增添策略,也可以指定是否运行某一个策略,甚至实现自己的 strategy
。
在验证失败时,devise 使用 throw 和 catch 语句跳出后续 controller#action
的执行,直接处理失败响应,这里又一次将失败处理与 devise 解藕,我们可以选择使用默认的 Devise::FailureApp
也可以自己实现一个 FailureApp