omniauth-identity 3.0.4 → 3.0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,6 +5,9 @@ require 'mongoid'
5
5
  module OmniAuth
6
6
  module Identity
7
7
  module Models
8
+ # Mongoid is an ORM adapter for MongoDB:
9
+ # https://github.com/mongodb/mongoid
10
+ # NOTE: Mongoid is based on ActiveModel.
8
11
  module Mongoid
9
12
  def self.included(base)
10
13
  base.class_eval do
@@ -5,7 +5,9 @@ require 'nobrainer'
5
5
  module OmniAuth
6
6
  module Identity
7
7
  module Models
8
- # http://nobrainer.io/ an ORM for RethinkDB
8
+ # NoBrainer is an ORM adapter for RethinkDB:
9
+ # http://nobrainer.io/
10
+ # NOTE: NoBrainer is based on ActiveModel.
9
11
  module NoBrainer
10
12
  def self.included(base)
11
13
  base.class_eval do
@@ -1,11 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'nobrainer'
3
+ require 'sequel'
4
4
 
5
5
  module OmniAuth
6
6
  module Identity
7
7
  module Models
8
- # http://sequel.jeremyevans.net/ an SQL ORM
8
+ # Sequel is an ORM adapter for the following databases:
9
+ # ADO, Amalgalite, IBM_DB, JDBC, MySQL, Mysql2, ODBC, Oracle, PostgreSQL, SQLAnywhere, SQLite3, and TinyTDS
10
+ # The homepage is: http://sequel.jeremyevans.net/
11
+ # NOTE: Sequel is *not* based on ActiveModel, but supports the API we need, except for `persisted?`:
12
+ # * create
13
+ # * save
9
14
  module Sequel
10
15
  def self.included(base)
11
16
  base.class_eval do
@@ -14,13 +19,11 @@ module OmniAuth
14
19
  # plugin :validation_helpers
15
20
  plugin :validation_class_methods
16
21
 
17
- include OmniAuth::Identity::Model
22
+ include ::OmniAuth::Identity::Model
18
23
  include ::OmniAuth::Identity::SecurePassword
19
24
 
20
25
  has_secure_password
21
26
 
22
- alias_method :persisted?, :valid?
23
-
24
27
  def self.auth_key=(key)
25
28
  super
26
29
  validates_uniqueness_of :key, case_sensitive: false
@@ -29,6 +32,14 @@ module OmniAuth
29
32
  def self.locate(search_hash)
30
33
  where(search_hash).first
31
34
  end
35
+
36
+ def persisted?
37
+ exists?
38
+ end
39
+
40
+ def save
41
+ super
42
+ end
32
43
  end
33
44
  end
34
45
  end
@@ -4,7 +4,7 @@ require 'bcrypt'
4
4
 
5
5
  module OmniAuth
6
6
  module Identity
7
- # This is taken directly from Rails 3.1 code and is used if
7
+ # This is lightly edited from Rails 6.1 code and is used if
8
8
  # the version of ActiveModel that's being used does not
9
9
  # include SecurePassword. The only difference is that instead of
10
10
  # using ActiveSupport::Concern, it checks to see if there is already
@@ -14,63 +14,124 @@ module OmniAuth
14
14
  base.extend ClassMethods unless base.respond_to?(:has_secure_password)
15
15
  end
16
16
 
17
+ # BCrypt hash function can handle maximum 72 bytes, and if we pass
18
+ # password of length more than 72 bytes it ignores extra characters.
19
+ # Hence need to put a restriction on password length.
20
+ MAX_PASSWORD_LENGTH_ALLOWED = 72
21
+
22
+ class << self
23
+ attr_accessor :min_cost # :nodoc:
24
+ end
25
+ self.min_cost = false
26
+
17
27
  module ClassMethods
18
28
  # Adds methods to set and authenticate against a BCrypt password.
19
- # This mechanism requires you to have a password_digest attribute.
29
+ # This mechanism requires you to have a +XXX_digest+ attribute.
30
+ # Where +XXX+ is the attribute name of your desired password.
31
+ #
32
+ # The following validations are added automatically:
33
+ # * Password must be present on creation
34
+ # * Password length should be less than or equal to 72 bytes
35
+ # * Confirmation of password (using a +XXX_confirmation+ attribute)
20
36
  #
