Devise Secure Password Extension

License

The Devise Secure Password Extension is a user account password policy enforcement gem that can be added to a Rails project to enforce password policies. The gem is implemented as an extension to the Rails devise authentication solution gem and requires that devise is installed as well.

Overview

The Devise Secure Password Extension is composed of the following modules:

  • password_has_required_content: require that passwords consist of a specific number (configurable) of letters, numbers, and special characters (symbols)
  • password_disallows_frequent_reuse: prevent the reuse of a number (configurable) of previous passwords when a user changes their password
  • password_disallows_frequent_changes: prevent the user from changing their password more than once within a time duration (configurable)
  • password_requires_regular_updates: require that a user change their password following a time duration (configurable)

Compatibility

The goal of this project is to provide compatibility for officially supported stable releases of Ruby and Ruby on Rails. More specifically, the following releases are currently supported by the Devise Secure Password Extension:

  • Ruby on Rails: 6.1.x, 7.0.x
  • Ruby: 3.1.x, 3.2.x, 3.3.x

Updating to a New Rails Version

This gem uses so-called "dummy" apps in the specs to verify compatibility with a major/minor version of Rails. Adding a new major/minor version of Rails requires us to add a new "dummy" app in the spec folder, and a corresponding Gemfile in the gemfiles directory. While manual, this process is relatively straightforward:

  1. Create a new Rails app in the directory spec/rails_<major>_<minor> by using the Rails generator for that version, ensuring you skip Git setup. (e.g. cd spec; rails _6.0.3.6_ new rails-app-6_0 --skip-git)
  2. Move the Gemfile from the newly created app to the gemfiles directory and rename it with the major/minor version (e.g. mv spec/rails_6_1/Gemfile gemfiles/rails_6_1.gemfile)
  3. Update the Gemfile to include the Rails target and gemspec immediately beneath the source declarations, like this:
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ENV['RAILS_TARGET'] ||= '6.1'

gemspec path: '../'
  1. Add gem 'shoulda-matchers' under the test group in the new Gemfile
  2. Ensure you can bundle by running bundle with the BUNDLE_GEMFILE variable set to the new Gemfile (i.e. BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle). This should run successfully - fix as needed.
  3. Copy the file config/initializers/devise.rb from an existing "dummy" app to the same location in the new app.
  4. Copy the file config/routes.rb from an existing "dummy" app to the same location in the new app.
  5. Copy the contents of the db/migrate directory from an existing "dummy" app to the same location in the new app. Copy the db/schema.rb and db/test.sqlite3 as well
  6. Copy the app/controllers/static_pages_controller.rb from an existing "dummy" app to the same location in the new app.
  7. Copy the app/models/isolated directory and the app/models/user.rb file from an existing "dummy" app to the same location in the new app.
  8. Copy the app/views/static_pages directory from an existing "dummy" app to the same location in the new app.
  9. Update the app/views/layouts/application.html.erb in the new app to have the same <body> content and <title> as the same file in an existing "dummy" app.
  10. At this point you should be able to run specs. (i.e. BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle exec rake). Run specs and fix version specific issues, taking care to maintain backwards compatibility with supported versions.
  11. You should also run Rubocop (i.e. BUNDLE_GEMFILE=gemfiles/rails_6_1.gemfile bundle exec rubocop) and fix whatever issues are reported (again, maintaining backwards compatibility)
  12. In the .circleci/config.yml file update the current_rails_gemfile and previous_rails_gemfile to reference the new version and the previous version of Rails to be supported
  13. Delete any files for old Rails versions that are no longer supported - "dummy" apps and the corresponding gemfiles Gemfile.
  14. Update the Circle CI badge label in this README to reflect the newly supported Rails version.

Installation

Add this line to your application's Gemfile:

gem 'devise',                 '~> 4.8'
gem 'devise-secure_password', '~> 2.0'

And then execute:

prompt> bundle

Or install it yourself as:

prompt> gem install devise-secure_password

Finally, run the generator:

prompt> rails generate devise:secure_password:install

Usage

Configuration

