分享 Rails 中 Cookie 和 Session 的关联

mr_zou123 · 2019年05月04日 · 最后由 w569893882 回复于 2019年05月08日 · 4142 次阅读

上一节介绍了 warden 是如何验证用户的登录授权身份,但是对于 cookie 和 session 之间是如何生成的,是如何产生关联的,然后是怎么通过保存 session 数据到 cookie 中的还有点模糊。总结分享下 Rails 中是如何处理 cookie 和 session 的,有不正确的地方欢迎大家指出😀

Rails 中 Session 和 Cookie 是如何关联的

session 可以有多种保存方式,可以通过保存在 cookie 中,也可以通过 cache_store 或者 activerecord_session_store(现在用独立的gem来实现了) 的方式去保存。如果是通过 cookie 保存的方式,则会把 session 中设置的键值对通过处理后放到一个特定的 key 下面,默认是项目名_session。每次浏览器发出请求,都会带上该 cookie 键值对,然后服务端通过解出那串 cookie 值来识别设置 session。而数据库或者用 cache 的方式都是通过把请求中的 session_id 解析出来,再去数据库查找对应的 session 数据。下面具体讨论 session 存储在 cookie 中的方式。

Rails 中设置 Session 存储方式

# ~/.rvm/gems/ruby-2.4.3/gems/railties-5.1.6.2/lib/rails/application/finisher.rb
initializer :setup_default_session_store, before: :build_middleware_stack do |app|
  unless app.config.session_store?
    app_name = app.class.name ? app.railtie_name.chomp("_application") : ""
    app.config.session_store :cookie_store, key: "_#{app_name}_session"
  end
end

如果在 application.rb 中没有配置 session_store,则会用 cookie_store 作为默认的 session 存储方式,key 默认的名字是_#{app_name}_session

# ~/.rvm/gems/ruby-2.4.3/gems/railties-5.1.6.2/lib/rails/application/default_middleware_stack.rb
middleware.use config.session_store, config.session_options

# ~/.rvm/gems/ruby-2.4.3/gems/railties-5.1.6.2/lib/rails/application/configuration.rb
def session_store(new_session_store = nil, **options)
  ...
  else
    case @session_store
    when :disabled
      nil
    when :active_record_store
      ActionDispatch::Session::ActiveRecordStore
    when Symbol
      ActionDispatch::Session.const_get(@session_store.to_s.camelize)
    else
      @session_store
    end
  end
end

上面是通过@session_store实例变量的名字去获取对应的类,而@session_store是在执行setup_default_session_store初始化的时候设置的 cookie_store。

session_store 是一个 middleware,项目初始化的时候会去初始化一个 session 对象

ActionDispatch::Session::CookieStore#initialize => ActionDispatch::Session::Compatibility#initialize => Rack::Session::Abstract::Persisted#initialize

上面是一个 middleware 初始化调用栈的过程

CookieStore 继承了 AbstractStore 类,AbstractStore 继承了 Rack::Session::Abstract::Persisted,这个类是以 middleware 的接口规范去定义的。所以当一个请求发送过来时,会执行到 CookieStore 这个 middleware 上的 call 方法,然后再调用 context 方法。

# ~/.rvm/gems/ruby-2.4.3/gems/rack-2.0.6/lib/rack/session/abstract/id.rb

def call(env)
  context(env)
end

def context(env, app=@app)
  req = make_request env
  prepare_session(req) # 在header中设置session
  status, headers, body = app.call(req.env)
  res = Rack::Response::Raw.new status, headers
  commit_session(req, res)
  [status, headers, body]
end

上面的 context 方法可分为执行app.call,执行app.call之前和之后这三个部分。在调用app.call之前是在 header(这里的 header 不是 http 的 header,而是作为项目环境中的一个键值对保存,下面的 header 默认指这个) 中初始化了一个 Session 实例,到时在 controller 中对 session 的键值对的设置都会改变其中的实例变量的值。接着就是执行app.call等一系列的 middleware,设置好 cookie 和 session。第三部分就是利用第二部分设置好的 session,通过上面的commit_session方法把 session 中的数据经过处理后 set 到 cookie 中,上面是总的过程,下面分步解析。

初始化 Session 实例

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/session/abstract_store.rb
def prepare_session(req)
  Request::Session.create(self, req, @default_options)
