上一节介绍了 warden 是如何验证用户的登录授权身份,但是对于 cookie 和 session 之间是如何生成的,是如何产生关联的,然后是怎么通过保存 session 数据到 cookie 中的还有点模糊。总结分享下 Rails 中是如何处理 cookie 和 session 的,有不正确的地方欢迎大家指出
session 可以有多种保存方式,可以通过保存在 cookie 中,也可以通过 cache_store 或者 activerecord_session_store(现在用独立的gem来实现了) 的方式去保存。如果是通过 cookie 保存的方式,则会把 session 中设置的键值对通过处理后放到一个特定的 key 下面,默认是项目名_session
。每次浏览器发出请求,都会带上该 cookie 键值对,然后服务端通过解出那串 cookie 值来识别设置 session。而数据库或者用 cache 的方式都是通过把请求中的 session_id 解析出来,再去数据库查找对应的 session 数据。下面具体讨论 session 存储在 cookie 中的方式。
# ~/.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 中,上面是总的过程,下面分步解析。
# ~/.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 中。
在执行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 中去。
程序在初始化 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 数据的过程,只不过读取的值需要解密和验证一下数据的正确性。
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 和 session 之间的交互关系,下面讨论下 cookie 设置到 http 头部的数据的几种格式。一种是 Cookie 直接对数据进行设置,如cookies[:user_id] = 2
。另外一种是为了防止别有用心的人修改 cookie 的值而设置的,可以用签名的方式设置 cookie,防止此类情况,如 cookies.signed[:user_id] = 2。但是上面签名方式是通过 base64 encode 的,别人可以 decode 出来查看数据,所以第三种方式是通过给数据加密的方式去设置 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 的值。如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
第三种方式是上面两种方式的加强,生成的数据在浏览器上既更改不了,也查看不了,数据都是加密过的,只有有解密的 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
分隔开的字符串。