The Devise Secure Password Extension exposes configuration parameters as outlined below. Commented out configuration parameters reflect the default settings.

Devise.setup do |config|
  # ==> Configuration for the Devise Secure Password extension
  #     Module: password_has_required_content
  #
  # Configure password content requirements including the number of uppercase,
  # lowercase, number, and special characters that are required. To configure the
  # minimum and maximum length refer to the Devise config.password_length
  # standard configuration parameter.

  # The number of uppercase letters (latin A-Z) required in a password:
  # config.password_required_uppercase_count = 1

  # The number of lowercase letters (latin A-Z) required in a password:
  # config.password_required_lowercase_count = 1

  # The number of numbers (0-9) required in a password:
  # config.password_required_number_count = 1

  # The number of special characters ( !@#$%^&*()_+-=[]{}|'"/\.,`<>:;?~) required in a password:
  # config.password_required_special_character_count = 1

  # ==> Configuration for the Devise Secure Password extension
  #     Module: password_disallows_frequent_reuse
  #
  # The number of previously used passwords that can not be reused:
  # config.password_previously_used_count = 8

  # ==> Configuration for the Devise Secure Password extension
  #     Module: password_disallows_frequent_changes
  #     *Requires* password_disallows_frequent_reuse
  #
  # The minimum time that must pass between password changes:
  # config.password_minimum_age = 1.days

  # ==> Configuration for the Devise Secure Password extension
  #     Module: password_requires_regular_updates
  #     *Requires* password_disallows_frequent_reuse
  #
  # The maximum allowed age of a password:
  # config.password_maximum_age = 180.days
end

NOTE: Password policy defaults have been selected as a middle-of-the-road combination based on published recommendations by Microsoft and Carnegie Mellon University. It is up to YOU to verify the default settings and make adjustments where necessary.

Enable the Devise Secure Password Extension enforcement in your Devise model(s):

devise :password_has_required_content, :password_disallows_frequent_reuse,
       :password_disallows_frequent_changes, :password_requires_regular_updates

Usually, you would append these after your selection of Devise modules. So your configuration will more likely look like the following:

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable,
         :password_has_required_content, :password_disallows_frequent_reuse,
         :password_disallows_frequent_changes, :password_requires_regular_updates
   ...
   <YOUR USER MODEL CONTENT FOLLOWS>
end

NOTE: Both :password_disallows_frequent_changes and :password_requires_regular_updates are dependent upon the previous passwords memorization implemented by the :password_disallows_frequent_reuse module.

Database migration

The following database migration needs to be applied:

prompt> rails generate migration create_previous_passwords salt:string encrypted_password:string user:references

Edit the resulting file to disallow null values for the hash,add indexes for both hash and user_id fields, and to also add the timestamp (created_at, updated_at) fields:

class CreatePreviousPasswords < ActiveRecord::Migration[5.1]
  def change
    create_table :previous_passwords do |t|
      t.string :salt, null: false
      t.string :encrypted_password, null: false
      t.references :user, foreign_key: true

      t.timestamps
    end

    add_index :previous_passwords, :encrypted_password
    add_index :previous_passwords, [:user_id, :created_at]
  end
end

And then:

prompt> bundle exec rake db:migrate

Displaying errors

You will likely want to display errors, produced as a result of secure password enforcement violations, to your users. Errors are available via the User.errors array and via the devise_error_messages! method. An example usage follows and is taken from the default password edit.html.erb page:

<%= form_for(resource, as: resource_name, url: [resource_name, :password_with_policy], html: { method: :put }) do |f| %>
  <% if resource.errors.full_messages.count.positive? %>
    <%= devise_error_messages! %>
  <% end %>

  <p><%= f.label :current_password, 'Current password' %><br />
  <%= f.password_field :current_password %></p>

  <p><%= f.label :password, 'New password' %><br />
  <%= f.password_field :password %></p>

  <p><%= f.label :password_confirmation, 'Password confirmation' %><br />
  <%= f.password_field :password_confirmation %></p>

  <p><%= f.submit 'Update' %></p>
<% end %>

Running Tests

