From 28a7f78fba90b7641c57301e4ae4e7615e33a724 Mon Sep 17 00:00:00 2001 From: "Carlos A. da Silva" Date: Tue, 20 Oct 2009 00:31:33 -0200 Subject: [PATCH] Creating rememberable module. --- app/views/sessions/new.html.erb | 4 ++ lib/devise.rb | 17 +++--- lib/devise/controllers/filters.rb | 1 + lib/devise/hooks/rememberable.rb | 27 +++++++++ lib/devise/mapping.rb | 4 +- lib/devise/models/rememberable.rb | 62 +++++++++++++++++++ lib/devise/strategies/authenticable.rb | 40 +++++++------ lib/devise/strategies/rememberable.rb | 33 ++++++++++ lib/devise/warden.rb | 11 +++- test/controllers/filters_test.rb | 1 + test/integration/rememberable_test.rb | 56 +++++++++++++++++ test/mapping_test.rb | 2 + test/models/rememberable_test.rb | 76 ++++++++++++++++++++++++ test/rails_app/app/models/admin.rb | 2 +- test/support/integration_tests_helper.rb | 7 ++- test/test_helper.rb | 13 ++-- 16 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 lib/devise/hooks/rememberable.rb create mode 100644 lib/devise/models/rememberable.rb create mode 100644 lib/devise/strategies/rememberable.rb create mode 100644 test/integration/rememberable_test.rb create mode 100644 test/models/rememberable_test.rb diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 85fae420..1de63844 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -7,6 +7,10 @@

<%= f.label :password %>

<%= f.password_field :password %>

+ <% if devise_mapping.rememberable? -%> +

<%= f.check_box :remember_me %> <%= f.label :remember_me %>

+ <% end -%> +

<%= f.submit "Sign in" %>

