negroni 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +20 -0
  3. data/README.md +59 -0
  4. data/Rakefile +64 -0
  5. data/app/mailers/negroni/mailer.rb +45 -0
  6. data/app/views/negroni/mailer/password_change.html.erb +5 -0
  7. data/app/views/negroni/mailer/reset_password_instructions.html.erb +8 -0
  8. data/app/views/negroni/mailer/unlock_instructions.html.erb +7 -0
  9. data/config/locales/en.yml +9 -0
  10. data/config/routes.rb +4 -0
  11. data/lib/negroni.rb +209 -0
  12. data/lib/negroni/configuration.rb +231 -0
  13. data/lib/negroni/controllers/helpers.rb +29 -0
  14. data/lib/negroni/controllers/token_authenticable.rb +20 -0
  15. data/lib/negroni/encryptor.rb +35 -0
  16. data/lib/negroni/engine.rb +35 -0
  17. data/lib/negroni/mailers/helpers.rb +112 -0
  18. data/lib/negroni/models.rb +138 -0
  19. data/lib/negroni/models/authenticable.rb +197 -0
  20. data/lib/negroni/models/base.rb +318 -0
  21. data/lib/negroni/models/lockable.rb +216 -0
  22. data/lib/negroni/models/omniauthable.rb +33 -0
  23. data/lib/negroni/models/recoverable.rb +204 -0
  24. data/lib/negroni/models/registerable.rb +14 -0
  25. data/lib/negroni/models/validatable.rb +63 -0
  26. data/lib/negroni/modules.rb +12 -0
  27. data/lib/negroni/omniauth.rb +25 -0
  28. data/lib/negroni/omniauth/config.rb +81 -0
  29. data/lib/negroni/orm/active_record.rb +7 -0
  30. data/lib/negroni/orm/mongoid.rb +6 -0
  31. data/lib/negroni/param_filter.rb +53 -0
  32. data/lib/negroni/resolver.rb +17 -0
  33. data/lib/negroni/token_generator.rb +58 -0
  34. data/lib/negroni/token_not_found.rb +13 -0
  35. data/lib/negroni/version.rb +6 -0
  36. data/lib/tasks/negroni_tasks.rake +5 -0
  37. metadata +169 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Negroni