21
- # Validations for presence of password, confirmation of password (using
22
- # a "password_confirmation" attribute) are automatically added.
23
- # You can add more validations by hand if need be.
37
+ # If confirmation validation is not needed, simply leave out the
38
+ # value for +XXX_confirmation+ (i.e. don't provide a form field for
39
+ # it). When this attribute has a +nil+ value, the validation will not be
40
+ # triggered.
41
+ #
42
+ # For further customizability, it is possible to suppress the default
43
+ # validations by passing <tt>validations: false</tt> as an argument.
44
+ #
45
+ # Add bcrypt (~> 3.1.7) to Gemfile to use #has_secure_password:
46
+ #
47
+ # gem 'bcrypt', '~> 3.1.7'
24
48
  #
25
49
  # Example using Active Record (which automatically includes ActiveModel::SecurePassword):
26
50
  #
27
- # # Schema: User(name:string, password_digest:string)
51
+ # # Schema: User(name:string, password_digest:string, recovery_password_digest:string)
28
52
  # class User < ActiveRecord::Base
29
53
  # has_secure_password
54
+ # has_secure_password :recovery_password, validations: false
30
55
  # end
31
56
  #
32
- # user = User.new(:name => "david", :password => "", :password_confirmation => "nomatch")
33
- # user.save # => false, password required
34
- # user.password = "mUc3m00RsqyRe"
35
- # user.save # => false, confirmation doesn't match
36
- # user.password_confirmation = "mUc3m00RsqyRe"
37
- # user.save # => true
38
- # user.authenticate("notright") # => false
39
- # user.authenticate("mUc3m00RsqyRe") # => user
40
- # User.find_by_name("david").try(:authenticate, "notright") # => nil
41
- # User.find_by_name("david").try(:authenticate, "mUc3m00RsqyRe") # => user
42
- def has_secure_password
43
- attr_reader :password
57
+ # user = User.new(name: 'david', password: '', password_confirmation: 'nomatch')
58
+ # user.save # => false, password required
59
+ # user.password = 'mUc3m00RsqyRe'
60
+ # user.save # => false, confirmation doesn't match
61
+ # user.password_confirmation = 'mUc3m00RsqyRe'
62
+ # user.save # => true
63
+ # user.recovery_password = "42password"
64
+ # user.recovery_password_digest # => "$2a$04$iOfhwahFymCs5weB3BNH/uXkTG65HR.qpW.bNhEjFP3ftli3o5DQC"
65
+ # user.save # => true
66
+ # user.authenticate('notright') # => false
67
+ # user.authenticate('mUc3m00RsqyRe') # => user
68
+ # user.authenticate_recovery_password('42password') # => user
69
+ # User.find_by(name: 'david')&.authenticate('notright') # => false
70
+ # User.find_by(name: 'david')&.authenticate('mUc3m00RsqyRe') # => user
71
+ def has_secure_password(attribute = :password, validations: true)
72
+ # Load bcrypt gem only when has_secure_password is used.
73
+ # This is to avoid ActiveModel (and by extension the entire framework)
74
+ # being dependent on a binary library.
75
+ begin
76
+ require 'bcrypt'
77
+ rescue LoadError
78
+ warn "You don't have bcrypt installed in your application. Please add it to your Gemfile and run bundle install"
79
+ raise
80
+ end
44
81
 
45
- validates_confirmation_of :password
46
- validates_presence_of :password_digest
82
+ include InstanceMethodsOnActivation.new(attribute)
47
83
 
48
- include InstanceMethodsOnActivation
84
+ if validations
85
+ include ActiveModel::Validations
49
86
 
50
- if respond_to?(:attributes_protected_by_default)
51
- def self.attributes_protected_by_default
52
- super + ['password_digest']
87
+ # This ensures the model has a password by checking whether the password_digest
88
+ # is present, so that this works with both new and existing records. However,
89
+ # when there is an error, the message is added to the password attribute instead
90
+ # so that the error message will make sense to the end-user.
91
+ validate do |record|
92
+ record.errors.add(attribute, :blank) unless record.public_send("#{attribute}_digest").present?
53
93
  end
94
+
95
+ validates_length_of attribute, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED
96
+ validates_confirmation_of attribute, allow_blank: true
54
97
  end
55
98
  end
56
99
  end
57
100
 
