devise-secure_password 1.0.0

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