This document assumes that you already have a functioning ruby install.

Default Rails target

The Devise Secure Password Extension provides compatibility for officially supported stable releases of Ruby on Rails. To configure and test the default target (the most-recent supported Rails release):

prompt> bundle
prompt> bundle exec rake

Selecting an alternate Rails target

To determine the Ruby on Rails versions supported by this release, run the following commands:

prompt> gem install flay ruby2ruby rubocop rspec
prompt> rake test:spec:targets

Available Rails targets: 7.0, 6.1

Reconfigure the project by specifying the correct Gemfile when running bundler, followed by running tests:

prompt> BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle
prompt> BUNDLE_GEMFILE=gemfiles/rails_7_0.gemfile bundle exec rake

The only time you need to define the BUNDLE_GEMFILE environment variable is when testing a non-default target.

Testing with code coverage (SimpleCov)

SimpleCov tests are enabled by defining the test:spec:coverage rake task:

prompt> bundle exec rake test:spec:coverage

A brief summary will be output at the end of the run but a more extensive eport will be saved in the coverage directory (under the top-level project directory).

Testing with headless Chrome

You will need to install the ChromeDriver >= v2.3.4 for testing.

prompt> brew install chromedriver

You can always install ChromeDriver by downloading and then unpacking into the /usr/local/bin directory.

Automated screenshots on failure

The capybara-screenshot gem supports automated screenshot captures on failing tests but this will only take place for tests that have JavaScript enabled. You can temporarily modify an example by setting js: true as in the following example:

context 'when minimum age enforcement is enabled', js: true do
...
end

Do not submit pull requests with this setting enabled where it wasn't enabled previously.

Testing inside the spec/rails-app-X_y_z

To debug from inside of the dummy rails-app you will need to first install the rails bin stubs and then perform a db migration:

prompt> cd spec/rails-app-X_y_z
prompt> rake app:update:bin
prompt> RAILS_ENV=development bundle exec rake db:migrate

Remember, the dummy app is not meant to be a full featured rails app: there is just enough functionality to test the gem feature set.

Running benchmarks

Available benchmarks can be run as follows:

prompt> bundle exec rake test:benchmark

Benchmarks are run within an RSpec context but are not run along with other tests as benchmarks merely seek to measure performance and not enforce set performance targets.

Screenshots

Failing tests that invoke the JavaScript driver will result in both the failing html along with a screenshot of the page output to be saved in the spec/rails-app-X_y_z/tmp/capybara snapshot directory.

NOTE: On circleci the snapshots will be captured as artifacts.

The snapshot directory will be pruned automatically between runs.

Docker

This repository includes a Dockerfile to facilitate testing in and using Docker.

To start the container simply build and launch the image:

prompt> docker build -t secure-password-dev .
prompt> docker run -it --rm secure-password-dev /bin/bash

The above docker run command will start the container, connect you to the command line within the project home directory where you can issue the tests as documented in the Running Tests section above. When you exit the shell, the container will be removed.

Running tests in a Docker container

The Docker container is derived from the latest circleci/ruby image. It is critical that you update the bundler inside of the Docker image as the circleci user (i.e. the default user) before initiating any development work including tests.

prompt> gem update bundler

Updating test.sqlite3.db

To update or generate a db/test/sqlite3.db database file:

prompt> cd spec/rails-app-X_y_z
prompt> bundle install
prompt> rake app:update:bin
prompt> RAILS_ENV=test bundle exec rake db:migrate

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/valimail/devise-secure_password. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

Basic guidelines for contributors

1 Fork it

2 Create your feature branch (git checkout -b my-new-feature)

3 Commit your changes (git commit -am 'Add some feature')

4 Push to the branch (git push origin my-new-feature)

5 Create new Pull Request

NOTE: Contributions should always be based on the master branch. You may be asked to rebase your contributions on the tip of the master branch, this is normal and is to be expected if the master branch has moved ahead since your pull request was opened, discussed, and accepted.

License

The Devise Secure Password Extension gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Devise Secure Password Extension project’s codebases and issue trackers is expected to follow the code of conduct.