58
- module InstanceMethodsOnActivation
59
- # Returns self if the password is correct, otherwise false.
60
- def authenticate(unencrypted_password)
61
- if BCrypt::Password.new(password_digest) == unencrypted_password
62
- self
63
- else
64
- false
101
+ class InstanceMethodsOnActivation < Module
102
+ def initialize(attribute)
103
+ attr_reader attribute
104
+
105
+ define_method("#{attribute}=") do |unencrypted_password|
106
+ if unencrypted_password.nil?
107
+ public_send("#{attribute}_digest=", nil)
108
+ elsif !unencrypted_password.empty?
109
+ instance_variable_set("@#{attribute}", unencrypted_password)
110
+ cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
111
+ public_send("#{attribute}_digest=", BCrypt::Password.create(unencrypted_password, cost: cost))
112
+ end
65
113
  end
66
- end
67
114
 
68
- # Encrypts the password into the password_digest attribute.
69
- def password=(unencrypted_password)
70
- @password = unencrypted_password
71
- if unencrypted_password && !unencrypted_password.empty?
72
- self.password_digest = BCrypt::Password.create(unencrypted_password)
115
+ define_method("#{attribute}_confirmation=") do |unencrypted_password|
116
+ instance_variable_set("@#{attribute}_confirmation", unencrypted_password)
73
117
  end
118
+
119
+ # Returns +self+ if the password is correct, otherwise +false+.
120
+ #
121
+ # class User < ActiveRecord::Base
122
+ # has_secure_password validations: false
123
+ # end
124
+ #
125
+ # user = User.new(name: 'david', password: 'mUc3m00RsqyRe')
126
+ # user.save
127
+ # user.authenticate_password('notright') # => false
128
+ # user.authenticate_password('mUc3m00RsqyRe') # => user
129
+ define_method("authenticate_#{attribute}") do |unencrypted_password|
130
+ attribute_digest = public_send("#{attribute}_digest")
131
+ BCrypt::Password.new(attribute_digest).is_password?(unencrypted_password) && self
132
+ end
133
+
134
+ alias_method :authenticate, :authenticate_password if attribute == :password
74
135
  end
75
136
  end
76
137
  end
@@ -6,8 +6,8 @@ module OmniAuth
6
6
  # user authentication using the same process flow that you
7
7
  # use for external OmniAuth providers.
8
8
  class Identity
9
+ DEFAULT_REGISTRATION_FIELDS = %i[password password_confirmation].freeze
9
10
  include OmniAuth::Strategy
10
-
11
11
  option :fields, %i[name email]
12
12
 
13
13
  # Primary Feature Switches:
@@ -20,6 +20,11 @@ module OmniAuth
20
20
  option :on_registration, nil # See #registration_phase
21
21
  option :on_failed_registration, nil # See #registration_phase
22
22
  option :locate_conditions, ->(req) { { model.auth_key => req['auth_key'] } }
23
+ option :create_identity_link_text, 'Create an Identity'
24
+ option :registration_failure_message, 'One or more fields were invalid'
25
+ option :validation_failure_message, 'Validation failed'
26
+ option :title, 'Identity Verification' # Title for Login Form
27
+ option :registration_form_title, 'Register Identity' # Title for Registration Form
23
28
 
24
29
  def request_phase
25
30
  if options[:on_login]
@@ -65,29 +70,25 @@ module OmniAuth
65
70
  end
66
71
 
67
72
  def registration_phase
68
- attributes = (options[:fields] + %i[password password_confirmation]).each_with_object({}) do |k, h|
73
+ attributes = (options[:fields] + DEFAULT_REGISTRATION_FIELDS).each_with_object({}) do |k, h|
69
74
  h[k] = request[k.to_s]
70
75
  end
71
76
  if model.respond_to?(:column_names) && model.column_names.include?('provider')
72
77
  attributes.reverse_merge!(provider: 'identity')
73
78
  end
74
- @identity = model.new(attributes)
75
-
76
- # on_validation may run a Captcha or other validation mechanism
77
- # Must return true when validation passes, false otherwise
78
- if options[:on_validation] && !options[:on_validation].call(env: env)
79
- if options[:on_failed_registration]
80
- env['omniauth.identity'] = @identity
81
- options[:on_failed_registration].call(env)
79
+ if validating?
80
+ @identity = model.new(attributes)
81
+ env['omniauth.identity'] = @identity
82
+ if valid?
83
+ @identity.save
84
+ registration_result
82
85
  else
83
- validation_message = 'Validation failed'
84
- registration_form(validation_message)
86
+ registration_failure(options[:validation_failure_message])
85
87
  end