end

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/request/session.rb
def self.create(store, req, default_options)
  session_was = find req # 查看头部是否有保存rack.session这个header
  session     = Request::Session.new(store, req) # 初始化的实例变量如下
  session.merge! session_was if session_was

  set(req, session) # 把Session实例作为value,rack.session作为key,set到头部中
  Options.set(req, Request::Session::Options.new(store, default_options))
  session
end

def self.find(req)
  req.get_header ENV_SESSION_KEY
end

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/request/session.rb
def initialize(by, req)
  @by       = by # 存储session的方式
  @req      = req
  @delegate = {} # 存储在session里的key,value值
  @loaded   = false # 记录cookie中的值是否load过到session中了
  @exists   = nil # we haven't checked yet
end

上面是初始化 session 部分的说明,主要是设置 session 存储方式和设置一些实例变量。同时把 session 实例设置到 header 中。

设置 Session 和 Cookie

在执行app.call时,会执行到一系列 middleware,其中有些 middleware 会调用到一些 cookies 和 session 方法,就是利用这些方法设置了 cookie 和 session 的值。如 warden,用 session 记录了用户的信息。

# ~/.rvm/gems/ruby-2.4.3/gems/warden-1.2.8/lib/warden/session_serializer.rb
def store(user, scope)
  ...
  session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user)
end

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/request/session.rb
def []=(key, value)
  load_for_write! # 这个用于把cookies中的session值加载到session实例中
  @delegate[key.to_s] = value
end

设置 session 之前,会先去检查 cookie 是否 loaded 进 session 中了,如果已经加载过了,则不会再次加载,防止数据再次加载会发生覆盖。如果没有加载过,则会执行 load! 方法加载一次 cookie 的值到 session 中,该过程下面解析。设置 session 的过程其实就是执行 session 中的[]=方法,在@delegate实例变量中加入键值对。

类似的,设置 cookies,也是为实例变量设置键值对。但是 session 的所有键值对设置到 cookies 中是经过处理的,键默认是开头部分提到的_#{app_name}_session。具体如下:

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_controller/metal/cookies.rb
def cookies # 为controller中定义的cookies方法
  request.cookie_jar
end

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb
def cookie_jar
  fetch_header("action_dispatch.cookies".freeze) do
    self.cookie_jar = Cookies::CookieJar.build(self, cookies)
  end
end

def []=(name, options)
  if options.is_a?(Hash)
    options.symbolize_keys!
    value = options[:value]
  else
    value = options
    options = { value: value }
  end

  handle_options(options)

  if @cookies[name.to_s] != value || options[:expires]
    @cookies[name.to_s] = value
    @set_cookies[name.to_s] = options
    @delete_cookies.delete(name.to_s)
  end

  value
end

上面就是设置 cookie 方法调用栈的执行过程,通过 request 中的 cookie_jar 找到设置 cookie 的对应方法。cookie_jar 可以理解为装 cookie 的罐子,里面存储着 cookie 的键值对。如果 cookie 需要进行加密或者签名等处理,则会通过调用对应的方法代理到一个类去,然后代理的类用parent_jar实例变量记录原先的 CookieJar 类,在代理的类上设置的 cookie 就是设置在parent_jar上面。session 中的键值对放到 cookie 中就是通过代理到 EncryptedCookieJar 类中去,然后经过加密签名处理后再设置到 cookie_jar 中去。

读取 Cookie 数据到 Session

程序在初始化 session 后,在第一次需要读取 session 中某个 key 的值时,会先把 cookie 中存储 session 的那个键值对读取出来。执行的过程如下:

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/request/session.rb
def load!
  id, session = @by.load_session @req # 把session_id的值和cookie中的数据解析出来,放到@delegate实例变量中去
  options[:id] = id
  @delegate.replace(stringify_keys(session))
  @loaded = true
end

# ~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/session/cookie_store.rb
def load_session(req)
  stale_session_check! do
    data = unpacked_cookie_data(req) # 从cookie中取出数据到session
    data = persistent_session_id!(data)
    [data["session_id"], data]
  end
end

# unpacked_cookie_data =》get_cookie 方法调用栈

def get_cookie(req)
  cookie_jar(req)[@key] # @key值就是所有session的键值对设置到cookie中的name
end

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb
def [](name)
  if data = @parent_jar[name.to_s]
    parse name, data # 通过调用CookieJar#[]中的方法取出处理过的session键值对,然后通过调用对应的解析方式解析出正确的键值对。
  end