<% end -%> diff --git a/lib/devise.rb b/lib/devise.rb index 5a4a6519..34afc441 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -1,12 +1,14 @@ -begin - require 'warden' -rescue - gem 'hassox-warden' - require 'warden' -end +#begin +# require 'warden' +#rescue +# gem 'hassox-warden' +# require 'warden' +#end + +require File.join(File.dirname(__FILE__), '..', 'warden', 'lib', 'warden') module Devise - ALL = [:authenticable, :confirmable, :recoverable, :validatable].freeze + ALL = [:authenticable, :confirmable, :recoverable, :rememberable, :validatable].freeze # Maps controller names to devise modules CONTROLLERS = { @@ -17,7 +19,6 @@ module Devise end require 'devise/warden' -require 'devise/mapping' require 'devise/routes' # Ensure to include Devise modules only after Rails initialization. diff --git a/lib/devise/controllers/filters.rb b/lib/devise/controllers/filters.rb index daed127b..2c6080eb 100644 --- a/lib/devise/controllers/filters.rb +++ b/lib/devise/controllers/filters.rb @@ -40,6 +40,7 @@ module Devise # Sign out based on scope. def sign_out(scope, *args) + warden.user(scope) # Without loading user here, before_logout hook is not called warden.raw_session.inspect # Without this inspect here. The session does not clear. warden.logout(scope, *args) end diff --git a/lib/devise/hooks/rememberable.rb b/lib/devise/hooks/rememberable.rb new file mode 100644 index 00000000..8ba545dc --- /dev/null +++ b/lib/devise/hooks/rememberable.rb @@ -0,0 +1,27 @@ +# After authenticate hook to verify if the user in the given scope asked to be +# remembered while he does not sign out. Generates a new remember token for +# that specific user and adds a cookie with this user info to sign in this user +# automatically without asking for credentials. Refer to rememberable strategy +# for more info. +Warden::Manager.after_authentication do |record, auth, options| + scope = options[:scope] + remember_me = auth.params[scope].try(:fetch, :remember_me, nil) + remember_me = remember_me == '1' || remember_me == 'true' + mapping = Devise.mappings[scope] + if remember_me && mapping.present? && mapping.rememberable? + record.remember_me! + auth.cookies['remember_token'] = record.class.serialize_into_cookie(record) + end +end + +# Before logout hook to forget the user in the given scope, only if rememberable +# is activated for this scope. Also clear remember token to ensure the user +# won't be remembered again. +# TODO: verify warden to call before_logout when @users are not loaded yet. +Warden::Manager.before_logout do |record, auth, scope| + mapping = Devise.mappings[scope] + if mapping.present? && mapping.rememberable? + record.forget_me! + auth.cookies['remember_token'] = nil + end +end diff --git a/lib/devise/mapping.rb b/lib/devise/mapping.rb index 0de46efc..ebdbedb2 100644 --- a/lib/devise/mapping.rb +++ b/lib/devise/mapping.rb @@ -9,7 +9,7 @@ module Devise # map.devise_for :users # mapping = Devise.mappings[:user] # - # mapping.name #=> :user + # mapping.name #=> :user # # is the scope used in controllers and warden, given in the route as :singular. # # mapping.as #=> "users" @@ -57,7 +57,7 @@ module Devise # self.for.include?(:confirmable) # end # - CONTROLLERS.values.each do |m| + ALL.each do |m| class_eval <<-METHOD, __FILE__, __LINE__ def #{m}? self.for.include?(:#{m}) diff --git a/lib/devise/models/rememberable.rb b/lib/devise/models/rememberable.rb new file mode 100644 index 00000000..b98b5e00 --- /dev/null +++ b/lib/devise/models/rememberable.rb @@ -0,0 +1,62 @@ +require 'digest/sha1' + +module Devise + module Models + + # Rememberable Module + module Rememberable + + def self.included(base) + base.class_eval do + extend ClassMethods + + # Remember me option available in after_authentication hook. + attr_accessor :remember_me + attr_accessible :remember_me + end + end + + # Generate a new remember token and save the record without validations. + def remember_me! + self.remember_token = friendly_token + save(false) + end + + # Removes the remember token only if it exists, and save the record + # without validations. + def forget_me! + if remember_token? + self.remember_token = nil + save(false) + end + end + + # Checks whether the incoming token matches or not with the record token. + def valid_remember_token?(token) + remember_token.present? && remember_token == token + end + + module ClassMethods + + # Attempts to remember the user through it's id and remember_token. + # Returns the user if one is found and the token is valid, otherwise nil. + # Attributes must contain :id and :remember_token + def remember_me!(attributes={}) + rememberable = find_by_id(attributes[:id]) + rememberable if rememberable.try(:valid_remember_token?, attributes[:remember_token]) + end + + # Create the cookie key using the record id and remember_token + def serialize_into_cookie(record) + "#{record.id}::#{record.remember_token}" + end + + # Recreate the user based on the stored cookie + def serialize_from_cookie(cookie) + record_id, remember_token = cookie.split('::') + remember_me!(:id => record_id, :remember_token => remember_token) + end + end + end + end +end diff --git a/lib/devise/strategies/authenticable.rb b/lib/devise/strategies/authenticable.rb index 98d54940..95f98116 100644 --- a/lib/devise/strategies/authenticable.rb +++ b/lib/devise/strategies/authenticable.rb @@ -16,28 +16,30 @@ module Devise end end - # Find the attributes for the current mapping. - def attributes - @attributes ||= request.params[scope] - end + private - # Check for the right keys. - def valid_attributes? - attributes && attributes[:email].present? && attributes[:password].present? - end + # Find the attributes for the current mapping. + def attributes + @attributes ||= params[scope] + end - # Stores requested uri to redirect the user after signing in. We cannot use - # scoped session provided by warden here, since the user is not authenticated - # yet, but we still need to store the uri based on scope, so different scopes - # would never use the same uri to redirect. - def store_location - session[:"#{mapping.name}.return_to"] = request.request_uri if request.get? - end + # Check for the right keys. + def valid_attributes? + attributes && attributes[:email].present? && attributes[:password].present? + end - # Create path to sign in the resource - def sign_in_path - "/#{mapping.as}/#{mapping.path_names[:sign_in]}" - end + # Stores requested uri to redirect the user after signing in. We cannot use + # scoped session provided by warden here, since the user is not authenticated + # yet, but we still need to store the uri based on scope, so different scopes + # would never use the same uri to redirect. + def store_location + session[:"#{mapping.name}.return_to"] = request.request_uri if request.get? + end + + # Create path to sign in the resource + def sign_in_path + "/#{mapping.as}/#{mapping.path_names[:sign_in]}" + end end end end diff --git a/lib/devise/strategies/rememberable.rb b/lib/devise/strategies/rememberable.rb new file mode 100644 index 00000000..581c692a --- /dev/null +++ b/lib/devise/strategies/rememberable.rb @@ -0,0 +1,33 @@ +module Devise + module Strategies + # Remember the user through the remember token. This strategy is responsible + # to verify whether there is a cookie with the remember token, and to + # recreate the user from this cookie if it exists. Must be called *before* + # authenticable. + class Rememberable < Devise::Strategies::Base + + # A valid strategy for rememberable needs a remember token in the cookies. + def valid? + super && remember_me_cookie.present? + end + + # To authenticate a user we deserialize the cookie and attempt finding + # the record in the database. If the attempt fails, we pass to another + # strategy handle the authentication. + def authenticate! + if resource = mapping.to.serialize_from_cookie(remember_me_cookie) + success!(resource) + else + pass + end + end + + private + + # Accessor for remember cookie + def remember_me_cookie + cookies['remember_token'] + end + end + end +end diff --git a/lib/devise/warden.rb b/lib/devise/warden.rb index b64c34e7..29d13627 100644 --- a/lib/devise/warden.rb +++ b/lib/devise/warden.rb @@ -19,6 +19,11 @@ module Warden::Mixins::Common raw_session.inspect # why do I have to inspect it to get it to clear? raw_session.clear end + + # Proxy to request cookies + def cookies + request.cookies + end end # Session Serialization in. This block determines how the user will be stored @@ -41,9 +46,13 @@ end # Adds Warden Manager to Rails middleware stack, configuring default devise # strategy and also the controller who will manage not authenticated users. Rails.configuration.middleware.use Warden::Manager do |manager| - manager.default_strategies :authenticable + manager.default_strategies :rememberable, :authenticable manager.failure_app = SessionsController end # Setup devise strategies for Warden +Warden::Strategies.add(:rememberable, Devise::Strategies::Rememberable) Warden::Strategies.add(:authenticable, Devise::Strategies::Authenticable) + +# Require rememberable hooks +require 'devise/hooks/rememberable' diff --git a/test/controllers/filters_test.rb b/test/controllers/filters_test.rb index f9107d74..ce9b4887 100644 --- a/test/controllers/filters_test.rb +++ b/test/controllers/filters_test.rb @@ -48,6 +48,7 @@ class ControllerAuthenticableTest < ActionController::TestCase end test 'proxy logout to warden' do + @mock_warden.expects(:user).with(:user).returns(true) @mock_warden.expects(:logout).with(:user).returns(true) @controller.sign_out(:user) end diff --git a/test/integration/rememberable_test.rb b/test/integration/rememberable_test.rb new file mode 100644 index 00000000..18b72fb6 --- /dev/null +++ b/test/integration/rememberable_test.rb @@ -0,0 +1,56 @@ +require 'test/test_helper' + +class RememberMeTest < ActionController::IntegrationTest + + def create_user_and_remember(add_to_token='') + user = create_user + user.remember_me! + cookies['remember_token'] = User.serialize_into_cookie(user) + add_to_token + user + end + + test 'do not remember the user if he has not checked remember me option' do + user = sign_in_as_user + + assert_nil user.reload.remember_token + end + + test 'generate remember token after sign in' do + user = sign_in_as_user :remember_me => true + + assert_not_nil user.reload.remember_token + end + + test 'remember the user before sign in' do + user = create_user_and_remember + get users_path + assert_response :success + assert warden.authenticated?(:user) + assert warden.user(:user) == user + end + + test 'do not remember with invalid token' do + user = create_user_and_remember('add') + get users_path + assert_response :success + assert_not warden.authenticated?(:user) + end + + test 'forget the user before sign out' do + user = create_user_and_remember + get users_path + assert warden.authenticated?(:user) + get destroy_user_session_path + assert_not warden.authenticated?(:user) + assert_nil user.reload.remember_token + end + + test 'do not remember the user anymore after forget' do + user = create_user_and_remember + get users_path + assert warden.authenticated?(:user) + get destroy_user_session_path + get users_path + assert_not warden.authenticated?(:user) + end +end diff --git a/test/mapping_test.rb b/test/mapping_test.rb index a44d080a..73c3f0e2 100644 --- a/test/mapping_test.rb +++ b/test/mapping_test.rb @@ -60,10 +60,12 @@ class MapTest < ActiveSupport::TestCase assert mapping.authenticable? assert mapping.confirmable? assert mapping.recoverable? + assert mapping.rememberable? mapping = Devise.mappings[:admin] assert mapping.authenticable? assert_not mapping.confirmable? assert_not mapping.recoverable? + assert_not mapping.rememberable? end end diff --git a/test/models/rememberable_test.rb b/test/models/rememberable_test.rb new file mode 100644 index 00000000..1f6a3993 --- /dev/null +++ b/test/models/rememberable_test.rb @@ -0,0 +1,76 @@ +require 'test/test_helper' + +class RememberableTest < ActiveSupport::TestCase + + test 'should respond to remember_me attribute' do + user = new_user + assert user.respond_to?(:remember_me) + end + + test 'should have remember_me accessible' do + assert field_accessible?(:remember_me) + end + + test 'remember_me should generate a new token and save the record without validating' do + user = create_user + user.expects(:valid?).never + token = user.remember_token + user.remember_me! + assert_not_equal token, user.remember_token + assert_not user.changed? + end + + test 'forget_me should clear remember token and save the record without validating' do + user = create_user + user.remember_me! + assert_not_nil user.remember_token + user.expects(:valid?).never + user.forget_me! + assert_nil user.remember_token + assert_not user.changed? + end + + test 'forget should do nothing if no remember token exists' do + user = create_user + user.expects(:save).never + user.forget_me! + end + + test 'valid remember token' do + user = create_user + assert_not user.valid_remember_token?(user.remember_token) + user.remember_me! + assert user.valid_remember_token?(user.remember_token) + user.forget_me! + assert_not user.valid_remember_token?(user.remember_token) + end + + test 'find a user by its id and remember it if the token is valid' do + user = create_user + user.remember_me! + remembered_user = User.remember_me!(:id => user.id, :remember_token => user.remember_token) + assert_not_nil remembered_user + assert_equal remembered_user, user + end + + test 'remember me should return nil if no user is found' do + assert_nil User.remember_me!(:id => 0) + end + + test 'remember me return nil if is a valid user with invalid token' do + user = create_user + assert_nil User.remember_me!(:id => user.id, :remember_token => 'invalid_token') + end + + test 'serialize into cookie' do + user = create_user + user.remember_me! + assert_equal "#{user.id}::#{user.remember_token}", User.serialize_into_cookie(user) + end + + test 'serialize from cookie' do + user = create_user + user.remember_me! + assert_equal user, User.serialize_from_cookie("#{user.id}::#{user.remember_token}") + end +end diff --git a/test/rails_app/app/models/admin.rb b/test/rails_app/app/models/admin.rb index 7aa10f1e..f4b3b266 100644 --- a/test/rails_app/app/models/admin.rb +++ b/test/rails_app/app/models/admin.rb @@ -1,3 +1,3 @@ class Admin < ActiveRecord::Base - devise :all, :except => [:recoverable, :confirmable] + devise :all, :except => [:recoverable, :confirmable, :rememberable] end diff --git a/test/support/integration_tests_helper.rb b/test/support/integration_tests_helper.rb index 33a1e4a7..b45e4d3a 100644 --- a/test/support/integration_tests_helper.rb +++ b/test/support/integration_tests_helper.rb @@ -24,21 +24,24 @@ class ActionController::IntegrationTest end def sign_in_as_user(options={}, &block) - create_user(options) + user = create_user(options) visit new_user_session_path unless options[:visit] == false fill_in 'email', :with => 'user@test.com' fill_in 'password', :with => '123456' + check 'remember me' if options[:remember_me] == true yield if block_given? click_button 'Sign In' + user end def sign_in_as_admin(options={}, &block) - create_admin(options) + admin = create_admin(options) visit new_admin_session_path unless options[:visit] == false fill_in 'email', :with => 'admin@test.com' fill_in 'password', :with => '123456' yield if block_given? click_button 'Sign In' + admin end # Fix assert_redirect_to in integration sessions because they don't take into diff --git a/test/test_helper.rb b/test/test_helper.rb index 71161555..6a557a42 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -19,10 +19,15 @@ ActiveRecord::Schema.define(:version => 1) do t.string :email, :null => false t.string :encrypted_password, :null => false t.string :password_salt, :null => false - t.string :confirmation_token - t.datetime :confirmation_sent_at - t.datetime :confirmed_at - t.string :reset_password_token + if table == :users + t.string :confirmation_token + t.datetime :confirmation_sent_at + t.datetime :confirmed_at + t.string :reset_password_token + t.string :remember_token + end + + t.timestamps end end end