86
- elsif @identity.save && @identity.persisted?
87
- env['PATH_INFO'] = callback_path
88
- callback_phase
89
88
  else
90
- show_custom_options_or_default
89
+ @identity = model.create(attributes)
90
+ env['omniauth.identity'] = @identity
91
+ registration_result
91
92
  end
92
93
  end
93
94
 
@@ -120,19 +121,19 @@ module OmniAuth
120
121
 
121
122
  def build_omniauth_login_form
122
123
  OmniAuth::Form.build(
123
- title: (options[:title] || 'Identity Verification'),
124
+ title: options[:title],
124
125
  url: callback_path
125
126
  ) do |f|
126
127
  f.text_field 'Login', 'auth_key'
127
128
  f.password_field 'Password', 'password'
128
129
  if options[:enable_registration]
129
- f.html "<p align='center'><a href='#{registration_path}'>Create an Identity</a></p>"
130
+ f.html "<p align='center'><a href='#{registration_path}'>#{options[:create_identity_link_text]}</a></p>"
130
131
  end
131
132
  end
132
133
  end
133
134
 
134
135
  def build_omniauth_registration_form(validation_message)
135
- OmniAuth::Form.build(title: 'Register Identity') do |f|
136
+ OmniAuth::Form.build(title: options[:registration_form_title]) do |f|
136
137
  f.html "<p style='color:red'>#{validation_message}</p>" if validation_message
137
138
  options[:fields].each do |field|
138
139
  f.text_field field.to_s.capitalize, field.to_s
@@ -142,13 +143,36 @@ module OmniAuth
142
143
  end
143
144
  end
144
145
 
145
- def show_custom_options_or_default
146
+ # Validates the model before it is persisted
147
+ #
148
+ # @return [truthy or falsey] :on_validation option is truthy or falsey
149
+ def validating?
150
+ !!options[:on_validation]
151
+ end
152
+
153
+ # Validates the model before it is persisted
154
+ #
155
+ # @return [true or false] result of :on_validation call
156
+ def valid?
157
+ # on_validation may run a Captcha or other validation mechanism
158
+ # Must return true when validation passes, false otherwise
159
+ !!options[:on_validation].call(env: env)
160
+ end
161
+
162
+ def registration_failure(message)
146
163
  if options[:on_failed_registration]
147
- env['omniauth.identity'] = @identity
148
164
  options[:on_failed_registration].call(env)
149
165
  else
150
- validation_message = 'One or more fields were invalid'
151
- registration_form(validation_message)
166
+ registration_form(message)
167
+ end
168
+ end
169
+
170
+ def registration_result
171
+ if @identity.persisted?
172
+ env['PATH_INFO'] = callback_path
173
+ callback_phase
174
+ else
175
+ registration_failure(options[:registration_failure_message])
152
176
  end
153
177
  end
154
178
  end
@@ -1,123 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class ExampleModel
4
- include OmniAuth::Identity::Model
5
- end
6
-
7
3
  RSpec.describe OmniAuth::Identity::Model do
8
- context 'Class Methods' do
9
- subject { ExampleModel }
10
-
11
- describe '.locate' do
12
- it('is abstract') { expect { subject.locate('abc') }.to raise_error(NotImplementedError) }
4
+ before do
5
+ identity_test_klass = Class.new do
6
+ include OmniAuth::Identity::Model
13
7
  end
8
+ stub_const('IdentityTestClass', identity_test_klass)
9
+ end
14
10
 
15
- describe '.authenticate' do
16
- it 'calls locate and then authenticate' do
17
- mocked_instance = double('ExampleModel', authenticate: 'abbadoo')
18
- allow(subject).to receive(:locate).with('email' => 'example').and_return(mocked_instance)
19
- expect(subject.authenticate({ 'email' => 'example' }, 'pass')).to eq('abbadoo')
20
- end
11
+ describe 'Class Methods' do
12
+ subject(:model_klass) { IdentityTestClass }
21
13
 
22
- it 'calls locate with additional scopes when provided' do
23
- mocked_instance = double('ExampleModel', authenticate: 'abbadoo')
24
- allow(subject).to receive(:locate).with('email' => 'example',
25
- 'user_type' => 'admin').and_return(mocked_instance)
26
- expect(subject.authenticate({ 'email' => 'example', 'user_type' => 'admin' }, 'pass')).to eq('abbadoo')
27
- end
14
+ include_context 'model with class methods'
28
15
 
