negroni 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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