devise-secure_password 1.0.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 (35) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +74 -0
  3. data/Dockerfile +44 -0
  4. data/Dockerfile.prev +44 -0
  5. data/Gemfile +13 -0
  6. data/Gemfile.lock +280 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +326 -0
  9. data/Rakefile +11 -0
  10. data/app/controllers/devise/passwords_with_policy_controller.rb +52 -0
  11. data/app/views/devise/passwords_with_policy/edit.html.erb +16 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +6 -0
  14. data/config/locales/en.yml +71 -0
  15. data/devise-secure_password.gemspec +57 -0
  16. data/docker-entrypoint.sh +6 -0
  17. data/gemfiles/rails-5_0_6.gemfile +17 -0
  18. data/gemfiles/rails-5_1_4.gemfile +16 -0
  19. data/lib/devise/secure_password.rb +70 -0
  20. data/lib/devise/secure_password/controllers/active_helpers.rb +40 -0
  21. data/lib/devise/secure_password/controllers/devise_helpers.rb +64 -0
  22. data/lib/devise/secure_password/hooks/password_requires_regular_updates.rb +5 -0
  23. data/lib/devise/secure_password/models/password_disallows_frequent_changes.rb +60 -0
  24. data/lib/devise/secure_password/models/password_disallows_frequent_reuse.rb +71 -0
  25. data/lib/devise/secure_password/models/password_has_required_content.rb +131 -0
  26. data/lib/devise/secure_password/models/password_requires_regular_updates.rb +56 -0
  27. data/lib/devise/secure_password/models/previous_password.rb +20 -0
  28. data/lib/devise/secure_password/routes.rb +11 -0
  29. data/lib/devise/secure_password/version.rb +5 -0
  30. data/lib/generators/devise/secure_password/install_generator.rb +30 -0
  31. data/lib/generators/devise/templates/README.txt +21 -0
  32. data/lib/generators/devise/templates/secure_password.rb +43 -0
  33. data/lib/support/string/character_counter.rb +53 -0
  34. data/pkg/devise-secure_password-1.0.0.gem +0 -0
  35. metadata +471 -0
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,71 @@
1
+ en:
2
+ secure_password:
3
+ password_has_required_content:
4
+ errors:
5
+ messages:
6
+ unknown_characters: "contains %{count} invalid %{subject}"
7
+ minimum_characters: "must contain at least %{count} %{type} %{subject}"
8
+ maximum_characters: "must contain less than %{count} %{type} %{subject}"
9
+ password_disallows_frequent_reuse:
10
+ errors:
11
+ messages:
12
+ password_is_recent: "Last %{count} passwords may not be reused"
13
+ password_disallows_frequent_changes:
14
+ errors:
15
+ messages:
16
+ password_is_recent: "Password cannot be changed more than once per %{timeframe}"
17
+ password_requires_regular_updates:
18
+ alerts:
19
+ messages:
20
+ password_updated: "Your password has been updated."
21
+ errors:
22
+ messages:
23
+ password_expired: "Your password has expired. Passwords must be changed every %{timeframe}"
24
+ devise:
25
+ passwords_with_policy:
26
+ edit:
27
+ titles:
28
+ section_title: "Change your password"
29
+ labels:
30
+ current_password: "Current password"
31
+ new_password: "New password"
32
+ confirm_new_password: "Confirm new password"
33
+ buttons:
34
+ change_my_password: "Change my password"
35
+ # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words()
36
+ datetime:
37
+ distance_in_words:
38
+ half_a_minute: "half a minute"
39
+ less_than_x_seconds:
40
+ one: "1 second" # default was: "less than 1 second"
41
+ other: "%{count} seconds" # default was: "less than %{count} seconds"
42
+ x_seconds:
43
+ one: "1 second"
44
+ other: "%{count} seconds"
45
+ less_than_x_minutes:
46
+ one: "a minute" # default was: "less than a minute"
47
+ other: "%{count} minutes" # default was: "less than %{count} minutes"
48
+ x_minutes:
49
+ one: "1 minute"
50
+ other: "%{count} minutes"
51
+ about_x_hours:
52
+ one: "1 hour" # default was: "about 1 hour"
53
+ other: "%{count} hours" # default was: "about %{count} hours"
54
+ x_days:
55
+ one: "1 day"
56
+ other: "%{count} days"
57
+ about_x_months:
58
+ one: "1 month" # default was: "about 1 month"
59
+ other: "%{count} months" # default was: "about %{count} months"
60
+ x_months:
61
+ one: "1 month"
62
+ other: "%{count} months"
63
+ about_x_years:
64
+ one: "1 year" # default was: "about 1 year"
65
+ other: "%{count} years" # default was: "about %{count} years"
66
+ over_x_years:
67
+ one: "1 year" # default was: "over 1 year"
68
+ other: "%{count} years" # default was: "over %{count} years"
69
+ almost_x_years:
70
+ one: "1 year" # default was: "almost 1 year"
71
+ other: "%{count} years" # default was: "almost %{count} years"
@@ -0,0 +1,57 @@
1
+ #
2
+ # devise-secure_password.gemspec
3
+ #
4
+ lib = File.expand_path('lib', __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require 'date'
8
+ require 'devise/secure_password/version'
9
+
10
+ Gem::Specification.new do |spec|
11
+ spec.name = 'devise-secure_password'
12
+ spec.version = Devise::SecurePassword::VERSION.dup
13
+ spec.platform = Gem::Platform::RUBY
14
+
15
+ spec.authors = ['Mark Eissler']
16
+ spec.email = ['mark.eissler@valimail.com']
17
+
18
+ spec.summary = 'A devise password policy enforcement extension.'
19
+ spec.description = 'Adds configurable password policy enforcement to devise.'
20
+
21
+ spec.homepage = 'https://github.com/valimail/devise-secure_password'
22
+ spec.license = 'MIT'
23
+
24
+ spec.files = Dir['./**/*'].reject do |f|
25
+ f.match(%r{^./(test|spec|features|lib/tasks)/|Gemfile.lock.ci})
26
+ end
27
+ spec.executables = spec.files.grep(%r{^bin/}).map { |f| File.basename(f) }
28
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
29
+ spec.require_paths = ['lib']
30
+
31
+ spec.add_runtime_dependency 'devise', '>= 4.0.0', '< 5.0.0'
32
+ spec.add_runtime_dependency 'railties', '>= 5.0.0', '< 6.0.0'
33
+
34
+ spec.add_development_dependency 'bundler', '~> 1.16', '>= 1.16.1'
35
+ spec.add_development_dependency 'capybara', '~> 2.16', '>= 2.16.1'
36
+ spec.add_development_dependency 'capybara-screenshot', '~> 1.0', '>= 1.0.18'
37
+ spec.add_development_dependency 'coffee-rails', '~> 4.2'
38
+ spec.add_development_dependency 'database_cleaner', '~> 1.6', '>= 1.6.2'
39
+ spec.add_development_dependency 'devise', '~> 4.0'
40
+ spec.add_development_dependency 'flay', '~> 2.10', '>= 2.10.0'
41
+ spec.add_development_dependency 'launchy', '~> 2.4', '>= 2.4.3'
42
+ spec.add_development_dependency 'rails', '~> 5.1', '>= 5.1.4'
43
+ spec.add_development_dependency 'rake', '~> 12.3'
44
+ spec.add_development_dependency 'rspec', '~> 3.7'
45
+ spec.add_development_dependency 'rspec-rails', '~> 3.7'
46
+ spec.add_development_dependency 'rspec_junit_formatter', '~> 0.3'
47
+ spec.add_development_dependency 'rubocop', '~> 0'
48
+ spec.add_development_dependency 'ruby2ruby', '~> 2.4', '>= 2.4.0'
49
+ spec.add_development_dependency 'sass-rails', '~> 5.0'
50
+ spec.add_development_dependency 'selenium-webdriver', '~> 3.7', '>= 3.7.0'
51
+ spec.add_development_dependency 'simplecov', '~> 0.15.1'
52
+ spec.add_development_dependency 'simplecov-console', '~> 0.4.2'
53
+ spec.add_development_dependency 'sqlite3', '~> 1.3', '>= 1.3.13'
54
+ spec.add_development_dependency 'therubyracer', '~> 0.12.3'
55
+
56
+ spec.required_ruby_version = '>= 2.4'
57
+ end
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ #
3
+ # Silence errors from Xvfb because we are starting it in non-priveleged mode.
4
+ #
5
+ Xvfb :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
6
+ exec "$@"
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ ENV['RAILS_TARGET'] ||= '5.0.6'
6
+
7
+ gemspec path: '../'
8
+
9
+ group :development, :test do
10
+ gem 'byebug', '>= 0'
11
+ end
12
+
13
+ group :test do
14
+ gem 'jquery-rails', '~> 4.3.1'
15
+ gem 'rails', '~> 5.0.0'
16
+ gem 'shoulda-matchers', git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5'
17
+ end
@@ -0,0 +1,16 @@
1
+ source 'https://rubygems.org'
2
+
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ ENV['RAILS_TARGET'] ||= '5.1.4'
6
+
7
+ gemspec path: '../'
8
+
9
+ group :development, :test do
10
+ gem 'byebug', '>= 0'
11
+ end
12
+
13
+ group :test do
14
+ gem 'rails', '~> 5.1.0'
15
+ gem 'shoulda-matchers', git: 'https://github.com/thoughtbot/shoulda-matchers.git', branch: 'rails-5'
16
+ end
@@ -0,0 +1,70 @@
1
+ #
2
+ # lib/devise-secure_password.rb
3
+ #
4
+ require 'active_support/concern'
5
+ require 'devise'
6
+ require 'devise/secure_password/routes'
7
+ require 'devise/secure_password/version'
8
+ require 'devise/secure_password/models/password_has_required_content'
9
+ require 'devise/secure_password/models/password_disallows_frequent_reuse'
10
+ require 'devise/secure_password/models/password_disallows_frequent_changes'
11
+ require 'devise/secure_password/models/password_requires_regular_updates'
12
+
13
+ module Devise
14
+ # password_content_enforcement configuration parameters
15
+ @password_required_uppercase_count = 1
16
+ @password_required_lowercase_count = 1
17
+ @password_required_number_count = 1
18
+ @password_required_special_count = 1
19
+
20
+ # password_frequent_reuse_prevention configuration parameters
21
+ @password_previously_used_count = 24
22
+
23
+ # password_frequent_change_prevention configuration parameters
24
+ @password_minimum_age = 1.day
25
+
26
+ # password_regular_update_enforcement configuration parameters
27
+ @password_maximum_age = 60.days
28
+
29
+ class << self
30
+ attr_accessor :password_required_uppercase_count
31
+ attr_accessor :password_required_lowercase_count
32
+ attr_accessor :password_required_number_count
33
+ attr_accessor :password_required_special_count
34
+ attr_accessor :password_previously_used_count
35
+ attr_accessor :password_minimum_age
36
+ attr_accessor :password_maximum_age
37
+ end
38
+
39
+ module SecurePassword
40
+ module Controllers
41
+ autoload :DeviseHelpers, 'devise/secure_password/controllers/devise_helpers'
42
+ autoload :ActiveHelpers, 'devise/secure_password/controllers/active_helpers'
43
+ end
44
+
45
+ class Engine < ::Rails::Engine
46
+ ActiveSupport.on_load(:devise_controller) do
47
+ include ActionView::Helpers::DateHelper
48
+ include Devise::SecurePassword::Controllers::DeviseHelpers
49
+ end
50
+ ActiveSupport.on_load(:action_controller) do
51
+ include ActionView::Helpers::DateHelper
52
+ include Devise::SecurePassword::Controllers::ActiveHelpers
53
+ end
54
+
55
+ # add exceptions to the inflector so it doesn't get tripped up by our concerns that end in an 's'
56
+ ActiveSupport::Inflector.inflections do |inflect|
57
+ inflect.uncountable :password_disallows_frequent_changes
58
+ inflect.uncountable :password_requires_regular_updates
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ # modules
65
+ Devise.add_module :password_has_required_content, model: 'devise/secure_password/models/password_has_required_content'
66
+ Devise.add_module :password_disallows_frequent_reuse, model: 'devise/secure_password/models/password_disallows_frequent_reuse'
67
+ Devise.add_module :password_disallows_frequent_changes, model: 'devise/secure_password/models/password_disallows_frequent_changes'
68
+ Devise.add_module :password_requires_regular_updates,
69
+ model: 'devise/secure_password/models/password_requires_regular_updates',
70
+ controller: :passwords_with_policy, route: :passwords_with_policy
@@ -0,0 +1,40 @@
1
+ module Devise
2
+ module SecurePassword
3
+ module Controllers
4
+ module ActiveHelpers
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ before_action :pending_password_expired_redirect!, except: [:destroy]
9
+ end
10
+
11
+ # Redirect to password change page if password needs to be changed.
12
+ def pending_password_expired_redirect!
13
+ return unless skip_current_controller? && redirected_in_session? && warden.session && warden.session['secure_password_expired']
14
+ redirect_to edit_user_password_with_policy_url, alert: "#{error_string_for_password_expired}."
15
+ end
16
+
17
+ def redirected_in_session?
18
+ warden.authenticated? && warden.session['secure_password_last_controller'] == 'Devise::SessionsController'
19
+ end
20
+
21
+ def skip_current_controller?
22
+ exclusion_list = [
23
+ 'Devise::SessionsController',
24
+ 'Devise::PasswordsWithPolicyController#edit',
25
+ 'Devise::PasswordsWithPolicyController#update'
26
+ ]
27
+ exclusion_list.select { |e| e == "#{self.class.name}#" + action_name || e == self.class.name.to_s }.empty?
28
+ end
29
+
30
+ def error_string_for_password_expired
31
+ return 'password expired' unless warden.user.class.respond_to?(:password_maximum_age)
32
+ I18n.t(
33
+ 'secure_password.password_requires_regular_updates.errors.messages.password_expired',
34
+ timeframe: distance_of_time_in_words(warden.user.class.password_maximum_age)
35
+ )
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ module Devise
2
+ module SecurePassword
3
+ module Controllers
4
+ module DeviseHelpers
5
+ extend ActiveSupport::Concern
6
+
7
+ # rubocop:disable Style/ClassAndModuleChildren
8
+ class ::DeviseController
9
+ alias old_require_no_authentication require_no_authentication
10
+
11
+ protected
12
+
13
+ # Override the devise require_no_authentication before callback so users
14
+ # have to prevent authenticated users with expired passwords from
15
+ # escaping to other pages without first updating their passwords.
16
+ def require_no_authentication
17
+ return if check_password_expired_and_redirect!
18
+
19
+ old_require_no_authentication
20
+ end
21
+
22
+ # Store the name of the current controller and action in the warden
23
+ # session store then redirect if signed in and password expired. The
24
+ # stored values will be used by non-devise controllers to prevent a
25
+ # user from escaping the change password process.
26
+ def check_password_expired_and_redirect!
27
+ assert_is_devise_resource!
28
+
29
+ return if skip_current_devise_controller?
30
+
31
+ if signed_in?(scope_name) && warden.session(scope_name)[:secure_password_expired]
32
+ save_controller_state
33
+ redirect_to edit_user_password_with_policy_url, alert: "#{error_string_for_password_expired}."
34
+ return true
35
+ end
36
+
37
+ false
38
+ end
39
+
40
+ def save_controller_state
41
+ warden.session(scope_name)[:secure_last_controller] = self.class.name
42
+ warden.session(scope_name)[:secure_last_action] = action_name
43
+ end
44
+
45
+ def skip_current_devise_controller?
46
+ exclusion_list = [
47
+ 'Devise::SessionsController'
48
+ ]
49
+ exclusion_list.select { |e| e == "#{self.class.name}#" + action_name || e == self.class.name.to_s }.empty?
50
+ end
51
+
52
+ def error_string_for_password_expired
53
+ class_obj = scope_name.to_s.camelize.constantize
54
+ I18n.t(
55
+ 'secure_password.password_requires_regular_updates.errors.messages.password_expired',
56
+ timeframe: distance_of_time_in_words(class_obj.password_maximum_age)
57
+ )
58
+ end
59
+ end
60
+ # rubocop:enable Style/ClassAndModuleChildren
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ Warden::Manager.after_authentication do |user, warden, options|
2
+ if user.respond_to?(:password_expired?)
3
+ warden.session(options[:scope])[:secure_password_expired] = user.password_expired?
4
+ end
5
+ end
@@ -0,0 +1,60 @@
1
+ module Devise
2
+ module Models
3
+ module PasswordDisallowsFrequentChanges
4
+ extend ActiveSupport::Concern
5
+
6
+ class ConfigurationError < RuntimeError; end
7
+
8
+ included do
9
+ include ActionView::Helpers::DateHelper
10
+ validate :validate_password_frequent_change
11
+
12
+ set_callback(:initialize, :before, :before_resource_initialized)
13
+ set_callback(:initialize, :after, :after_resource_initialized)
14
+ end
15
+
16
+ def validate_password_frequent_change
17
+ if encrypted_password_changed? && password_recent?
18
+ error_string = I18n.t(
19
+ 'secure_password.password_disallows_frequent_changes.errors.messages.password_is_recent',
20
+ timeframe: distance_of_time_in_words(self.class.password_minimum_age)
21
+ )
22
+ errors.add(:base, error_string)
23
+ end
24
+
25
+ errors.count.zero?
26
+ end
27
+
28
+ def password_recent?
29
+ last_password = previous_passwords.unscoped.last
30
+ last_password&.fresh?(self.class.password_minimum_age)
31
+ end
32
+
33
+ protected
34
+
35
+ def before_resource_initialized
36
+ return if self.class.respond_to?(:password_previously_used_count)
37
+
38
+ raise ConfigurationError, <<-ERROR.strip_heredoc
39
+
40
+ The password_disallows_frequent_changes module depends on the
41
+ password_disallows_frequent_reuse module. Verify that you have
42
+ added both modules to your model, for example:
43
+
44
+ devise :database_authenticatable, :registerable,
45
+ :password_disallows_frequent_reuse,
46
+ :password_disallows_frequent_changes
47
+ ERROR
48
+ end
49
+
50
+ def after_resource_initialized
51
+ raise ConfigurationError, 'invalid type for password_minimum_age' \
52
+ unless self.class.password_minimum_age.is_a?(::ActiveSupport::Duration)
53
+ end
54
+
55
+ module ClassMethods
56
+ ::Devise::Models.config(self, :password_minimum_age)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,71 @@
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
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
+ if Rails.version > '5.1'
57
+ saved_change_to_encrypted_password?
58
+ else
59
+ encrypted_password_changed?
60
+ end
61
+ end
62
+
63
+ module ClassMethods
64
+ config_params = %i(
65
+ password_previously_used_count
66
+ )
67
+ ::Devise::Models.config(self, *config_params)
68
+ end
69
+ end
70
+ end
71
+ end