From d2d4acf02793580e0f0c1bc380389527325b6254 Mon Sep 17 00:00:00 2001 From: Joshua Peek Date: Sat, 16 Jan 2010 17:21:46 -0600 Subject: [PATCH] Cookies middleware --- .../lib/action_controller/metal/cookies.rb | 186 +-------------- .../lib/action_controller/metal/testing.rb | 3 + actionpack/lib/action_dispatch.rb | 1 + .../lib/action_dispatch/http/response.rb | 2 +- .../lib/action_dispatch/middleware/cookies.rb | 214 ++++++++++++++++++ actionpack/test/abstract_unit.rb | 1 + actionpack/test/controller/cookie_test.rb | 32 +-- actionpack/test/dispatch/response_test.rb | 9 +- .../dispatch/session/cookie_store_test.rb | 4 +- railties/lib/rails/configuration.rb | 1 + railties/test/application/middleware_test.rb | 1 + 11 files changed, 235 insertions(+), 219 deletions(-) create mode 100644 actionpack/lib/action_dispatch/middleware/cookies.rb diff --git a/actionpack/lib/action_controller/metal/cookies.rb b/actionpack/lib/action_controller/metal/cookies.rb index 5b51bd21d0..7aa687b52c 100644 --- a/actionpack/lib/action_controller/metal/cookies.rb +++ b/actionpack/lib/action_controller/metal/cookies.rb @@ -1,48 +1,4 @@ module ActionController #:nodoc: - # Cookies are read and written through ActionController#cookies. - # - # The cookies being read are the ones received along with the request, the cookies - # being written will be sent out with the response. Reading a cookie does not get - # the cookie object itself back, just the value it holds. - # - # Examples for writing: - # - # # Sets a simple session cookie. - # cookies[:user_name] = "david" - # - # # Sets a cookie that expires in 1 hour. - # cookies[:login] = { :value => "XJ-122", :expires => 1.hour.from_now } - # - # Examples for reading: - # - # cookies[:user_name] # => "david" - # cookies.size # => 2 - # - # Example for deleting: - # - # cookies.delete :user_name - # - # Please note that if you specify a :domain when setting a cookie, you must also specify the domain when deleting the cookie: - # - # cookies[:key] = { - # :value => 'a yummy cookie', - # :expires => 1.year.from_now, - # :domain => 'domain.com' - # } - # - # cookies.delete(:key, :domain => 'domain.com') - # - # The option symbols for setting cookies are: - # - # * :value - The cookie's value or list of values (as an array). - # * :path - The path for which this cookie applies. Defaults to the root - # of the application. - # * :domain - The domain for which this cookie applies. - # * :expires - The time at which this cookie expires, as a Time object. - # * :secure - Whether this cookie is a only transmitted to HTTPS servers. - # Default is +false+. - # * :httponly - Whether this cookie is accessible via scripting or - # only HTTP. Defaults to +false+. module Cookies extend ActiveSupport::Concern @@ -52,146 +8,10 @@ module Cookies helper_method :cookies cattr_accessor :cookie_verifier_secret end - - protected - # Returns the cookie container, which operates as described above. + + private def cookies - @cookies ||= CookieJar.build(request, response) + request.cookie_jar end end - - class CookieJar < Hash #:nodoc: - def self.build(request, response) - new.tap do |hash| - hash.update(request.cookies) - hash.response = response - end - end - - attr_accessor :response - - # Returns the value of the cookie by +name+, or +nil+ if no such cookie exists. - def [](name) - super(name.to_s) - end - - # Sets the cookie named +name+. The second argument may be the very cookie - # value, or a hash of options as documented above. - def []=(key, options) - if options.is_a?(Hash) - options.symbolize_keys! - value = options[:value] - else - value = options - options = { :value => value } - end - - super(key.to_s, value) - - options[:path] ||= "/" - response.set_cookie(key, options) - end - - # Removes the cookie on the client machine by setting the value to an empty string - # and setting its expiration date into the past. Like []=, you can pass in - # an options hash to delete cookies with extra data such as a :path. - def delete(key, options = {}) - options.symbolize_keys! - options[:path] ||= "/" - value = super(key.to_s) - response.delete_cookie(key, options) - value - end - - # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now. Example: - # - # cookies.permanent[:prefers_open_id] = true - # # => Set-Cookie: prefers_open_id=true; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT - # - # This jar is only meant for writing. You'll read permanent cookies through the regular accessor. - # - # This jar allows chaining with the signed jar as well, so you can set permanent, signed cookies. Examples: - # - # cookies.permanent.signed[:remember_me] = current_user.id - # # => Set-Cookie: discount=BAhU--848956038e692d7046deab32b7131856ab20e14e; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT - def permanent - @permanent ||= PermanentCookieJar.new(self) - end - - # Returns a jar that'll automatically generate a signed representation of cookie value and verify it when reading from - # the cookie again. This is useful for creating cookies with values that the user is not supposed to change. If a signed - # cookie was tampered with by the user (or a 3rd party), an ActiveSupport::MessageVerifier::InvalidSignature exception will - # be raised. - # - # This jar requires that you set a suitable secret for the verification on ActionController::Base.cookie_verifier_secret. - # - # Example: - # - # cookies.signed[:discount] = 45 - # # => Set-Cookie: discount=BAhpMg==--2c1c6906c90a3bc4fd54a51ffb41dffa4bf6b5f7; path=/ - # - # cookies.signed[:discount] # => 45 - def signed - @signed ||= SignedCookieJar.new(self) - end - end - - class PermanentCookieJar < CookieJar #:nodoc: - def initialize(parent_jar) - @parent_jar = parent_jar - end - - def []=(key, options) - if options.is_a?(Hash) - options.symbolize_keys! - else - options = { :value => options } - end - - options[:expires] = 20.years.from_now - @parent_jar[key] = options - end - - def signed - @signed ||= SignedCookieJar.new(self) - end - - def controller - @parent_jar.controller - end - - def method_missing(method, *arguments, &block) - @parent_jar.send(method, *arguments, &block) - end - end - - class SignedCookieJar < CookieJar #:nodoc: - def initialize(parent_jar) - unless ActionController::Base.cookie_verifier_secret - raise "You must set ActionController::Base.cookie_verifier_secret to use signed cookies" - end - - @parent_jar = parent_jar - @verifier = ActiveSupport::MessageVerifier.new(ActionController::Base.cookie_verifier_secret) - end - - def [](name) - @verifier.verify(@parent_jar[name]) - end - - def []=(key, options) - if options.is_a?(Hash) - options.symbolize_keys! - options[:value] = @verifier.generate(options[:value]) - else - options = { :value => @verifier.generate(options) } - end - - @parent_jar[key] = options - end - - def method_missing(method, *arguments, &block) - @parent_jar.send(method, *arguments, &block) - end - end end diff --git a/actionpack/lib/action_controller/metal/testing.rb b/actionpack/lib/action_controller/metal/testing.rb index c193a5eff4..d62269b9af 100644 --- a/actionpack/lib/action_controller/metal/testing.rb +++ b/actionpack/lib/action_controller/metal/testing.rb @@ -10,6 +10,9 @@ def process_with_new_base_test(request, response) @_response = response @_response.request = request ret = process(request.parameters[:action]) + if cookies = @_request.env['action_dispatch.cookies'] + cookies.write(@_response) + end @_response.body ||= self.response_body @_response.prepare! set_test_assigns diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index 7b44212310..763a090de2 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -43,6 +43,7 @@ module ActionDispatch autoload_under 'middleware' do autoload :Callbacks autoload :Cascade + autoload :Cookies autoload :Flash autoload :Head autoload :ParamsParser diff --git a/actionpack/lib/action_dispatch/http/response.rb b/actionpack/lib/action_dispatch/http/response.rb index 65df9b1f03..f299306ff4 100644 --- a/actionpack/lib/action_dispatch/http/response.rb +++ b/actionpack/lib/action_dispatch/http/response.rb @@ -119,7 +119,7 @@ def location=(url) def to_a assign_default_content_type_and_charset! handle_conditional_get! - self["Set-Cookie"] = @cookie.join("\n") + self["Set-Cookie"] = @cookie.join("\n") unless @cookie.blank? self["ETag"] = @etag if @etag super end diff --git a/actionpack/lib/action_dispatch/middleware/cookies.rb b/actionpack/lib/action_dispatch/middleware/cookies.rb new file mode 100644 index 0000000000..5d2734a15e --- /dev/null +++ b/actionpack/lib/action_dispatch/middleware/cookies.rb @@ -0,0 +1,214 @@ +module ActionDispatch + class Request + def cookie_jar + env['action_dispatch.cookies'] ||= Cookies::CookieJar.build(self) + end + end + + # Cookies are read and written through ActionController#cookies. + # + # The cookies being read are the ones received along with the request, the cookies + # being written will be sent out with the response. Reading a cookie does not get + # the cookie object itself back, just the value it holds. + # + # Examples for writing: + # + # # Sets a simple session cookie. + # cookies[:user_name] = "david" + # + # # Sets a cookie that expires in 1 hour. + # cookies[:login] = { :value => "XJ-122", :expires => 1.hour.from_now } + # + # Examples for reading: + # + # cookies[:user_name] # => "david" + # cookies.size # => 2 + # + # Example for deleting: + # + # cookies.delete :user_name + # + # Please note that if you specify a :domain when setting a cookie, you must also specify the domain when deleting the cookie: + # + # cookies[:key] = { + # :value => 'a yummy cookie', + # :expires => 1.year.from_now, + # :domain => 'domain.com' + # } + # + # cookies.delete(:key, :domain => 'domain.com') + # + # The option symbols for setting cookies are: + # + # * :value - The cookie's value or list of values (as an array). + # * :path - The path for which this cookie applies. Defaults to the root + # of the application. + # * :domain - The domain for which this cookie applies. + # * :expires - The time at which this cookie expires, as a Time object. + # * :secure - Whether this cookie is a only transmitted to HTTPS servers. + # Default is +false+. + # * :httponly - Whether this cookie is accessible via scripting or + # only HTTP. Defaults to +false+. + class Cookies + class CookieJar < Hash #:nodoc: + def self.build(request) + new.tap do |hash| + hash.update(request.cookies) + end + end + + def initialize + @set_cookies = {} + @delete_cookies = {} + + super + end + + # Returns the value of the cookie by +name+, or +nil+ if no such cookie exists. + def [](name) + super(name.to_s) + end + + # Sets the cookie named +name+. The second argument may be the very cookie + # value, or a hash of options as documented above. + def []=(key, options) + if options.is_a?(Hash) + options.symbolize_keys! + value = options[:value] + else + value = options + options = { :value => value } + end + + value = super(key.to_s, value) + + options[:path] ||= "/" + @set_cookies[key] = options + value + end + + # Removes the cookie on the client machine by setting the value to an empty string + # and setting its expiration date into the past. Like []=, you can pass in + # an options hash to delete cookies with extra data such as a :path. + def delete(key, options = {}) + options.symbolize_keys! + options[:path] ||= "/" + value = super(key.to_s) + @delete_cookies[key] = options + value + end + + # Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now. Example: + # + # cookies.permanent[:prefers_open_id] = true + # # => Set-Cookie: prefers_open_id=true; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT + # + # This jar is only meant for writing. You'll read permanent cookies through the regular accessor. + # + # This jar allows chaining with the signed jar as well, so you can set permanent, signed cookies. Examples: + # + # cookies.permanent.signed[:remember_me] = current_user.id + # # => Set-Cookie: discount=BAhU--848956038e692d7046deab32b7131856ab20e14e; path=/; expires=Sun, 16-Dec-2029 03:24:16 GMT + def permanent + @permanent ||= PermanentCookieJar.new(self) + end + + # Returns a jar that'll automatically generate a signed representation of cookie value and verify it when reading from + # the cookie again. This is useful for creating cookies with values that the user is not supposed to change. If a signed + # cookie was tampered with by the user (or a 3rd party), an ActiveSupport::MessageVerifier::InvalidSignature exception will + # be raised. + # + # This jar requires that you set a suitable secret for the verification on ActionController::Base.cookie_verifier_secret. + # + # Example: + # + # cookies.signed[:discount] = 45 + # # => Set-Cookie: discount=BAhpMg==--2c1c6906c90a3bc4fd54a51ffb41dffa4bf6b5f7; path=/ + # + # cookies.signed[:discount] # => 45 + def signed + @signed ||= SignedCookieJar.new(self) + end + + def write(response) + @set_cookies.each { |k, v| response.set_cookie(k, v) } + @delete_cookies.each { |k, v| response.delete_cookie(k, v) } + end + end + + class PermanentCookieJar < CookieJar #:nodoc: + def initialize(parent_jar) + @parent_jar = parent_jar + end + + def []=(key, options) + if options.is_a?(Hash) + options.symbolize_keys! + else + options = { :value => options } + end + + options[:expires] = 20.years.from_now + @parent_jar[key] = options + end + + def signed + @signed ||= SignedCookieJar.new(self) + end + + def controller + @parent_jar.controller + end + + def method_missing(method, *arguments, &block) + @parent_jar.send(method, *arguments, &block) + end + end + + class SignedCookieJar < CookieJar #:nodoc: + def initialize(parent_jar) + unless ActionController::Base.cookie_verifier_secret + raise "You must set ActionController::Base.cookie_verifier_secret to use signed cookies" + end + + @parent_jar = parent_jar + @verifier = ActiveSupport::MessageVerifier.new(ActionController::Base.cookie_verifier_secret) + end + + def [](name) + @verifier.verify(@parent_jar[name]) + end + + def []=(key, options) + if options.is_a?(Hash) + options.symbolize_keys! + options[:value] = @verifier.generate(options[:value]) + else + options = { :value => @verifier.generate(options) } + end + + @parent_jar[key] = options + end + + def method_missing(method, *arguments, &block) + @parent_jar.send(method, *arguments, &block) + end + end + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + + if cookie_jar = env['action_dispatch.cookies'] + response = Rack::Response.new(body, status, headers) + cookie_jar.write(response) + response.to_a + else + [status, headers, body] + end + end + end +end diff --git a/actionpack/test/abstract_unit.rb b/actionpack/test/abstract_unit.rb index 10913c0fdb..7b04638ccc 100644 --- a/actionpack/test/abstract_unit.rb +++ b/actionpack/test/abstract_unit.rb @@ -92,6 +92,7 @@ def self.build_app(routes = nil) middleware.use "ActionDispatch::ShowExceptions" middleware.use "ActionDispatch::Callbacks" middleware.use "ActionDispatch::ParamsParser" + middleware.use "ActionDispatch::Cookies" middleware.use "ActionDispatch::Flash" middleware.use "ActionDispatch::Head" }.build(routes || ActionController::Routing::Routes) diff --git a/actionpack/test/controller/cookie_test.rb b/actionpack/test/controller/cookie_test.rb index c8e8b3857e..fd6538b27a 100644 --- a/actionpack/test/controller/cookie_test.rb +++ b/actionpack/test/controller/cookie_test.rb @@ -54,12 +54,12 @@ def set_permanent_cookie cookies.permanent[:user_name] = "Jamie" head :ok end - + def set_signed_cookie cookies.signed[:user_id] = 45 head :ok end - + def set_permanent_signed_cookie cookies.permanent.signed[:remember_me] = 100 head :ok @@ -120,28 +120,6 @@ def test_expiring_cookie assert_equal({"user_name" => nil}, @response.cookies) end - def test_cookiejar_accessor - @request.cookies["user_name"] = "david" - @controller.request = @request - jar = ActionController::CookieJar.build(@controller.request, @controller.response) - assert_equal "david", jar["user_name"] - assert_equal nil, jar["something_else"] - end - - def test_cookiejar_accessor_with_array_value - @request.cookies["pages"] = %w{1 2 3} - @controller.request = @request - jar = ActionController::CookieJar.build(@controller.request, @controller.response) - assert_equal %w{1 2 3}, jar["pages"] - end - - def test_cookiejar_delete_removes_item_and_returns_its_value - @request.cookies["user_name"] = "david" - @controller.response = @response - jar = ActionController::CookieJar.build(@controller.request, @controller.response) - assert_equal "david", jar.delete("user_name") - end - def test_delete_cookie_with_path get :delete_cookie_with_path assert_cookie_header "user_name=; path=/beaten; expires=Thu, 01-Jan-1970 00:00:00 GMT" @@ -157,19 +135,19 @@ def test_permanent_cookie assert_match /Jamie/, @response.headers["Set-Cookie"] assert_match %r(#{20.years.from_now.utc.year}), @response.headers["Set-Cookie"] end - + def test_signed_cookie get :set_signed_cookie assert_equal 45, @controller.send(:cookies).signed[:user_id] end - + def test_permanent_signed_cookie get :set_permanent_signed_cookie assert_match %r(#{20.years.from_now.utc.year}), @response.headers["Set-Cookie"] assert_equal 100, @controller.send(:cookies).signed[:remember_me] end - + private def assert_cookie_header(expected) header = @response.headers["Set-Cookie"] diff --git a/actionpack/test/dispatch/response_test.rb b/actionpack/test/dispatch/response_test.rb index 02f63f7006..4697fa3e2b 100644 --- a/actionpack/test/dispatch/response_test.rb +++ b/actionpack/test/dispatch/response_test.rb @@ -13,8 +13,7 @@ def setup assert_equal({ "Content-Type" => "text/html; charset=utf-8", "Cache-Control" => "max-age=0, private, must-revalidate", - "ETag" => '"65a8e27d8879283831b664bd8b7f0ad4"', - "Set-Cookie" => "" + "ETag" => '"65a8e27d8879283831b664bd8b7f0ad4"' }, headers) parts = [] @@ -30,8 +29,7 @@ def setup assert_equal({ "Content-Type" => "text/html; charset=utf-8", "Cache-Control" => "max-age=0, private, must-revalidate", - "ETag" => '"ebb5e89e8a94e9dd22abf5d915d112b2"', - "Set-Cookie" => "" + "ETag" => '"ebb5e89e8a94e9dd22abf5d915d112b2"' }, headers) end @@ -44,8 +42,7 @@ def setup assert_equal 200, status assert_equal({ "Content-Type" => "text/html; charset=utf-8", - "Cache-Control" => "no-cache", - "Set-Cookie" => "" + "Cache-Control" => "no-cache" }, headers) parts = [] diff --git a/actionpack/test/dispatch/session/cookie_store_test.rb b/actionpack/test/dispatch/session/cookie_store_test.rb index ab7b9bc31b..d2c1758af1 100644 --- a/actionpack/test/dispatch/session/cookie_store_test.rb +++ b/actionpack/test/dispatch/session/cookie_store_test.rb @@ -137,7 +137,7 @@ def test_doesnt_write_session_cookie_if_session_is_not_accessed with_test_route_set do get '/no_session_access' assert_response :success - assert_equal "", headers['Set-Cookie'] + assert_equal nil, headers['Set-Cookie'] end end @@ -147,7 +147,7 @@ def test_doesnt_write_session_cookie_if_session_is_unchanged "fef868465920f415f2c0652d6910d3af288a0367" get '/no_session_access' assert_response :success - assert_equal "", headers['Set-Cookie'] + assert_equal nil, headers['Set-Cookie'] end end diff --git a/railties/lib/rails/configuration.rb b/railties/lib/rails/configuration.rb index a2fab120cf..a597a01d72 100644 --- a/railties/lib/rails/configuration.rb +++ b/railties/lib/rails/configuration.rb @@ -15,6 +15,7 @@ def self.default_middleware_stack middleware.use('::Rack::Runtime') middleware.use('ActionDispatch::ShowExceptions', lambda { ActionController::Base.consider_all_requests_local }) middleware.use('ActionDispatch::Callbacks', lambda { ActionController::Dispatcher.prepare_each_request }) + middleware.use('ActionDispatch::Cookies') middleware.use(lambda { ActionController::Base.session_store }, lambda { ActionController::Base.session_options }) middleware.use('ActionDispatch::Flash', :if => lambda { ActionController::Base.session_store }) middleware.use('ActionDispatch::ParamsParser') diff --git a/railties/test/application/middleware_test.rb b/railties/test/application/middleware_test.rb index 7b3077bb6e..f2bfeb79fe 100644 --- a/railties/test/application/middleware_test.rb +++ b/railties/test/application/middleware_test.rb @@ -19,6 +19,7 @@ def setup "Rack::Runtime", "ActionDispatch::ShowExceptions", "ActionDispatch::Callbacks", + "ActionDispatch::Cookies", "ActionDispatch::Session::CookieStore", "ActionDispatch::Flash", "ActionDispatch::Cascade",