end

上面是从 cookie 中取出 session 的数据的过程,其实只是读取 cookie 数据的过程,只不过读取的值需要解密和验证一下数据的正确性。

设置 Session 数据到 Cookie

app 执行 call 方法后,在一些 middleware 中设置的 cookie 或 session。执行 call 方法后,通过 commit_session 方法调用,把 session 中的数据处理后放到 cookie 中去。

~/.rvm/gems/ruby-2.4.3/gems/rack-2.0.6/lib/rack/session/abstract/id.rb
def commit_session(req, res)
  session_data = session.to_hash.delete_if { |k,v| v.nil? } # 把session中@delegate的数据返回来
  data = write_session(req, session_id, session_data, options) # session里的数据
  ...
  else
    cookie = Hash.new
    cookie[:value] = data
    cookie[:expires] = Time.now + options[:expire_after] if options[:expire_after]
    cookie[:expires] = Time.now + options[:max_age] if options[:max_age]
    set_cookie(req, res, cookie.merge!(options)) # 放到cookie里面去
  end
end

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/session/cookie_store.rb
def set_cookie(request, session_id, cookie)
  cookie_jar(request)[@key] = cookie
end

def cookie_jar(request)
  request.cookie_jar.signed_or_encrypted # 代理到EncryptedCookieJar类去处理session的键值对
end

set_cookie方法中调用的cookie_jar方法会去检查头部中是否设置了cookie_jar,如果没有设置,就会通过实例化 Cookies::CookieJar 设置到头部,从而在需要的时候可以使用。Cookies::CookieJar 类主要是用来存储一些 cookies 的键值对,同时为 cookie 值的处理方式提供一个接口,可以通过那些接口对 cookie 进行签名、加密等各种处理方式。set_cookie方法的调用会回到上面设置平常 cookie 的调用过程,其中 cookie 是已经经过加密,签名的处理过程了,直接设置返回给浏览器就可以了。至此从一个请求过来,把浏览器中 cookie 的 session 部分加载到 session 实例,到重新把 session 的键值对设置到 cookie 中就形成了一个闭环。

Cookie 对数据的处理方式

上面是 cookie 和 session 之间的交互关系,下面讨论下 cookie 设置到 http 头部的数据的几种格式。一种是 Cookie 直接对数据进行设置,如cookies[:user_id] = 2。另外一种是为了防止别有用心的人修改 cookie 的值而设置的,可以用签名的方式设置 cookie,防止此类情况,如 cookies.signed[:user_id] = 2。但是上面签名方式是通过 base64 encode 的,别人可以 decode 出来查看数据,所以第三种方式是通过给数据加密的方式去设置 Cookie,这时候别人没有办法查看,也没有办法更改你的数据。下面介绍下这三种数据的设置方式。

直接设置 Cookie

这种方式比较简单,可以直接通过 cookies 调用方法去设置 cookie。如cookies[:user_id] = 1。该数值会直接显示在浏览器上,并且还可以更改数值后提交到服务器端。这种方式的实现是直接调用 CookieJar 的实例方法[]=来把键值对设置到@cookies实例变量中的。

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb, line 265
def []=(name, options)
  if options.is_a?(Hash)
    options.symbolize_keys!
    value = options[:value]
  else
    value = options
    options = { value: value }
  end

  handle_options(options)

  if @cookies[name.to_s] != value || options[:expires]
    @cookies[name.to_s] = value
    @set_cookies[name.to_s] = options
    @delete_cookies.delete(name.to_s)
  end

  value
end

用签名的方式设置 Cookie

这种方式可以防止别人更改 cookie 数据,但是在浏览器上可以查看 cookie 的值。如cookies.signed[:user_id] = 1,这是在浏览器上显示的 user_id 的值为Mg%3D%3D--2205b41e2e653cc2aba580b6a1213de57a5cc233,"--"前面的部分为 user_id 用 base64 加密的数值,后面的部分是该值做 hash 计算算出来的结果,主要是为了防止别人篡改前面部分的数据。

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb, line 195
def signed
  @signed ||=
    if upgrade_legacy_signed_cookies?
      UpgradeLegacySignedCookieJar.new(self)
    else
      SignedCookieJar.new(self)
    end
end

通过判断是否同时存在 secret_key_base 和 secret_token 的值决定执行哪种处理方式。如果是 SignedCookieJar 的处理方式,则生成和解析的方法如下:

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb, line 549
def parse(name, signed_message)
  deserialize name, @verifier.verified(signed_message)
