devise-secure-password 1.1.1

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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +74 -0
  3. data/Changelog.md +98 -0
  4. data/Dockerfile +44 -0
  5. data/Dockerfile.prev +44 -0
  6. data/Gemfile +13 -0
  7. data/Gemfile.lock +272 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +382 -0
  10. data/Rakefile +11 -0
  11. data/app/controllers/devise/passwords_with_policy_controller.rb +93 -0
  12. data/app/views/devise/passwords_with_policy/edit.html.erb +20 -0
  13. data/bin/console +14 -0
  14. data/bin/setup +6 -0
  15. data/config/locales/en.yml +84 -0
  16. data/devise-secure-password.gemspec +57 -0
  17. data/docker-entrypoint.sh +6 -0
  18. data/gemfiles/rails_5_1.gemfile +21 -0
  19. data/gemfiles/rails_5_2.gemfile +22 -0
  20. data/lib/devise/secure_password.rb +71 -0
  21. data/lib/devise/secure_password/controllers/devise_helpers.rb +23 -0
  22. data/lib/devise/secure_password/controllers/helpers.rb +58 -0
  23. data/lib/devise/secure_password/grammar.rb +13 -0
  24. data/lib/devise/secure_password/models/password_disallows_frequent_changes.rb +62 -0
  25. data/lib/devise/secure_password/models/password_disallows_frequent_reuse.rb +73 -0
  26. data/lib/devise/secure_password/models/password_has_required_content.rb +170 -0
  27. data/lib/devise/secure_password/models/password_requires_regular_updates.rb +54 -0
  28. data/lib/devise/secure_password/models/previous_password.rb +20 -0
  29. data/lib/devise/secure_password/routes.rb +11 -0
  30. data/lib/devise/secure_password/version.rb +5 -0
  31. data/lib/generators/devise/secure_password/install_generator.rb +30 -0
  32. data/lib/generators/devise/templates/README.txt +21 -0
  33. data/lib/generators/devise/templates/secure_password.rb +43 -0
  34. data/lib/support/string/character_counter.rb +55 -0
  35. data/pkg/devise-secure-password-1.1.1.gem +0 -0
  36. metadata +429 -0