29
- it 'recovers gracefully if locate is nil' do
30
- allow(subject).to receive(:locate).and_return(nil)
31
- expect(subject.authenticate('blah', 'foo')).to be false
16
+ describe '::locate' do
17
+ it('is abstract') do
18
+ expect { model_klass.locate('email' => 'example') }.to raise_error(NotImplementedError)
32
19
  end
33
20
  end
34
21
  end
35
22
 
36
- context 'Instance Methods' do
37
- subject { ExampleModel.new }
23
+ describe 'Instance Methods' do
24
+ subject(:instance) { IdentityTestClass.new }
38
25
 
39
- describe '#authenticate' do
40
- it('is abstract') { expect { subject.authenticate('abc') }.to raise_error(NotImplementedError) }
41
- end
26
+ include_context 'instance with instance methods'
42
27
 
43
- describe '#uid' do
44
- it 'defaults to #id' do
45
- allow(subject).to receive(:respond_to?).with(:id).and_return(true)
46
- allow(subject).to receive(:id).and_return 'wakka-do'
47
- expect(subject.uid).to eq('wakka-do')
48
- end
49
-
50
- it 'stringifies it' do
51
- allow(subject).to receive(:id).and_return 123
52
- expect(subject.uid).to eq('123')
53
- end
54
-
55
- it 'raises NotImplementedError if #id is not defined' do
56
- allow(subject).to receive(:respond_to?).with(:id).and_return(false)
57
- expect { subject.uid }.to raise_error(NotImplementedError)
58
- end
28
+ describe '#authenticate' do
29
+ it('is abstract') { expect { instance.authenticate('my-password') }.to raise_error(NotImplementedError) }
59
30
  end
60
31
 
61
32
  describe '#auth_key' do
62
- it 'defaults to #email' do
63
- allow(subject).to receive(:respond_to?).with(:email).and_return(true)
64
- allow(subject).to receive(:email).and_return('bob@bob.com')
65
- expect(subject.auth_key).to eq('bob@bob.com')
66
- end
67
-
68
- it 'uses the class .auth_key' do
69
- subject.class.auth_key 'login'
70
- allow(subject).to receive(:login).and_return 'bob'
71
- expect(subject.auth_key).to eq('bob')
72
- subject.class.auth_key nil
73
- end
74
-
75
33
  it 'raises a NotImplementedError if the auth_key method is not defined' do
76
- expect { subject.auth_key }.to raise_error(NotImplementedError)
34
+ expect { instance.auth_key }.to raise_error(NotImplementedError)
77
35
  end
78
36
  end
79
37
 
80
38
  describe '#auth_key=' do
81
- it 'defaults to setting email' do
82
- allow(subject).to receive(:respond_to?).with(:email=).and_return(true)
83
- expect(subject).to receive(:email=).with 'abc'
84
-
85
- subject.auth_key = 'abc'
86
- end
87
-
88
- it 'uses a custom .auth_key if one is provided' do
89
- subject.class.auth_key 'login'
90
- allow(subject).to receive(:respond_to?).with(:login=).and_return(true)
91
- expect(subject).to receive(:login=).with('abc')
92
-
93
- subject.auth_key = 'abc'
94
- end
95
-
96
- it 'raises a NotImplementedError if the autH_key method is not defined' do
97
- expect { subject.auth_key = 'broken' }.to raise_error(NotImplementedError)
98
- end
99
- end
100
-
101
- describe '#info' do
102
- it 'includes attributes that are set' do
103
- allow(subject).to receive(:name).and_return('Bob Bobson')
104
- allow(subject).to receive(:nickname).and_return('bob')
105
-
106
- expect(subject.info).to eq({
107
- 'name' => 'Bob Bobson',
108
- 'nickname' => 'bob'
109
- })
110
- end
111
-
112
- it 'automaticallies set name off of nickname' do
113
- allow(subject).to receive(:nickname).and_return('bob')
114
- subject.info['name'] == 'bob'
115
- end
116
-
117
- it 'does not overwrite a provided name' do
118
- allow(subject).to receive(:name).and_return('Awesome Dude')
119
- allow(subject).to receive(:first_name).and_return('Frank')
120
- expect(subject.info['name']).to eq('Awesome Dude')
39
+ it 'raises a NotImplementedError if the auth_key method is not defined' do
40
+ expect { instance.auth_key = 'broken' }.to raise_error(NotImplementedError)
121
41
  end
122
42
  end
123
43
  end