end

def commit(options)
  options[:value] = @verifier.generate(serialize(options[:value]))

  raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE
end

用加密的方式设置 Cookie

第三种方式是上面两种方式的加强,生成的数据在浏览器上既更改不了,也查看不了,数据都是加密过的,只有有解密的 key 才可以解出来。通过调用cookies.encrypted[:user_id]=1来进行加密,生成的数值是VTRqOVpsUnJoaFhnTFpRcUF1OWdOUT09LS0xSXRUWmI5UWQrTXg1MDNXbUhhSC93PT0%3D--2f18602eec2a4816591345caa62ddcce55c3eb83--前面的部分是加密后的值和加密的 iv 变量 encode 的值,后面部分是前面部分 hash 计算的值。也是为了防止别人更改前面部分的值。

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb, line 585
def parse(name, encrypted_message)
  deserialize name, @encryptor.decrypt_and_verify(encrypted_message)
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
  nil
end

def commit(options)
  options[:value] = @encryptor.encrypt_and_sign(serialize(options[:value]))

  raise CookieOverflow if options[:value].bytesize > MAX_COOKIE_SIZE
end

上面的过程是准备 cookies 数据的过程,准备完成后是怎么用符合浏览器的格式去设置到响应的 http 头部去呢?其实也是通过 middleware 的方式去设置的,具体如下:

~/.rvm/gems/ruby-2.4.3/gems/actionpack-5.1.6.2/lib/action_dispatch/middleware/cookies.rb

module ActionDispatch
  class Cookies
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new env

    status, headers, body = @app.call(env)

    if request.have_cookie_jar?
      cookie_jar = request.cookie_jar
      unless cookie_jar.committed?
        cookie_jar.write(headers)
        if headers[HTTP_HEADER].respond_to?(:join)
          headers[HTTP_HEADER] = headers[HTTP_HEADER].join("\n")
        end
      end
    end

    [status, headers, body]
  end
  end
end

class CookieJar
  def write(headers)
    if header = make_set_cookie_header(headers[HTTP_HEADER])
      headers[HTTP_HEADER] = header
    end
  end

   def make_set_cookie_header(header)
     header = @set_cookies.inject(header) { |m, (k, v)|
       if write_cookie?(v)
         ::Rack::Utils.add_cookie_to_header(m, k, v)
       else
         m
       end
     }
     @delete_cookies.inject(header) { |m, (k, v)|
       ::Rack::Utils.add_remove_cookie_to_header(m, k, v)
     }
    end
  end
end

~/.rvm/gems/ruby-2.4.3/gems/rack-2.0.6/lib/rack/utils.rb
def add_cookie_to_header(header, key, value)
  case value
  when Hash
    domain  = "; domain=#{value[:domain]}"   if value[:domain]
    path    = "; path=#{value[:path]}"       if value[:path]
    max_age = "; max-age=#{value[:max_age]}" if value[:max_age]
    expires = "; expires=" +
      rfc2822(value[:expires].clone.gmtime) if value[:expires]
    secure = "; secure"  if value[:secure]
    httponly = "; HttpOnly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only])
    same_site =
      case value[:same_site]
      when false, nil
        nil
      when :lax, 'Lax', :Lax
        '; SameSite=Lax'.freeze
      when true, :strict, 'Strict', :Strict
        '; SameSite=Strict'.freeze
      else
        raise ArgumentError, "Invalid SameSite value: #{value[:same_site].inspect}"
      end
    value = value[:value]
  end
  value = [value] unless Array === value

  cookie = "#{escape(key)}=#{value.map { |v| escape v }.join('&')}#{domain}" \
    "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}"

  case header
  when nil, ''
    cookie
  when String
    [header, cookie].join("\n")
  when Array
    (header + [cookie]).join("\n")
  else
    raise ArgumentError, "Unrecognized cookie header value. Expected String, Array, or nil, got #{header.inspect}"
  end
end

上面 make_set_cookie_header 方法中遍历@set_cookies实例变量去调用 add_cookie_to_header 方法组装 cookie 的值,然后拼装成一定格式的字符串设置到 http 头部,其中 key 为Set-Cookie,值是不同的 cookie 按一定格式拼接后用\n分隔开的字符串。

满满的干货啊👍 👍

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