diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1ea0485b..3019a07d 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -18,7 +18,7 @@ class SessionsController < ApplicationController set_flash_message :notice, :signed_in sign_in_and_redirect(resource_name, resource, true) else - set_now_flash_message :alert, warden.message || :invalid + set_now_flash_message :alert, (warden.message || :invalid) build_resource render_with_scope :new end diff --git a/lib/devise.rb b/lib/devise.rb index e9cba256..c0726d0c 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -27,21 +27,22 @@ module Devise end ALL = [:authenticatable, :activatable, :confirmable, :recoverable, - :rememberable, :validatable, :trackable, :timeoutable, :lockable] + :rememberable, :validatable, :trackable, :timeoutable, :lockable, :token_authenticatable] # Maps controller names to devise modules CONTROLLERS = { - :sessions => [:authenticatable], + :sessions => [:authenticatable, :token_authenticatable], :passwords => [:recoverable], :confirmations => [:confirmable], :unlocks => [:lockable] } - STRATEGIES = [:rememberable, :authenticatable] + STRATEGIES = [:rememberable, :token_authenticatable, :authenticatable] + TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'] # Maps the messages types that are used in flash message. - FLASH_MESSAGES = [ :unauthenticated, :unconfirmed, :invalid, :timeout, :inactive, :locked ] + FLASH_MESSAGES = [ :unauthenticated, :unconfirmed, :invalid, :invalid_token, :timeout, :inactive, :locked ] # Declare encryptors length which are used in migrations. ENCRYPTORS_LENGTH = { @@ -131,6 +132,21 @@ module Devise mattr_accessor :mailer_sender @@mailer_sender + # Array of known events that should trigger a authentication token reset. + # + # == Valid events: + # + # Warden: :after_set_user, :before_logout + # Authenticatable: :after_changed_password + # + # Note: If set to nil, authentication token will never be reset automatically. + mattr_accessor :reset_authentication_token_on + @@reset_authentication_token_on = nil + + # Authentication token params key name of choice. E.g. /users/sign_in?some_key=... + mattr_accessor :authentication_token_param_key + @@authentication_token_param_key = :auth_token + class << self # Default way to setup Devise. Run script/generate devise_install to create # a fresh initializer with all configuration values. diff --git a/lib/devise/hooks/token_authenticatable.rb b/lib/devise/hooks/token_authenticatable.rb new file mode 100644 index 00000000..0d5a643b --- /dev/null +++ b/lib/devise/hooks/token_authenticatable.rb @@ -0,0 +1,23 @@ +# After each Warden-sign-in: Ensure authentication token is set - if this is enabled. +Warden::Manager.after_authentication do |record, warden, options| + scope = options[:scope] + puts "#" + if Devise.mappings[scope].try(:token_authenticatable?) && warden.authenticated?(scope) + Devise.reset_authentication_token_on ||= [] + + if Devise.reset_authentication_token_on.include?(:after_set_user) + record.reset_authentication_token! + end + end +end + +# After each Authenticatable-password-change: Ensure authentication token is re-set - if this is enabled. +Devise.after_changed_password do |record, scope| + if Devise.mappings[scope].try(:token_authenticatable?) + Devise.reset_authentication_token_on ||= [] + + if Devise.reset_authentication_token_on.include?(:after_changed_password) + record.reset_authentication_token! + end + end +end if Devise.respond_to?(:after_changed_password) \ No newline at end of file diff --git a/lib/devise/locales/en.yml b/lib/devise/locales/en.yml index 1b47ff04..6b2eb358 100644 --- a/lib/devise/locales/en.yml +++ b/lib/devise/locales/en.yml @@ -8,6 +8,7 @@ en: unconfirmed: 'You have to confirm your account before continuing.' locked: 'Your account is locked.' invalid: 'Invalid email or password.' + invalid_token: 'Invalid authentication token.' timeout: 'Your session expired, please sign in again to continue.' inactive: 'Your account was not activated yet.' passwords: diff --git a/lib/devise/models.rb b/lib/devise/models.rb index 2a64bd2e..7c8fea2b 100644 --- a/lib/devise/models.rb +++ b/lib/devise/models.rb @@ -46,6 +46,53 @@ module Devise end end + # Creates events/hooks for Devise and for the given module. + # + # Devise::Models.events(Devise::Authenticable, :after_changed_password, :after_timeout_hooks) + # + # The line above creates: + # + # 1) Accessor for each hook holding any callback hooks (see +Devise::Models::config+), or explicit: + # + # Devise::Models.config(Devise::Authenticable, :after_changed_password_hooks, :after_timeout_hooks) + # + # 1) Setup module accessor hook holding any callback hooks (default fallback config that is): + # + # Devise.after_changed_password_hooks = [] + # Devise.after_timeout_hooks = [] + # + # 2) Callback hooks: +Devise::Authenticable.after_changed_password_hooks+ and +Devise::Authenticable.on_timeout+, + # used in same manner as +Warden::Manager::after_set_user+, etc. + # + # To add the class methods you need to have a module ClassMethods defined + # inside the given class. + # + def self.events(mod, *events) + ::Devise::Models.config(mod, *events.collect { |event| :"#{event}_hooks" }) + + events.each do |event| + ::Devise.class_eval <<-METHOD, __FILE__, __LINE__ + mattr_accessor :#{event}_hooks + @@#{event}_hooks = [] + + # Hook for changed password event. + def self.#{event}(options = {}, &block) + raise BlockNotGiven unless block_given? + self.#{event}_hooks << [block, options] + end + METHOD + end + end + + # Triggers a named event for a Devise model instance, or more explicitly + # triggers all callback hooks for this event. + # + def self.event!(object, event, *args) + object.class.send(:"#{event}_hooks").each { |hook| hook.first.call(*args[0..hook.first.arity]) } + rescue + # raise "An invalid event was triggered: #{event}. See Devise::Models::events() for usage." + end + # Include the chosen devise modules in your model: # # devise :authenticatable, :confirmable, :recoverable diff --git a/lib/devise/models/authenticatable.rb b/lib/devise/models/authenticatable.rb index af8047e2..14773bb2 100644 --- a/lib/devise/models/authenticatable.rb +++ b/lib/devise/models/authenticatable.rb @@ -36,7 +36,8 @@ module Devise end end - # Regenerates password salt and encrypted password each time password is set. + # Regenerates password salt and encrypted password each time password is set, + # and then trigger any "after_changed_password"-callbacks. def password=(new_password) @password = new_password @@ -44,11 +45,19 @@ module Devise self.password_salt = self.class.encryptor_class.salt self.encrypted_password = password_digest(@password) end + + ::Devise::Models.event!(self, :after_changed_password, self, self.class.name.underscore.to_sym) end # Verifies whether an incoming_password (ie from sign in) is the user password. def valid_password?(incoming_password) - password_digest(incoming_password) == encrypted_password + password_digest(incoming_password) == self.encrypted_password + end + + # Verifies whether an +incoming_authentication_token+ (i.e. from single access URL) + # is the user authentication token. + def valid_authentication_token?(incoming_auth_token) + incoming_auth_token == self.authentication_token end # Checks if a resource is valid upon authentication. @@ -74,7 +83,15 @@ module Devise self.class.encryptor_class.digest(password, self.class.stretches, self.password_salt, self.class.pepper) end + def password_changed? + !valid_password?(params[:old_password]) + end + module ClassMethods + + Devise::Models.config(self, :pepper, :stretches, :encryptor, :authentication_keys) + Devise::Models.events(self, :after_changed_password) + # Authenticate a user based on configured attribute keys. Returns the # authenticated user if it's valid or nil. Attributes are by default # :email and :password, but the latter is always required. @@ -106,7 +123,6 @@ module Devise find(:first, :conditions => conditions) end - Devise::Models.config(self, :pepper, :stretches, :encryptor, :authentication_keys) end end end diff --git a/lib/devise/models/token_authenticatable.rb b/lib/devise/models/token_authenticatable.rb new file mode 100644 index 00000000..39fb53d6 --- /dev/null +++ b/lib/devise/models/token_authenticatable.rb @@ -0,0 +1,87 @@ +require 'devise/strategies/token_authenticatable' +require 'devise/hooks/token_authenticatable' + +module Devise + module Models + # Token Authenticatable Module, responsible for generate authentication token and validating + # authenticity of a user while signing in using a authentication token (say follows an URL). + # + # == Configuration: + # + # You can overwrite configuration values by setting in globally in Devise (+Devise.setup+), + # using devise method, or overwriting the respective instance method. + # + # +authentication_token_param_key+ - Defines name of the authentication token params key. E.g. /users/sign_in?some_key=... + # + # +reset_authentication_token_on+ - Defines which callback hooks that should trigger a authentication token reset. + # + # == Examples: + # + # User.authenticate_with_token(:auth_token => '123456789') # returns authenticated user or nil + # User.find(1).valid_authentication_token?('rI1t6PKQ8yP7VetgwdybB') # returns true/false + # + module TokenAuthenticatable + def self.included(base) + base.class_eval do + extend ClassMethods + + before_save :ensure_authentication_token! + end + end + + # Generate authentication token unless already exists. + # + def ensure_authentication_token! + self.reset_authentication_token!(false) if self.authentication_token.blank? + end + + # Generate new authentication token (a.k.a. "single access token"). + # + def reset_authentication_token!(do_save = true) + self.authentication_token = self.class.authentication_token + self.save if do_save + end + + # Verifies whether an +incoming_authentication_token+ (i.e. from single access URL) + # is the user authentication token. + # + def valid_authentication_token?(incoming_auth_token) + incoming_auth_token.present? && incoming_auth_token == self.authentication_token + end + + module ClassMethods + + ::Devise::Models.config(self, :authentication_token_param_key, :reset_authentication_token_on) + + # Authenticate a user based on authentication token. + # + def authenticate_with_token(attributes = {}) + token = attributes[::Devise.authentication_token_param_key] + resource = self.find_for_token_authentication(token) + resource if resource.try(:valid_authentication_token?, token) + end + + def authentication_token + ::Devise.friendly_token + end + + protected + + # Find first record based on conditions given (ie by the sign in form). + # Overwrite to add customized conditions, create a join, or maybe use a + # namedscope to filter records while authenticating. + # Example: + # + # def self.find_for_token_authentication(token, conditions = {}) + # conditions = {:active => true} + # self.find_by_authentication_token(token, :conditions => conditions) + # end + # + def find_for_token_authentication(token, conditions = {}) + self.find_by_authentication_token(token, :conditions => conditions) + end + + end + end + end +end diff --git a/lib/devise/schema.rb b/lib/devise/schema.rb index 6080e006..3081cb8f 100644 --- a/lib/devise/schema.rb +++ b/lib/devise/schema.rb @@ -17,6 +17,11 @@ module Devise apply_schema :password_salt, String, :null => null end + # Creates authentication_token. + def token_authenticatable + apply_schema :authentication_token, String, :limit => 20 + end + # Creates confirmation_token, confirmed_at and confirmation_sent_at. def confirmable apply_schema :confirmation_token, String, :limit => 20 diff --git a/lib/devise/strategies/token_authenticatable.rb b/lib/devise/strategies/token_authenticatable.rb new file mode 100644 index 00000000..7c4164c4 --- /dev/null +++ b/lib/devise/strategies/token_authenticatable.rb @@ -0,0 +1,37 @@ +require 'devise/strategies/base' + +module Devise + module Strategies + # Strategy for signing in a user, based on a authenticatable token. + # Redirects to sign_in page if it's not authenticated. + class TokenAuthenticatable < Base + def valid? + super && authentication_token(scope).present? + end + + # Authenticate a user based on authenticatable token params, returning to warden + # success and the authenticated user if everything is okay. Otherwise redirect + # to sign in page. + def authenticate! + if resource = mapping.to.authenticate_with_token(params[scope] || params) + success!(resource) + else + fail!(:invalid_token) + end + end + + private + + def authentication_token(scope) + if params[scope] + params[scope][::Devise.authentication_token_param_key] + else + params[::Devise.authentication_token_param_key] + end + end + + end + end +end + +Warden::Strategies.add(:token_authenticatable, Devise::Strategies::TokenAuthenticatable) diff --git a/test/devise_test.rb b/test/devise_test.rb index 9cd20b5c..d518dc44 100644 --- a/test/devise_test.rb +++ b/test/devise_test.rb @@ -25,7 +25,7 @@ class DeviseTest < ActiveSupport::TestCase Devise.configure_warden(config) assert_equal Devise::FailureApp, config.failure_app - assert_equal [:rememberable, :authenticatable], config.default_strategies + assert_equal [:rememberable, :token_authenticatable, :authenticatable], config.default_strategies assert_equal :user, config.default_scope assert config.silence_missing_strategies? end diff --git a/test/integration/token_authenticatable_test.rb b/test/integration/token_authenticatable_test.rb new file mode 100644 index 00000000..a7c77c11 --- /dev/null +++ b/test/integration/token_authenticatable_test.rb @@ -0,0 +1,126 @@ +require 'test/test_helper' + +class TokenAuthenticationTest < ActionController::IntegrationTest + + test 'sign in user should authenticate with valid authentication token and proper authentication token key' do + swap Devise, :authentication_token_param_key => :secret_token do + sign_in_as_new_user_with_token(:auth_token_key => :secret_token, :auth_token => VALID_AUTHENTICATION_TOKEN) + + assert_response :success + assert_template 'users/index' + assert_contain 'Welcome' + assert warden.authenticated?(:user) + end + end + + test 'user signing in with valid authentication token - but improper authentication token key - return to sign in form with error message' do + # FIXME: For some reason I18n value is not respected. Always render defalt one. =S + # store_translations :en, :devise => {:sessions => {:unauthenticated => 'Ouch!'}} do + # assert 'Ouch!', I18n.t('devise.sessions.unauthenticated') # for paranoia + + swap Devise, :authentication_token_param_key => :donald_duck_token do + sign_in_as_new_user_with_token(:auth_token_key => :secret_token, :auth_token => VALID_AUTHENTICATION_TOKEN) + + assert_redirected_to new_user_session_path(:unauthenticated => true) + follow_redirect! + + # assert_contain 'Ouch!' + assert_contain 'Sign in' + assert_not warden.authenticated?(:user) + end + # end + end + + test 'user signing in with invalid authentication token should return to sign in form with error message' do + store_translations :en, :devise => {:sessions => {:invalid_token => 'LOL, that was not a single character correct.'}} do + sign_in_as_new_user_with_token(:auth_token => '*** INVALID TOKEN ***') + + assert_redirected_to new_user_session_path(:invalid_token => true) + follow_redirect! + assert_equal users_path(Devise.authentication_token_param_key => '*** INVALID TOKEN ***'), session[:"user.return_to"] + + assert_response :success + assert_contain 'LOL, that was not a single character correct.' + assert_contain 'Sign in' + assert_not warden.authenticated?(:user) + end + end + + test "authentication token should not be reset - if not set to do so if enabled" do + swap Devise, :reset_authentication_token_on => [] do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + + # after_set_user-event + user = sign_in_as_existing_user_with_token(:auth_token => VALID_AUTHENTICATION_TOKEN) + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + + # after_changed_password-event + user.password = "new_pass" + user.save + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + end + end + + test "authentication token should be reset after changed password if enabled" do + swap Devise, :reset_authentication_token_on => [:after_changed_password] do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + assert_not_blank user.authentication_token + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + + # after_set_user-event + user = sign_in_as_existing_user_with_token(:auth_token => VALID_AUTHENTICATION_TOKEN) + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + + # after_changed_password-event + User.expects(:authentication_token).returns("*** NEW TOKEN / CHANGED PASSWORD ***") + user.password = "new_pass" + user.save + assert_not_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + end + end + + # Problem: Warden::Manager.after_authenticate and/or Warden::Manager.after_set_user ignores my hook. Why? =( + # See: lib/devise/hooks/token_authenticatable.rb + test "authentication token should be reset after logging in if enabled" do + swap Devise, :reset_authentication_token_on => [:after_set_user] do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + assert_not_blank user.authentication_token + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + + # after_changed_password-event + user.password = "new_pass" + user.save + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + + # FIXME: after_set_user-event + User.expects(:authentication_token).returns("*** NEW TOKEN / SIGN IN ***") + user = sign_in_as_existing_user_with_token(:auth_token => VALID_AUTHENTICATION_TOKEN) + assert_not_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + end + end + + private + + def sign_in_as_new_user_with_token(options = {}, &block) + options[:auth_token_key] ||= Devise.authentication_token_param_key + user = create_user(options) + user.authentication_token = VALID_AUTHENTICATION_TOKEN + user.save + visit users_path(options[:auth_token_key].to_sym => (options[:auth_token] || VALID_AUTHENTICATION_TOKEN)) + yield if block_given? + user + end + + def sign_in_as_existing_user_with_token(options = {}, &block) + options[:auth_token_key] ||= Devise.authentication_token_param_key + options[:auth_token] ||= VALID_AUTHENTICATION_TOKEN + user = User.authenticate_with_token(options[:auth_token_key].to_sym => options[:auth_token]) + yield if block_given? + user + end + +end \ No newline at end of file diff --git a/test/models/token_authenticatable_test.rb b/test/models/token_authenticatable_test.rb new file mode 100644 index 00000000..afab0a5c --- /dev/null +++ b/test/models/token_authenticatable_test.rb @@ -0,0 +1,45 @@ +require 'test/test_helper' + +class TokenAuthenticatableTest < ActiveSupport::TestCase + + test 'should generate friendly authentication token on create' do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + assert_present user.authentication_token + assert_equal VALID_AUTHENTICATION_TOKEN, user.authentication_token + end + + test 'should reset authentication token' do + user = new_user + + user.reset_authentication_token!(false) + previous_token = user.authentication_token + + user.reset_authentication_token!(false) + assert_not_equal previous_token, user.authentication_token + end + + test 'should test for a valid authentication token' do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + assert user.valid_authentication_token?(VALID_AUTHENTICATION_TOKEN) + assert_not user.valid_authentication_token?(VALID_AUTHENTICATION_TOKEN.reverse) + end + + test 'should authenticate a valid user with authentication token and return it' do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + User.any_instance.stubs(:confirmed?).returns(true) + authenticated_user = User.authenticate_with_token(:auth_token => user.authentication_token) + assert_equal authenticated_user, user + end + + test 'should return nil when authenticating an invalid user by authentication token' do + User.expects(:authentication_token).returns(VALID_AUTHENTICATION_TOKEN) + user = create_user + User.any_instance.stubs(:confirmed?).returns(true) + authenticated_user = User.authenticate_with_token(:auth_token => user.authentication_token.reverse) + assert_nil authenticated_user + end + +end \ No newline at end of file diff --git a/test/orm/active_record.rb b/test/orm/active_record.rb index 0a681ac7..88e77294 100644 --- a/test/orm/active_record.rb +++ b/test/orm/active_record.rb @@ -17,6 +17,7 @@ ActiveRecord::Schema.define(:version => 1) do t.rememberable t.trackable t.lockable + t.token_authenticatable end t.timestamps diff --git a/test/rails_app/app/active_record/user.rb b/test/rails_app/app/active_record/user.rb index f290a7b5..bf036c4e 100644 --- a/test/rails_app/app/active_record/user.rb +++ b/test/rails_app/app/active_record/user.rb @@ -1,5 +1,5 @@ class User < ActiveRecord::Base devise :authenticatable, :confirmable, :recoverable, :rememberable, :trackable, - :validatable, :timeoutable, :lockable + :validatable, :timeoutable, :lockable, :token_authenticatable attr_accessible :username, :email, :password, :password_confirmation end diff --git a/test/rails_app/app/mongo_mapper/user.rb b/test/rails_app/app/mongo_mapper/user.rb index 187ce809..291e22a7 100644 --- a/test/rails_app/app/mongo_mapper/user.rb +++ b/test/rails_app/app/mongo_mapper/user.rb @@ -2,6 +2,6 @@ class User include MongoMapper::Document key :created_at, DateTime devise :authenticatable, :confirmable, :recoverable, :rememberable, :trackable, - :validatable, :timeoutable, :lockable + :validatable, :timeoutable, :lockable, :token_authenticatable # attr_accessible :username, :email, :password, :password_confirmation end diff --git a/test/support/tests_helper.rb b/test/support/tests_helper.rb new file mode 100644 index 00000000..92228971 --- /dev/null +++ b/test/support/tests_helper.rb @@ -0,0 +1,5 @@ +class ActiveSupport::TestCase + + VALID_AUTHENTICATION_TOKEN = 'AbCdEfGhIjKlMnOpQrSt'.freeze + +end \ No newline at end of file