devise-secure-password 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
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