@@ -0,0 +1,73 @@
1
+ module Devise
2
+ module Models
3
+ module PasswordDisallowsFrequentReuse
4
+ extend ActiveSupport::Concern
5
+
6
+ require 'devise/secure_password/models/previous_password'
7
+
8
+ included do
9
+ # we need to specify the foreign_key here to support the use of isolated subclasses in tests
10
+ has_many :previous_passwords,
11
+ -> { limit(User.password_previously_used_count) },
12
+ class_name: 'Devise::Models::PreviousPassword',
13
+ foreign_key: 'user_id',
14
+ dependent: :destroy
15
+ validate :validate_password_frequent_reuse, if: :password_required?
16
+
17
+ set_callback(:save, :before, :before_resource_saved)
18
+ set_callback(:save, :after, :after_resource_saved, if: :dirty_password?)
19
+ end
20
+
21
+ def validate_password_frequent_reuse
22
+ if encrypted_password_changed? && previous_password?(password)
23
+ error_string = I18n.t(
24
+ 'secure_password.password_disallows_frequent_reuse.errors.messages.password_is_recent',
25
+ count: self.class.password_previously_used_count
26
+ )
27
+ errors.add(:base, error_string)
28
+ end
29
+
30
+ errors.count.zero?
31
+ end
32
+
33
+ protected
34
+
35
+ def before_resource_saved; end
36
+
37
+ def after_resource_saved
38
+ salt = ::BCrypt::Password.new(encrypted_password).salt
39
+ previous_password = previous_passwords.build(user_id: id, salt: salt, encrypted_password: encrypted_password)
40
+ previous_password.save!
41
+ end
42
+
43
+ def previous_password?(password)
44
+ salts = previous_passwords.select(:salt).map(&:salt)
45
+ pepper = self.class.pepper.presence || ''
46
+
47
+ salts.each do |salt|
48
+ candidate = ::BCrypt::Engine.hash_secret("#{password}#{pepper}", salt)
49
+ return true unless previous_passwords.find_by(encrypted_password: candidate).nil?
50
+ end
51
+
52
+ false
53
+ end
54
+
55
+ def dirty_password?
56
+ return false unless password_required?
57
+
58
+ if Rails.version > '5.1'
59
+ saved_change_to_encrypted_password?
60
+ else
61
+ encrypted_password_changed?
62
+ end
63
+ end
64
+
65
+ module ClassMethods
66
+ config_params = %i(
67
+ password_previously_used_count
68
+ )
69
+ ::Devise::Models.config(self, *config_params)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,170 @@
1
+ module Devise
2
+ module Models
3
+ module PasswordHasRequiredContent
4
+ extend ActiveSupport::Concern
5
+
6
+ require 'support/string/character_counter'
7
+
8
+ LENGTH_MAX = 255
9
+
10
+ included do
11
+ validate :validate_password_content, if: :password_required?
12
+ validate :validate_password_confirmation_content, if: :password_required?
13
+ validate :validate_password_confirmation, if: :password_required?
14
+ end
15
+
16
+ def validate_password_content
17
+ self.password ||= ''
18
+ errors.delete(:password)
19
+ validate_password_content_for(:password)
20
+ errors[:password].count.zero?
21
+ end
22
+
23
+ def validate_password_confirmation_content
24
+ return true if password_confirmation.nil? # rails skips password_confirmation validation if nil!
25
+
26
+ errors.delete(:password_confirmation)
27
+ validate_password_content_for(:password_confirmation)
28
+ errors[:password_confirmation].count.zero?
29
+ end
30
+
31
+ def validate_password_confirmation
32
+ return true if password_confirmation.nil? # rails skips password_confirmation validation if nil!
33
+
34
+ unless password == password_confirmation
35
+ human_attribute_name = self.class.human_attribute_name(:password)
36
+ errors.add(:password_confirmation, :confirmation, attribute: human_attribute_name)
37
+ end
38
+ errors[:password_confirmation].count.zero?
39
+ end
40
+
41
+ def validate_password_content_for(attr)
42
+ return unless respond_to?(attr) && !(password_obj = send(attr)).nil?
43
+
44
+ ::Support::String::CharacterCounter.new.count(password_obj).each do |type, dict|
45
+ error_string = case type
46
+ when :length then validate_length(dict[:count])
47
+ when :unknown then validate_unknown(dict)
48
+ else validate_type(type, dict)
49
+ end
50
+ errors.add(attr, error_string) if error_string.present?
51
+ end
52
+ end
53
+
54
+ protected
55
+
56
+ def validate_unknown(dict)
57
+ type_total = dict.values.reduce(0, :+)
58
+ return if type_total <= required_char_counts_for_type(:unknown)[:max]
59
+
60
+ error_string_for_unknown_chars(type_total, dict.keys)
61
+ end
62
+
63
+ def validate_type(type, dict)
64
+ type_total = dict.values.reduce(0, :+)
65
+ error_string = if type_total < required_char_counts_for_type(type)[:min]
66
+ error_string_for_type_length(type, :min)
67
+ elsif type_total > required_char_counts_for_type(type)[:max]
68
+ error_string_for_type_length(type, :max)
69
+ end
70
+ error_string
71
+ end
72
+
73
+ def validate_length(dict)
74
+ if dict < Devise.password_length.min
75
+ error_string_for_length(:min)
76
+ elsif dict > Devise.password_length.max
77
+ error_string_for_length(:max)
78
+ end
79
+ end
80
+
81
+ def error_string_for_length(threshold = :min)
82
+ lang_key = case threshold
83
+ when :min then 'secure_password.password_has_required_content.errors.messages.minimum_length'
84
+ when :max then 'secure_password.password_has_required_content.errors.messages.maximum_length'
85
+ else return ''
86
+ end
87
+
88
+ count = required_char_counts_for_type(:length)[threshold]
89
+ I18n.t(lang_key, count: count, subject: I18n.t('secure_password.character', count: count))
90
+ end
91
+
92
+ def error_string_for_type_length(type, threshold = :min)
93
+ lang_key = case threshold
94
+ when :min then 'secure_password.password_has_required_content.errors.messages.minimum_characters'
95
+ when :max then 'secure_password.password_has_required_content.errors.messages.maximum_characters'
96
+ else return ''
97
+ end
98
+
99
+ count = required_char_counts_for_type(type)[threshold]
100
+ error_string = I18n.t(lang_key, count: count, type: I18n.t("secure_password.types.#{type}"), subject: I18n.t('secure_password.character', count: count))
101
+ error_string + ' ' + dict_for_type(type)
102
+ end
103
+
104
+ def error_string_for_unknown_chars(count, chars = [])
105
+ I18n.t(
106
+ 'secure_password.password_has_required_content.errors.messages.unknown_characters',
107
+ count: count,
108
+ subject: I18n.t('secure_password.character', count: count)
109
+ ) + " (#{chars.join('')})"
110
+ end
111
+
112
+ def dict_for_type(type)
113
+ character_counter = ::Support::String::CharacterCounter.new
114
+
115
+ case type
116
+ when :special, :unknown then "(#{character_counter.count_hash[type].keys.join('')})"
117
+ else
118
+ "(#{character_counter.count_hash[type].keys.first}..#{character_counter.count_hash[type].keys.last})"
119
+ end
120
+ end
121
+
122
+ def required_char_counts_for_type(type)
123
+ self.class.config[:REQUIRED_CHAR_COUNTS][type]
124
+ end
125
+
126
+ module ClassMethods
127
+ config_params = %i(
128
+ password_required_uppercase_count
129
+ password_required_lowercase_count
130
+ password_required_number_count
131
+ password_required_special_character_count
132
+ )
133
+ ::Devise::Models.config(self, *config_params)
134
+
135
+ # rubocop:disable Metrics/MethodLength
136
+ def config
137
+ {
138
+ REQUIRED_CHAR_COUNTS: {
139
+ length: {
140
+ min: Devise.password_length.min,
141
+ max: Devise.password_length.max
142
+ },
143
+ uppercase: {
144
+ min: password_required_uppercase_count,
145
+ max: LENGTH_MAX
146
+ },
147
+ lowercase: {
148
+ min: password_required_lowercase_count,
149
+ max: LENGTH_MAX
150
+ },
151
+ number: {
152
+ min: password_required_number_count,
153
+ max: LENGTH_MAX
154
+ },
155
+ special: {
156
+ min: password_required_special_character_count,
157
+ max: LENGTH_MAX
158
+ },
159
+ unknown: {
160
+ min: 0,
161
+ max: 0
162
+ }
163
+ }
164
+ }
165
+ end
166
+ # rubocop:enable Metrics/MethodLength
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,54 @@
1
+ module Devise
2
+ module Models
3
+ module PasswordRequiresRegularUpdates
4
+ extend ActiveSupport::Concern
5
+
6
+ class ConfigurationError < RuntimeError; end
7
+
8
+ included do
9
+ set_callback(:initialize, :before, :before_regular_update_initialized)
10
+ set_callback(:initialize, :after, :after_regular_update_initialized)
11
+ end
12
+
13
+ def password_expired?
14
+ last_password = previous_passwords.first
15
+ inconsistent_password?(last_password) || last_password.stale?(self.class.password_maximum_age)
16
+ end
17
+
18
+ protected
19
+
20
+ def before_regular_update_initialized
21
+ return if self.class.respond_to?(:password_previously_used_count)
22
+
23
+ raise ConfigurationError, <<-ERROR.strip_heredoc
24
+
25
+ The password_requires_regular_updates module depends on the
26
+ password_disallows_frequent_reuse module. Verify that you have
27
+ added both modules to your model, for example:
28
+
29
+ devise :database_authenticatable, :registerable,
30
+ :password_disallows_frequent_reuse,
31
+ :password_requires_regular_updates
32
+ ERROR
33
+ end
34
+
35
+ def after_regular_update_initialized
36
+ raise ConfigurationError, 'invalid type for password_maximum_age' \
37
+ unless self.class.password_maximum_age.is_a?(::ActiveSupport::Duration)
38
+ end
39
+
40
+ # Check if current password is out of sync with last_password
41
+ #
42
+ # @param last_password [PreviousPassword] Password to compare with current password
43
+ # @return [Boolean] True if password is nil or out of sync, otherwise false
44
+ #
45
+ def inconsistent_password?(last_password = nil)
46
+ last_password.nil? || (encrypted_password != last_password.encrypted_password)
47
+ end
48
+
49
+ module ClassMethods
50
+ ::Devise::Models.config(self, :password_maximum_age)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,20 @@
1
+ module Devise
2
+ module Models
3
+ class PreviousPassword < ::ActiveRecord::Base
4
+ self.table_name = 'previous_passwords'
5
+ belongs_to :user
6
+ default_scope -> { order(id: :desc) }
7
+ validates :user_id, presence: true
8
+ validates :salt, presence: true
9
+ validates :encrypted_password, presence: true
10
+
11
+ def fresh?(minimum_age_duration, now = ::Time.zone.now)
12
+ now <= (created_at + minimum_age_duration)
13
+ end
14
+
15
+ def stale?(maximum_age_duration, now = ::Time.zone.now)
16
+ now > (created_at + maximum_age_duration)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,11 @@
1
+ module ActionDispatch
2
+ module Routing
3
+ class Mapper
4
+ protected
5
+
6
+ def devise_passwords_with_policy(mapping, controllers)
7
+ resource :password_with_policy, only: %i(edit update), path: mapping.path_names[:change_password], controller: controllers[:passwords_with_policy]
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ module Devise
2
+ module SecurePassword
3
+ VERSION = '1.1.1'.freeze
4
+ end
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Devise
4
+ module SecurePassword
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ LOCALES = %w(en).freeze
8
+
9
+ source_root File.expand_path('../templates', __dir__)
10
+
11
+ desc 'Creates a Devise Secure Password extension initializer and copies locale files to your application.'
12
+
13
+ def copy_initializer
14
+ template 'secure_password.rb', 'config/initializers/secure_password.rb'
15
+ end
16
+
17
+ def copy_locale
18
+ LOCALES.each do |locale|
19
+ copy_file "../../../../config/locales/#{locale}.yml",
20
+ "config/locales/secure_password.#{locale}.yml"
21
+ end
22
+ end
23
+
24
+ def show_readme
25
+ readme 'README.txt' if behavior == :invoke
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ ===============================================================================
2
+
3
+ Additional setup required:
4
+
5
+ 1. Verify that default settings in config/initializers/secure_password.rb
6
+ are suitable for your purposes.
7
+
8
+ 2. Enable secure_password modules by adding all of them or just the ones you
9
+ want to your User model. See the README for instructions.
10
+
11
+ 3. Perform a database migration to create the PreviousPasswords table. This
12
+ step is not necessary if you only intend to enable the password content
13
+ module (password_has_required_content). See the README for instructions.
14
+
15
+ 4. Add flash messages in app/views/layouts/application.html.erb.
16
+ For example:
17
+
18
+ <p class="notice"><%= notice %></p>
19
+ <p class="alert"><%= alert %></p>
20
+
21
+ ===============================================================================
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ Devise.setup do |config|
4
+ # ==> Configuration for the Devise Secure Password extension
5
+ # Module: password_has_required_content
6
+ #
7
+ # Configure password content requirements including the number of uppercase,
8
+ # lowercase, number, and special characters that are required. To configure the
9
+ # minimum and maximum length refer to the Devise config.password_length
10
+ # standard configuration parameter.
11
+
12
+ # The number of uppercase letters (latin A-Z) required in a password:
13
+ # config.password_required_uppercase_count = 1
14
+
15
+ # The number of lowercase letters (latin A-Z) required in a password:
16
+ # config.password_required_lowercase_count = 1
17
+
18
+ # The number of numbers (0-9) required in a password:
19
+ # config.password_required_number_count = 1
20
+
21
+ # The number of special characters (!@#$%^&*()_+-=[]{}|') required in a password:
22
+ # config.password_required_special_character_count = 1
23
+
24
+ # ==> Configuration for the Devise Secure Password extension
25
+ # Module: password_disallows_frequent_reuse
26
+ #
27
+ # The number of previously used passwords that can not be reused:
28
+ # config.password_previously_used_count = 8
29
+
30
+ # ==> Configuration for the Devise Secure Password extension
31
+ # Module: password_disallows_frequent_changes
32
+ # *Requires* password_disallows_frequent_reuse
33
+ #
34
+ # The minimum time that must pass between password changes:
35
+ # config.password_minimum_age = 1.days
36
+
37
+ # ==> Configuration for the Devise Secure Password extension
38
+ # Module: password_requires_regular_updates
39
+ # *Requires* password_disallows_frequent_reuse
40
+ #
41
+ # The maximum allowed age of a password:
42
+ # config.password_maximum_age = 180.days
43
+ end
@@ -0,0 +1,55 @@
1
+ # lib/support/character_counter.rb
2
+ #
3
+ module Support
4
+ module String
5
+ class CharacterCounter
6
+ attr_reader :count_hash
7
+
8
+ def initialize
9
+ @count_hash = {
10
+ length: { count: 0 },
11
+ uppercase: characters_to_dictionary(('A'..'Z').to_a),
12
+ lowercase: characters_to_dictionary(('a'..'z').to_a),
13
+ number: characters_to_dictionary(('0'..'9').to_a),
14
+ special: characters_to_dictionary(%w(! @ # $ % ^ & * ( ) _ + - = [ ] { } | ')),
15
+ unknown: {}
16
+ }
17
+ end
18
+
19
+ def count(string)
20
+ raise ArgumentError, "Invalid value for string: #{string}" if string.nil?
21
+
22
+ string.split('').each { |c| tally_character(c) }
23
+ @count_hash[:length][:count] = string.length
24
+
25
+ @count_hash
26
+ end
27
+
28
+ private
29
+
30
+ def characters_to_dictionary(array)
31
+ dictionary = {}
32
+ array.each { |c| dictionary.store(c, 0) }
33
+
34
+ dictionary
35
+ end
36
+
37
+ def tally_character(character)
38
+ %i(uppercase lowercase number special unknown).each do |type|
39
+ if @count_hash[type].key?(character)
40
+ @count_hash[type][character] += 1
41
+ return @count_hash[type][character]
42
+ end
43
+ end
44
+
45
+ # must be new unknown char
46
+ @count_hash[:unknown][character] = 1
47
+ @count_hash[:unknown][character]
48
+ end
49
+
50
+ def character_in_dictionary?(character, dictionary)
51
+ dictionary.key?(character)
52
+ end
53
+ end
54
+ end
55
+ end