Devise 源码浅析

前言

Devise 是 ruby 世界中最常见的 gem 之一,是用于在 web 请求中做身份验证的,他的设计非常精妙,今天我们来尝试看看 devise 是如何设计的。

如何使用 Devise

devise 的使用重点是为 rails 的 MVC 三层中各引入 devise 相关的 magic 方法,比如我们今天以 User 模型为例

  1. 为 controller 层和 view 层引入 devise,需要在 config/routes.rb 中引入

    devise_for :users
  1. 为 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 为了支持多种账户的验证,比如 useradmin 账户都可以登录,在内部使用了 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

  1. 会生成一个 Devise::Mapping 对象,这个对象就是上面说的 mapping

  2. 在生成之后会将这个对象放入 Devise.mappings 映射中,方便后续的取用

  3. 然后将默认验证账户 scope 设置为第一个调用 add_mapping 方法的值

  4. 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_useruser_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 中最重要的两个方法就是 initializecall

  • initialize:当一个 rack 应用被启用时,会对每一个中间件调用 new 初始化中间件,并传入一个 app 和一个哈希 options
  • call:当每个请求到来时,会依次调用中间件的 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 进行验证

  1. 如果 user 没有被返回的话就会抛出 throw(:warden) 中断请求的执行,将后续处理移交 Warden:Manager
  2. 如果 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 方法中

  1. 首先会调用 user,这个方法会反序列化 session 然后查看 session 中是否有该 scope 的用户信息

    1. 如果有,则通过验证,进行对应的 controller#action
    2. 如果没有,进行第二步
  2. 然后调用 _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
  3. 而这需要获取 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

  1. strategy 必须继承自 Warden::Strategies::Base,每一个 strategy 必须实现 #authenticate! 方法
  2. 调用 Warden::Strategies.add 方法,将 strategy 注册到 Warden 中
  3. 在 controller 中调用 before_action :authenticate_user!, [自定义策略名] 进行验证

总结

devise 通过将自身模块化,实现了功能的解藕,当我们需要使用某些模块时,只需要 devise 导入就行。在验证方面 devise 默认提供两个策略,而这些策略的执行是由 devise 中抽象出来的一个中间件 warden 来处理,又进一步解藕了策略与执行,我们可以随时增添策略,也可以指定是否运行某一个策略,甚至实现自己的 strategy

在验证失败时,devise 使用 throw 和 catch 语句跳出后续 controller#action 的执行,直接处理失败响应,这里又一次将失败处理与 devise 解藕,我们可以选择使用默认的 Devise::FailureApp 也可以自己实现一个 FailureApp