4
+ # Namespace for Model-specific configuration.
5
+ module Models
6
+ extend ActiveSupport::Autoload
7
+
8
+ # Raised if a required attribute is missing.
9
+ class MissingAttribute < StandardError
10
+ # Create a MissingAttribute error with a list of attributes
11
+ def initialize(attributes)
12
+ @attributes = attributes
13
+ super(message)
14
+ end
15
+
16
+ # The error message
17
+ def message
18
+ verb, s = @attributes.count > 1 ? %w(are s) : ['is']
19
+ list = @attributes.join(', ')
20
+
21
+ "The following attribute#{s} #{verb} missing on your model: #{list}"
22
+ end
23
+ end
24
+
25
+ # Creates configuration values for Negroni and for the given module.
26
+ #
27
+ # Negroni::Models.config(Negroni::Authenticatable, :authentication_keys)
28
+ #
29
+ # The line above creates:
30
+ #
31
+ # 1) An accessor called Negroni.authentication_keys, which value is used
32
+ # by default;
33
+ #
34
+ # 2) Some class methods for your model Model.authentication_keys and
35
+ # Model.authentication_keys= which have higher priority than
36
+ # Negroni.authentication_keys;
37
+ #
38
+ # 3) And an instance method authentication_keys.
39
+ #
40
+ # To add the class methods you need to have a module ClassMethods defined
41
+ # inside the given class.
42
+ #
43
+ # @api private
44
+ def self.config(mod, *accessors) # rubocop:disable Metrics/MethodLength
45
+ class << mod; attr_accessor :available_configs; end
46
+ mod.available_configs = accessors
47
+
48
+ accessors.each do |accessor|
49
+ mod.class_eval <<-METHOD, __FILE__, __LINE__ + 1
50
+ def #{accessor}
51
+ if defined?(@#{accessor})
52
+ @#{accessor}
53
+ elsif superclass.respond_to?(:#{accessor})
54
+ superclass.#{accessor}
55
+ else
56
+ Negroni.#{accessor}
57
+ end
58
+ end
59
+
60
+ def #{accessor}=(value)
61
+ @#{accessor} = value
62
+ end
63
+ METHOD
64
+ end
65
+ end
66
+
67
+ # Checks that the including class has the appropriate required fields.
68
+ #
69
+ # @param klass [Class] the including class
70
+ # @raise [Negroni::Models::MissingAttribute] if the check fails.
71
+ # @return [Void]
72
+ def self.check_fields!(klass)
73
+ failed_attributes = []
74
+ instance = klass.new
75
+
76
+ klass.negroni_modules.each do |mod|
77
+ constant = const_get(mod.to_s.classify)
78
+
79
+ constant.required_fields(klass).each do |field|
80
+ failed_attributes << field unless instance.respond_to?(field)
81
+ end
82
+ end
83
+
84
+ return unless failed_attributes.any?
85
+ raise Negroni::Models::MissingAttribute, failed_attributes
86
+ end
87
+
88
+ # rubocop:disable Metrics/AbcSize,MethodLength
89
+
90
+ # Include the given Negroni modules in your model. Authenticate is always
91
+ # included.
92
+ #
93
+ # @param modules [Symbol, Array<Symbol>] the modules to include.
94
+ # @return [Void]
95
+ def negroni(*modules)
96
+ options = modules.extract_options!.dup
97
+
98
+ selected_modules = modules.map(&:to_sym).uniq.sort_by do |sym|
99
+ Negroni::ALL.index(sym) != -1
100
+ end
101
+
102
+ aa_module_hook! do
103
+ include Negroni::Models::Base
104
+
105
+ selected_modules.each do |m|
106
+ mod = Negroni::Models.const_get m.to_s.classify
107
+
108
+ if mod.const_defined? 'ClassMethods'
109
+ class_mod = mod.const_get('ClassMethods')
110
+ extend class_mod
111
+
112
+ if class_mod.respond_to? :available_configs
113
+ available_configs = class_mod.available_configs
114
+
115
+ available_configs.each do |config|
116
+ next unless options.key? config
117
+ send(:"#{config}=", options.delete(config))
118
+ end
119
+ end
120
+ end
121
+
122
+ include mod
123
+ end
124
+
125
+ self.negroni_modules |= selected_modules
126
+ options.each { |key, value| send(:"#{key}=", value) }
127
+ end
128
+ end
129
+ # rubocop:enable all
130
+
131
+ # a hook
132
+ def aa_module_hook!
133
+ yield
134
+ end
135
+ end
136
+ end
137
+
138
+ require 'negroni/models/base'
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Negroni
6
+ module Models
7
+ # The `Authenticable` module should be included in any application classes
8
+ # that should be authenticable via a JSON web token.
9
+ #
10
+ # This module makes a few assumptions about your class:
11
+ # * It has an `email` attribute
12
+ #
13
+ module Authenticable
14
+ extend ActiveSupport::Concern
15
+
16
+ included do
17
+ after_update :send_password_change_notification,
18
+ if: :send_password_change_notification?
19
+
20
+ attr_reader :password, :current_password
21
+
22
+ attr_accessor :password_confirmation
23
+ end
24
+
25
+ # Required fields for this module
26
+ def self.required_fields(klass)
27
+ [:password_digest] + klass.authentication_keys
28
+ end
29
+
30
+ # Generates a hashed password based on the given value.
31
+ def password=(new_password)
32
+ @password = new_password
33
+ self.password_digest = digest_password(@password) if @password.present?
34
+ end
35
+
36
+ # Checks if a password is valid for the given instance
37
+ def valid_password?(password)
38
+ Negroni::Encryptor.compare(self.class, password_digest, password)
39
+ end
40
+
41
+ # @!group Updating a Record
42
+
43
+ # Update record attributes when :current_password matches, otherwise
44
+ # returns error on :current_password.
45
+ #
46
+ # This method also rejects the password field if it is blank (allowing
47
+ # users to change relevant information like the e-mail without changing
48
+ # their password). In case the password field is rejected, the confirmation
49
+ # is also rejected as long as it is also blank.
50
+ #
51
+ # @param params [Hash] params from the controller
52
+ # @param options [Hash] a hash of options
53
+ def update_with_password(params, *options)
54
+ current_password = params.delete :current_password
55
+
56
+ params = _sanitize_password_params(params)
57
+
58
+ result = if valid_password?(current_password)
59
+ update_attributes(params, *options)
60
+ else
61
+ _invalid_update(current_password, params, *options)
62
+ end
63
+
64
+ clean_up_passwords
65
+ result
66
+ end
67
+
68
+ # Updates record attributes without asking for the current password.
69
+ # Never allows a change to the current password. If you are using this
70
+ # method, you should probably override this method to protect other
71
+ # attributes you would not like to be updated without a password.
72
+ #
73
+ # @example
74
+ #
75
+ # def update_without_password(params, *options)
76
+ # params.delete(:email)
77
+ # super(params)
78
+ # end
79
+ #
80
+ def update_without_password(params, *options)
81
+ params.delete(:password)
82
+ params.delete(:password_confirmation)
83
+
84
+ result = update_attributes(params, *options)
85
+ clean_up_passwords
86
+ result
87
+ end
88
+
89
+ # Destroy record when :current_password matches, otherwise returns
90
+ # error on :current_password. It also automatically rejects
91
+ # :current_password if it is blank.
92
+ #
93
+ # @param current_password [String] the current password for the record
94
+ #
95
+ def destroy_with_password(current_password)
96
+ result = if valid_password?(current_password)
97
+ destroy # rubocop:disable Rails/SaveBang
98
+ else
99
+ valid?
100
+ message = current_password.blank? ? :blank : :invalid
101
+ errors.add(:current_password, message)
102
+ false
103
+ end
104
+
105
+ result
106
+ end
107
+
108
+ # @!group Authentication Methods
109
+
110
+ # Authenticates the including class with `unencrypted_password`.
111
+ #
112
+ # @param unencrypted_password [String] the password to auth against
113
+ #
114
+ # @return [Boolean] if the user is successfully authenticated
115
+ def authenticate(unencrypted_password)
116
+ valid_password?(unencrypted_password) && self
117
+ end
118
+
119
+ # Authenticates the including class with `unencrypted_password`.
120
+ #
121
+ # @param unencrypted_password [String] the password to auth against
122
+ #
123
+ # @raise [ActiveRecord::RecordNotFound] if the user is not successfully
124
+ # authenticated
125
+ #
126
+ # @return [Boolean] if the user is successfully authenticated
127
+ def authenticate!(unencrypted_password)
128
+ authenticate(unencrypted_password) || raise('Bad password!')
129
+ end
130
+
131
+ # @!endgroup
132
+
133
+ # Reliably returns the salt, regardless of implementation
134
+ #
135
+ # @return [String]
136
+ def authenticable_salt
137
+ password_digest[0, 29] if password_digest
138
+ end
139
+
140
+ protected
141
+
142
+ def digest_password(password)
143
+ Negroni::Encryptor.digest(self.class, password)
144
+ end
145
+
146
+ def send_password_change_notification?
147
+ self.class.send_password_change_notification && password_digest_changed?
148
+ end
149
+
150
+ # @!group Callbacks
151
+
152
+ def send_password_change_notification
153
+ send_auth_notification(:password_change)
154
+ end
155
+
156
+ # Cleans up the @password and @password_confirmation ivars
157
+ def clean_up_passwords
158
+ self.password = self.password_confirmation = nil
159
+ end
160
+
161
+ # @!endgroup Callbacks
162
+
163
+ private
164
+
165
+ # remove :password and :password_confirmation if they are present
166
+ #
167
+ # @param params [Hash] the params hash to sanitize
168
+ # @return [Hash] the sanitized params
169
+ def _sanitize_password_params(params, remove_all: false)
170
+ params.delete(:password) if params[:password].blank? || remove_all
171
+
172
+ if params[:password_confirmation].blank? || remove_all
173
+ params.delete(:password_confirmation)
174
+ end
175
+
176
+ params
177
+ end
178
+
179
+ def _invalid_update(current_password, params, *options)
180
+ _sanitize_password_params(params, remove_all: true)
181
+ assign_attributes(params, *options)
182
+ valid?
183
+
184
+ message = current_password.blank? ? :blank : :invalid
185
+ errors.add(:current_password, message)
186
+ false
187
+ end
188
+
189
+ # Class methods for the Authenticate module.
190
+ module ClassMethods
191
+ Negroni::Models.config(
192
+ self, :pepper, :stretches, :send_password_change_notification
193
+ )
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Negroni
4
+ module Models
5
+ # The `Base` module contains methods that should be included in all Negroni
6
+ # models. It holds common settings for all authentication, as well as some
7
+ # shared behavior across all modules.
8
+ #
9
+ # ## Options
10
+ #
11
+ # Base adds the following options:
12
+ #
13
+ # * `authentication_keys`: parameters used for authentication.
14
+ # Default [:email].
15
+ #
16
+ # * `strip_whitespace_keys`: keys from which whitespace should be
17
+ # stripped prior to validation. Default [:email].
18
+ #
19
+ module Base
20
+ extend ActiveSupport::Concern
21
+
22
+ # Blacklisted attributes for serialization.
23
+ SERIALIZATION_BLACKLIST = [
24
+ :password_digest, :reset_password_token, :reset_password_sent_at,
25
+ :password_salt, :confirmation_token, :confirmed_at,
26
+ :confirmation_sent_at, :failed_attempts, :unlock_token, :locked_at
27
+ ].freeze
28
+
29
+ included do
30
+ class_attribute :negroni_modules, instance_writer: false
31
+ self.negroni_modules ||= []
32
+
33
+ before_validation :downcase_keys
34
+ before_validation :strip_whitespace
35
+ end
36
+
37
+ # Required fields for this module (_none_).
38
+ def self.required_fields(_klass)
39
+ []
40
+ end
41
+
42
+ # Returns whether or not the including class is active for
43
+ # authentication. This method is primarily intended to be overriden by
44
+ # other modules, including `Lockable`.
45
+ #
46
+ # Yields and returns the return value of the given block, if a block is
47
+ # given. Otherwise returns true.
48
+ #
49
+ # @return [Boolean]
50
+ def valid_for_auth?
51
+ block_given? ? yield : true
52
+ end
53
+
54
+ # The message that will be populated if an invalid record attempts to
55
+ # authenticate.
56
+ #
57
+ # @return [Symbol] the key of the translation to look up
58
+ def unauthenticated_message
59
+ :invalid
60
+ end
61
+
62
+ # Returns whether or not the including class is active for
63
+ # authentication. This method is primarily intended to be overriden by
64
+ # other modules, including `Lockable`.
65
+ #
66
+ # @return [Boolean]
67
+ def active_for_auth?
68
+ true
69
+ end
70
+
71
+ # The message that will be populated if an inactive record attempts to
72
+ # authenticate.
73
+ #
74
+ # @return [Symbol] the key of the translation to look up
75
+ def inactive_message
76
+ :inactive
77
+ end
78
+
79
+ # Redefine serializable_hash in models for more secure defaults. By
80
+ # default, it removes from the serializable model all attributes that are
81
+ # __not__ accessible. You can remove this default by using :force_except
82
+ # and passing a new list of attributes you want to exempt. All attributes
83
+ # given to :except will simply add names to exempt to Negroni internal
84
+ # list.
85
+ def serializable_hash(options = nil)
86
+ options ||= {}
87
+ options[:except] = Array(options[:except])
88
+
89
+ if options[:force_except]
90
+ options[:except].concat Array(options[:force_except])
91
+ else
92
+ options[:except].concat SERIALIZATION_BLACKLIST
93
+ end
94
+
95
+ super(options)
96
+ end
97
+
98
+ # Redefine inspect using serializable_hash, to ensure we don't accidentally
99
+ # leak passwords into exceptions.
100
+ def inspect
101
+ inspection = serializable_hash.collect do |k, v|
102
+ value = if respond_to?(:attribute_for_inspect)
103
+ attribute_for_inspect(k)
104
+ else
105
+ v.inspect
106
+ end
107
+
108
+ "#{k}: #{value}"
109
+ end
110
+
111
+ "#<#{self.class} #{inspection.join(', ')}>"
112
+ end
113
+
114
+ protected
115
+
116
+ def auth_mailer
117
+ Negroni.mailer
118
+ end
119
+
120
+ # This is an internal method called every time Negroni needs to send a
121
+ # notification/mail. This can be overridden if you need to customize the
122
+ # e-mail delivery logic. For instance, if you are using a queue to deliver
123
+ # e-mails (delayed job, sidekiq, resque, etc), you must add the delivery
124
+ # to the queue just after the transaction was committed. To achieve this,
125
+ # you can override send_auth_notification to store the deliveries
126
+ # until the after_commit callback is triggered:
127
+ #
128
+ # @example
129
+ # class User
130
+ # negroni :authenticable, :confirmable
131
+ #
132
+ # after_commit :send_pending_notifications
133
+ #
134
+ # protected
135
+ #
136
+ # def send_auth_notification(notification, *args)
137
+ # # If the record is new or changed then delay the
138
+ # # delivery until the after_commit callback otherwise
139
+ # # send now because after_commit will not be called.
140
+ # if new_record? || changed?
141
+ # pending_notifications << [notification, args]
142
+ # else
143
+ # message = auth_mailer.send(notification, self, *args)
144
+ # Remove once we move to Rails 4.2+ only.
145
+ # if message.respond_to?(:deliver_now)
146
+ # message.deliver_now
147
+ # else
148
+ # message.deliver
149
+ # end
150
+ # end
151
+ # end
152
+ #
153
+ # def send_pending_notifications
154
+ # pending_notifications.each do |notification, args|
155
+ # message = auth_mailer.send(notification, self, *args)
156
+ # Remove once we move to Rails 4.2+ only.
157
+ # if message.respond_to?(:deliver_now)
158
+ # message.deliver_now
159
+ # else
160
+ # message.deliver
161
+ # end
162
+ # end
163
+ #
164
+ # # Empty the pending notifications array because the
165
+ # # after_commit hook can be called multiple times which
166
+ # # could cause multiple emails to be sent.
167
+ # pending_notifications.clear
168
+ # end
169
+ #
170
+ # def pending_notifications
171
+ # \@pending_notifications ||= []
172
+ # end
173
+ # end
174
+ #
175
+ def send_auth_notification(notification, *args)
176
+ message = auth_mailer.send(notification, self, *args)
177
+ message.deliver_now
178
+ end
179
+
180
+ # @!group Callbacks
181
+
182
+ # Downcase keys before validation
183
+ def downcase_keys
184
+ self.class.case_insensitive_keys.each { |k| _apply(k, :downcase) }
185
+ end
186
+
187
+ # Strip whitespace from requested keys before validation
188
+ def strip_whitespace
189
+ self.class.strip_whitespace_keys.each { |k| _apply(k, :strip) }
190
+ end
191
+
192
+ # @!endgroup Callbacks
193
+
194
+ private
195
+
196
+ # Apply `method` to `attr`
197
+ def _apply(attr, method)
198
+ if self[attr]
199
+ self[attr] = self[attr].try(method)
200
+ elsif respond_to?(attr) && respond_to?("#{attr}=")
201
+ new_value = send(attr).try(method)
202
+ send("#{attr}=", new_value)
203
+ end
204
+ end
205
+
206
+ # Class Methods for the `Base` module.
207
+ module ClassMethods
208
+ Negroni::Models.config(self,
209
+ :authentication_keys,
210
+ :strip_whitespace_keys,
211
+ :case_insensitive_keys)
212
+
213
+ # Find first record based on conditions given (ie by the sign in form).
214
+ # This method is always called during an authentication process but
215
+ # it may be wrapped as well. For instance, database authenticable
216
+ # provides a `find_for_database_authentication` that wraps a call to
217
+ # this method. This allows you to customize both database authenticable
218
+ # or the whole authenticate stack by customize `find_for_authentication.`
219
+ #
220
+ # Overwrite to add customized conditions, create a join, or maybe use a
221
+ # namedscope to filter records while authenticating.
222
+ #
223
+ # @example
224
+ # def self.find_for_authentication(tainted_conditions)
225
+ # find_first_by_auth_conditions(tainted_conditions, active: true)
226
+ # end
227
+ #
228
+ # Finally, notice that Negroni also queries for users in other scenarios
229
+ # besides authentication, for example when retrieving an user to send
230
+ # an e-mail for password reset. In such cases, find_for_authentication
231
+ # is not called.
232
+ def find_for_authentication(tainted_conditions)
233
+ find_first_by_auth_conditions(tainted_conditions)
234
+ end
235
+
236
+ # Find the first record, given a set of `tainted_conditions`, and
237
+ # options.
238
+ #
239
+ # @param tainted_conditions [Hash, ActionDispatch::Parameters]
240
+ # the conditions used to find the record
241
+ # @param options [Hash] additional conditions and options for the find
242
+ # operation
243
+ #
244
+ # @return [Object] the first record returned from the database
245
+ def find_first_by_auth_conditions(tainted_conditions, options = {})
246
+ to_adapter.find_first(
247
+ _param_filter.filter(tainted_conditions).merge(options)
248
+ )
249
+ end
250
+
251
+ # Find or initialize a record setting an error if it can't be found.
252
+ def find_or_initialize_with_error_by(attr, value, error = :invalid)
253
+ find_or_initialize_with_errors([attr], { attr => value }, error)
254
+ end
255
+
256
+ # Find or initialize a record with a group of attributes, based on a
257
+ # list of required attributes
258
+ def find_or_initialize_with_errors(required, attrs, error = :invalid)
259
+ attrs = _indifferently(required, attrs).delete_if { |_, v| v.blank? }
260
+
261
+ if attrs.size == required.size
262
+ record = find_first_by_auth_conditions(attrs)
263
+ end
264
+
265
+ unless record
266
+ record = new
267
+
268
+ required.each do |key|
269
+ value = attrs[key]
270
+ record.send("#{key}=", value)
271
+ record.errors.add(key, value.present? ? error : :blank)
272
+ end
273
+ end
274
+
275
+ record
276
+ end
277
+
278
+ # Finds an entity of the including class by a token auth request.
279
+ # This allows users to sign in with either an email address or
280
+ # thier username.
281
+ #
282
+ # @param request [Request] The request which contains the sign in params.
283
+ #
284
+ # @return [Object, nil] The found entity, or `nil` if one was not found.
285
+ def from_token_request(request)
286
+ # Bail if there is no `auth` param
287
+ return nil unless (auth_params = request.params['auth'])
288
+
289
+ # find_first_by_auth_conditions(auth_params)
290
+
291
+ authentication_keys.each do |key|
292
+ if (found_key = auth_params[key.to_s])
293
+ return to_adapter.find_first(key => found_key)
294
+ end
295
+ end
296
+ end
297
+
298
+ protected
299
+
300
+ def _param_filter
301
+ @_param_filter ||= Negroni::ParamFilter.new(
302
+ case_insensitive_keys, strip_whitespace_keys
303
+ )
304
+ end
305
+
306
+ private
307
+
308
+ def _indifferently(required, attributes)
309
+ if attributes.respond_to?(:permit!)
310
+ attributes.slice(*required).permit!.to_h.with_indifferent_access
311
+ else
312
+ attributes.with_indifferent_access.slice(*required)
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
318
+ end