1
0
Fork 0
mirror of https://github.com/heartcombo/devise.git synced 2022-11-09 12:18:31 -05:00

Initial support for authorization using "authentication token" (a.k.a. "single access token") - new module. Corresponding changes to Devise core to hook events like "after_changed_password" (only one added now - only one that makes much sense for latest module) easily. Unit and integration tests included. NOTE: One failing test for hooking Warden::Manager.after_authentication - gets ignored for some reason.

Signed-off-by: José Valim <jose.valim@gmail.com>
This commit is contained in:
Jonas Grimfelt 2010-01-24 03:38:52 +01:00 committed by José Valim
parent c03b4ff339
commit e1440fb430
16 changed files with 420 additions and 11 deletions

View file

@ -18,7 +18,7 @@ class SessionsController < ApplicationController
set_flash_message :notice, :signed_in set_flash_message :notice, :signed_in
sign_in_and_redirect(resource_name, resource, true) sign_in_and_redirect(resource_name, resource, true)
else else
set_now_flash_message :alert, warden.message || :invalid set_now_flash_message :alert, (warden.message || :invalid)
build_resource build_resource
render_with_scope :new render_with_scope :new
end end

View file

@ -27,21 +27,22 @@ module Devise
end end
ALL = [:authenticatable, :activatable, :confirmable, :recoverable, ALL = [:authenticatable, :activatable, :confirmable, :recoverable,
:rememberable, :validatable, :trackable, :timeoutable, :lockable] :rememberable, :validatable, :trackable, :timeoutable, :lockable, :token_authenticatable]
# Maps controller names to devise modules # Maps controller names to devise modules
CONTROLLERS = { CONTROLLERS = {
:sessions => [:authenticatable], :sessions => [:authenticatable, :token_authenticatable],
:passwords => [:recoverable], :passwords => [:recoverable],
:confirmations => [:confirmable], :confirmations => [:confirmable],
:unlocks => [:lockable] :unlocks => [:lockable]
} }
STRATEGIES = [:rememberable, :authenticatable] STRATEGIES = [:rememberable, :token_authenticatable, :authenticatable]
TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'] TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE']
# Maps the messages types that are used in flash message. # 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. # Declare encryptors length which are used in migrations.
ENCRYPTORS_LENGTH = { ENCRYPTORS_LENGTH = {
@ -131,6 +132,21 @@ module Devise
mattr_accessor :mailer_sender mattr_accessor :mailer_sender
@@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 class << self
# Default way to setup Devise. Run script/generate devise_install to create # Default way to setup Devise. Run script/generate devise_install to create
# a fresh initializer with all configuration values. # a fresh initializer with all configuration values.

View file

@ -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)

View file

@ -8,6 +8,7 @@ en:
unconfirmed: 'You have to confirm your account before continuing.' unconfirmed: 'You have to confirm your account before continuing.'
locked: 'Your account is locked.' locked: 'Your account is locked.'
invalid: 'Invalid email or password.' invalid: 'Invalid email or password.'
invalid_token: 'Invalid authentication token.'
timeout: 'Your session expired, please sign in again to continue.' timeout: 'Your session expired, please sign in again to continue.'
inactive: 'Your account was not activated yet.' inactive: 'Your account was not activated yet.'
passwords: passwords:

View file

@ -46,6 +46,53 @@ module Devise
end end
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: # Include the chosen devise modules in your model:
# #
# devise :authenticatable, :confirmable, :recoverable # devise :authenticatable, :confirmable, :recoverable

View file

@ -36,7 +36,8 @@ module Devise
end end
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) def password=(new_password)
@password = new_password @password = new_password
@ -44,11 +45,19 @@ module Devise
self.password_salt = self.class.encryptor_class.salt self.password_salt = self.class.encryptor_class.salt
self.encrypted_password = password_digest(@password) self.encrypted_password = password_digest(@password)
end end
::Devise::Models.event!(self, :after_changed_password, self, self.class.name.underscore.to_sym)
end end
# Verifies whether an incoming_password (ie from sign in) is the user password. # Verifies whether an incoming_password (ie from sign in) is the user password.
def valid_password?(incoming_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 end
# Checks if a resource is valid upon authentication. # 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) self.class.encryptor_class.digest(password, self.class.stretches, self.password_salt, self.class.pepper)
end end
def password_changed?
!valid_password?(params[:old_password])
end
module ClassMethods 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 # Authenticate a user based on configured attribute keys. Returns the
# authenticated user if it's valid or nil. Attributes are by default # authenticated user if it's valid or nil. Attributes are by default
# :email and :password, but the latter is always required. # :email and :password, but the latter is always required.
@ -106,7 +123,6 @@ module Devise
find(:first, :conditions => conditions) find(:first, :conditions => conditions)
end end
Devise::Models.config(self, :pepper, :stretches, :encryptor, :authentication_keys)
end end
end end
end end

View file

@ -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

View file

@ -17,6 +17,11 @@ module Devise
apply_schema :password_salt, String, :null => null apply_schema :password_salt, String, :null => null
end end
# Creates authentication_token.
def token_authenticatable
apply_schema :authentication_token, String, :limit => 20
end
# Creates confirmation_token, confirmed_at and confirmation_sent_at. # Creates confirmation_token, confirmed_at and confirmation_sent_at.
def confirmable def confirmable
apply_schema :confirmation_token, String, :limit => 20 apply_schema :confirmation_token, String, :limit => 20

View file

@ -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)

View file

@ -25,7 +25,7 @@ class DeviseTest < ActiveSupport::TestCase
Devise.configure_warden(config) Devise.configure_warden(config)
assert_equal Devise::FailureApp, config.failure_app 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_equal :user, config.default_scope
assert config.silence_missing_strategies? assert config.silence_missing_strategies?
end end

View file

@ -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

View file

@ -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

View file

@ -17,6 +17,7 @@ ActiveRecord::Schema.define(:version => 1) do
t.rememberable t.rememberable
t.trackable t.trackable
t.lockable t.lockable
t.token_authenticatable
end end
t.timestamps t.timestamps

View file

@ -1,5 +1,5 @@
class User < ActiveRecord::Base class User < ActiveRecord::Base
devise :authenticatable, :confirmable, :recoverable, :rememberable, :trackable, devise :authenticatable, :confirmable, :recoverable, :rememberable, :trackable,
:validatable, :timeoutable, :lockable :validatable, :timeoutable, :lockable, :token_authenticatable
attr_accessible :username, :email, :password, :password_confirmation attr_accessible :username, :email, :password, :password_confirmation
end end

View file

@ -2,6 +2,6 @@ class User
include MongoMapper::Document include MongoMapper::Document
key :created_at, DateTime key :created_at, DateTime
devise :authenticatable, :confirmable, :recoverable, :rememberable, :trackable, devise :authenticatable, :confirmable, :recoverable, :rememberable, :trackable,
:validatable, :timeoutable, :lockable :validatable, :timeoutable, :lockable, :token_authenticatable
# attr_accessible :username, :email, :password, :password_confirmation # attr_accessible :username, :email, :password, :password_confirmation
end end

View file

@ -0,0 +1,5 @@
class ActiveSupport::TestCase
VALID_AUTHENTICATION_TOKEN = 'AbCdEfGhIjKlMnOpQrSt'.freeze
end