├── test ├── dummy │ ├── app │ │ ├── models │ │ │ ├── .gitkeep │ │ │ ├── secure_user.rb │ │ │ ├── captcha_user.rb │ │ │ ├── user.rb │ │ │ └── security_question_user.rb │ │ ├── views │ │ │ └── foos │ │ │ │ └── index.html.erb │ │ └── controllers │ │ │ ├── foos_controller.rb │ │ │ ├── application_controller.rb │ │ │ ├── captcha │ │ │ └── sessions_controller.rb │ │ │ └── security_question │ │ │ └── unlocks_controller.rb │ ├── config │ │ ├── secrets.yml │ │ ├── database.yml │ │ ├── environment.rb │ │ ├── initializers │ │ │ ├── migration_class.rb │ │ │ └── devise.rb │ │ ├── boot.rb │ │ ├── routes.rb │ │ ├── application.rb │ │ └── environments │ │ │ └── test.rb │ ├── config.ru │ ├── Rakefile │ └── db │ │ └── migrate │ │ ├── 20150407162345_add_verification_attempt_column.rb │ │ ├── 20160320162345_add_security_questions_fields.rb │ │ ├── 20150402165590_add_verification_columns.rb │ │ └── 20120508165529_create_tables.rb ├── test_helper.rb ├── test_install_generator.rb ├── test_password_expired_controller.rb ├── test_password_expirable.rb ├── test_captcha_controller.rb ├── test_password_archivable.rb ├── test_security_question_controller.rb ├── test_secure_validatable.rb └── test_paranoid_verification.rb ├── Gemfile ├── .document ├── lib ├── devise_security_extension │ ├── version.rb │ ├── models │ │ ├── old_password.rb │ │ ├── security_questionable.rb │ │ ├── session_limitable.rb │ │ ├── database_authenticatable_patch.rb │ │ ├── paranoid_verification.rb │ │ ├── password_expirable.rb │ │ ├── password_archivable.rb │ │ ├── secure_validatable.rb │ │ └── expirable.rb │ ├── hooks │ │ ├── password_expirable.rb │ │ ├── paranoid_verification.rb │ │ ├── expirable.rb │ │ └── session_limitable.rb │ ├── rails.rb │ ├── patches │ │ ├── controller_captcha.rb │ │ ├── controller_security_question.rb │ │ ├── unlocks_controller_captcha.rb │ │ ├── passwords_controller_captcha.rb │ │ ├── confirmations_controller_captcha.rb │ │ ├── sessions_controller_captcha.rb │ │ ├── unlocks_controller_security_question.rb │ │ ├── passwords_controller_security_question.rb │ │ ├── confirmations_controller_security_question.rb │ │ └── registrations_controller_captcha.rb │ ├── orm │ │ └── active_record.rb │ ├── routes.rb │ ├── patches.rb │ ├── schema.rb │ └── controllers │ │ └── helpers.rb ├── generators │ ├── devise_security_extension │ │ └── install_generator.rb │ └── templates │ │ └── devise_security_extension.rb └── devise_security_extension.rb ├── .travis.yml ├── config └── locales │ ├── it.yml │ ├── en.yml │ ├── de.yml │ └── es.yml ├── app ├── views │ └── devise │ │ ├── paranoid_verification_code │ │ └── show.html.erb │ │ └── password_expired │ │ └── show.html.erb └── controllers │ └── devise │ ├── paranoid_verification_code_controller.rb │ └── password_expired_controller.rb ├── Rakefile ├── .gitignore ├── LICENSE.txt ├── .rubocop.yml ├── devise_security_extension.gemspec ├── Gemfile.lock └── README.md /test/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/views/foos/index.html.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/foos_controller.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /test/dummy/config/secrets.yml: -------------------------------------------------------------------------------- 1 | test: 2 | secret_token: 'fooooooooooo' 3 | secret_key_base: 'fuuuuuuuuuuu' -------------------------------------------------------------------------------- /lib/devise_security_extension/version.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | VERSION = "0.10.0".freeze 3 | end -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: sqlite3 3 | database: ":memory:" 4 | 5 | test: 6 | adapter: sqlite3 7 | database: ":memory:" -------------------------------------------------------------------------------- /test/dummy/app/models/secure_user.rb: -------------------------------------------------------------------------------- 1 | class SecureUser < ActiveRecord::Base 2 | devise :database_authenticatable, :secure_validatable, email_validation: false 3 | end 4 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/old_password.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | class OldPassword < ActiveRecord::Base 3 | belongs_to :password_archivable, :polymorphic => true 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/captcha/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class Captcha::SessionsController < Devise::SessionsController 2 | include DeviseSecurityExtension::Patches::ControllerCaptcha 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run RailsApp::Application 5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the rails application 5 | RailsApp::Application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/migration_class.rb: -------------------------------------------------------------------------------- 1 | MIGRATION_CLASS = 2 | if ActiveRecord::VERSION::MAJOR >= 5 3 | ActiveRecord::Migration[4.2] 4 | else 5 | ActiveRecord::Migration 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/security_question/unlocks_controller.rb: -------------------------------------------------------------------------------- 1 | class SecurityQuestion::UnlocksController < Devise::UnlocksController 2 | include DeviseSecurityExtension::Patches::ControllerSecurityQuestion 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/captcha_user.rb: -------------------------------------------------------------------------------- 1 | class CaptchaUser < ActiveRecord::Base 2 | self.table_name = "users" 3 | devise :database_authenticatable, :password_archivable, 4 | :paranoid_verification, :password_expirable 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | devise :database_authenticatable, :password_archivable, :lockable, 3 | :paranoid_verification, :password_expirable, 4 | :security_questionable 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 5 | 6 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 7 | -------------------------------------------------------------------------------- /lib/devise_security_extension/hooks/password_expirable.rb: -------------------------------------------------------------------------------- 1 | Warden::Manager.after_authentication do |record, warden, options| 2 | if record.respond_to?(:need_change_password?) 3 | warden.session(options[:scope])['password_expired'] = record.need_change_password? 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /lib/devise_security_extension/hooks/paranoid_verification.rb: -------------------------------------------------------------------------------- 1 | Warden::Manager.after_set_user do |record, warden, options| 2 | if record.respond_to?(:need_paranoid_verification?) 3 | warden.session(options[:scope])['paranoid_verify'] = record.need_paranoid_verification? 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/security_question_user.rb: -------------------------------------------------------------------------------- 1 | class SecurityQuestionUser < ActiveRecord::Base 2 | self.table_name = "users" 3 | devise :database_authenticatable, :password_archivable, :lockable, 4 | :paranoid_verification, :password_expirable, 5 | :security_questionable 6 | end 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | before_install: gem install bundler && bundler -v 3 | install: bundle install --jobs=3 --retry=3 4 | before_script: bundle install 5 | script: bundle exec rake 6 | rvm: 7 | - 2.1.8 8 | - 2.2.4 9 | - ruby-head 10 | matrix: 11 | allow_failures: 12 | - rvm: ruby-head 13 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20150407162345_add_verification_attempt_column.rb: -------------------------------------------------------------------------------- 1 | class AddVerificationAttemptColumn < MIGRATION_CLASS 2 | def self.up 3 | add_column :users, :paranoid_verification_attempt, :integer, default: 0 4 | end 5 | 6 | def self.down 7 | remove_column :users, :paranoid_verification_attempt 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20160320162345_add_security_questions_fields.rb: -------------------------------------------------------------------------------- 1 | class AddSecurityQuestionsFields < MIGRATION_CLASS 2 | def change 3 | add_column :users, :locked_at, :datetime 4 | add_column :users, :unlock_token, :string 5 | add_column :users, :security_question_id, :integer 6 | add_column :users, :security_question_answer, :string 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/devise.rb: -------------------------------------------------------------------------------- 1 | Devise.setup do |config| 2 | config.mailer_sender = 'please-change-me-at-config-initializers-devise@example.com' 3 | 4 | require 'devise/orm/active_record' 5 | config.secret_key = 'f08cf11a38906f531d2dfc9a2c2d671aa0021be806c21255d4' 6 | config.case_insensitive_keys = [:email] 7 | 8 | config.strip_whitespace_keys = [:email] 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | RailsApp::Application.routes.draw do 2 | devise_for :users 3 | 4 | devise_for :captcha_users, only: [:sessions], controllers: { sessions: "captcha/sessions" } 5 | devise_for :security_question_users, only: [:sessions, :unlocks], controllers: { unlocks: "security_question/unlocks" } 6 | 7 | resources :foos 8 | 9 | root to: 'foos#index' 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20150402165590_add_verification_columns.rb: -------------------------------------------------------------------------------- 1 | class AddVerificationColumns < MIGRATION_CLASS 2 | def self.up 3 | add_column :users, :paranoid_verification_code, :string 4 | add_column :users, :paranoid_verified_at, :datetime 5 | end 6 | 7 | def self.down 8 | remove_column :users, :paranoid_verification_code 9 | remove_column :users, :paranoid_verified_at 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | 3 | require 'coveralls' 4 | Coveralls.wear! 5 | 6 | require 'dummy/config/environment' 7 | require 'minitest/autorun' 8 | require 'rails/test_help' 9 | require 'devise_security_extension' 10 | 11 | ActiveRecord::Migration.verbose = false 12 | ActiveRecord::Base.logger = Logger.new(nil) 13 | ActiveRecord::Migrator.migrate(File.expand_path('../dummy/db/migrate', __FILE__)) 14 | -------------------------------------------------------------------------------- /config/locales/it.yml: -------------------------------------------------------------------------------- 1 | it: 2 | errors: 3 | messages: 4 | taken_in_past: "e' stata gia' utilizzata in passato!" 5 | equal_to_current_password: " deve essere differente dalla password corrente!" 6 | devise: 7 | invalid_captcha: "Il captcha inserito non e' valido!" 8 | password_expired: 9 | updated: "La tua nuova password e' stata salvata." 10 | change_required: "La tua password e' scaduta. Si prega di rinnovarla!" -------------------------------------------------------------------------------- /app/views/devise/paranoid_verification_code/show.html.erb: -------------------------------------------------------------------------------- 1 |

Submit verification code

2 | 3 | <%= form_for(resource, :as => resource_name, :url => [resource_name, :paranoid_verification_code], :html => { :method => :put }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |

<%= f.label :paranoid_verification_code, 'Verification code' %>
7 | <%= f.text_field :paranoid_verification_code, value: '' %>

8 | 9 |

<%= f.submit "Submit" %>

10 | <% end %> 11 | -------------------------------------------------------------------------------- /lib/devise_security_extension/rails.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | class Engine < ::Rails::Engine 3 | ActiveSupport.on_load(:action_controller) do 4 | include DeviseSecurityExtension::Controllers::Helpers 5 | end 6 | 7 | if Rails.version > "5" 8 | ActiveSupport::Reloader.to_prepare do 9 | DeviseSecurityExtension::Patches.apply 10 | end 11 | else 12 | ActionDispatch::Callbacks.to_prepare do 13 | DeviseSecurityExtension::Patches.apply 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/controller_captcha.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module ControllerCaptcha 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | prepend_before_action :check_captcha, only: [:create] 7 | end 8 | 9 | private 10 | def check_captcha 11 | return if ((defined? verify_recaptcha) && (verify_recaptcha)) || ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 12 | 13 | flash[:alert] = t('devise.invalid_captcha') if is_navigational_format? 14 | respond_with({}, location: url_for(action: :new)) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/devise_security_extension/hooks/expirable.rb: -------------------------------------------------------------------------------- 1 | # Updates the last_activity_at fields from the record. Only when the user is active 2 | # for authentication and authenticated. 3 | # An expiry of the account is only checked on sign in OR on manually setting the 4 | # expired_at to the past (see Devise::Models::Expirable for this) 5 | Warden::Manager.after_set_user do |record, warden, options| 6 | if record && record.respond_to?(:active_for_authentication?) && record.active_for_authentication? && 7 | warden.authenticated?(options[:scope]) && record.respond_to?(:update_last_activity!) 8 | record.update_last_activity! 9 | end 10 | end -------------------------------------------------------------------------------- /lib/devise_security_extension/orm/active_record.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | module Orm 3 | # This module contains some helpers and handle schema (migrations): 4 | # 5 | # create_table :accounts do |t| 6 | # t.password_expirable 7 | # end 8 | # 9 | module ActiveRecord 10 | module Schema 11 | include DeviseSecurityExtension::Schema 12 | 13 | 14 | end 15 | end 16 | end 17 | end 18 | 19 | ActiveRecord::ConnectionAdapters::Table.send :include, DeviseSecurityExtension::Orm::ActiveRecord::Schema 20 | ActiveRecord::ConnectionAdapters::TableDefinition.send :include, DeviseSecurityExtension::Orm::ActiveRecord::Schema -------------------------------------------------------------------------------- /app/views/devise/password_expired/show.html.erb: -------------------------------------------------------------------------------- 1 |

Renew your password

2 | 3 | <%= form_for(resource, :as => resource_name, :url => [resource_name, :password_expired], :html => { :method => :put }) do |f| %> 4 | <%= devise_error_messages! %> 5 | 6 |

<%= f.label :current_password, "Current password" %>
7 | <%= f.password_field :current_password %>

8 | 9 |

<%= f.label :password, "New password" %>
10 | <%= f.password_field :password %>

11 | 12 |

<%= f.label :password_confirmation, "Confirm new password" %>
13 | <%= f.password_field :password_confirmation %>

14 | 15 |

<%= f.submit "Change my password" %>

16 | <% end %> 17 | -------------------------------------------------------------------------------- /test/test_install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rails/generators/test_case' 3 | require 'generators/devise_security_extension/install_generator' 4 | 5 | class TestInstallGenerator < Rails::Generators::TestCase 6 | tests DeviseSecurityExtension::Generators::InstallGenerator 7 | destination File.expand_path('../tmp', __FILE__) 8 | setup :prepare_destination 9 | 10 | test 'Assert all files are properly created' do 11 | run_generator 12 | assert_file 'config/initializers/devise_security_extension.rb' 13 | assert_file 'config/locales/devise.security_extension.en.yml' 14 | assert_file 'config/locales/devise.security_extension.de.yml' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/devise_security_extension/routes.rb: -------------------------------------------------------------------------------- 1 | module ActionDispatch::Routing 2 | class Mapper 3 | 4 | protected 5 | 6 | # route for handle expired passwords 7 | def devise_password_expired(mapping, controllers) 8 | resource :password_expired, :only => [:show, :update], :path => mapping.path_names[:password_expired], :controller => controllers[:password_expired] 9 | end 10 | 11 | # route for handle paranoid verification 12 | def devise_verification_code(mapping, controllers) 13 | resource :paranoid_verification_code, :only => [:show, :update], :path => mapping.path_names[:verification_code], :controller => controllers[:paranoid_verification_code] 14 | end 15 | end 16 | end 17 | 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | $:.unshift File.join(File.dirname(__FILE__), 'lib') 2 | require 'rubygems' 3 | require 'bundler' 4 | require 'rake/testtask' 5 | require 'rdoc/task' 6 | require 'devise_security_extension/version' 7 | 8 | desc 'Default: Run DeviseSecurityExtension unit tests' 9 | task default: :test 10 | 11 | Rake::TestTask.new(:test) do |t| 12 | t.libs << 'lib' 13 | t.libs << 'test' 14 | t.test_files = FileList['test/*test*.rb'] 15 | t.verbose = true 16 | t.warning = false 17 | end 18 | 19 | Rake::RDocTask.new do |rdoc| 20 | version = DeviseSecurityExtension::VERSION.dup 21 | 22 | rdoc.rdoc_dir = 'rdoc' 23 | rdoc.title = "devise_security_extension #{version}" 24 | rdoc.rdoc_files.include('README*') 25 | rdoc.rdoc_files.include('lib/**/*.rb') 26 | end 27 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/security_questionable.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Models 3 | # SecurityQuestionable is an accessible add-on for visually handicapped people, 4 | # to ship around the captcha with screenreader compatibility. 5 | # 6 | # You need to add two text_field_tags to the associated forms (unlock, 7 | # password, confirmation): 8 | # :security_question_answer and :captcha 9 | # 10 | # And add the security_question to the register/edit form. 11 | # f.select :security_question_id, SecurityQuestion.where(locale: I18n.locale).map{|s| [s.name, s.id]} 12 | # f.text_field :security_question_answer 13 | module SecurityQuestionable 14 | extend ActiveSupport::Concern 15 | 16 | end 17 | end 18 | end -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | require 'devise_security_extension' 5 | 6 | if defined?(Bundler) 7 | # If you precompile assets before deploying to production, use this line 8 | Bundler.require(*Rails.groups(assets: %w[development test])) 9 | # If you want your assets lazily compiled in production, use this line 10 | # Bundler.require(:default, :assets, Rails.env) 11 | end 12 | 13 | module RailsApp 14 | class Application < Rails::Application 15 | config.encoding = 'utf-8' 16 | 17 | config.filter_parameters += [:password] 18 | 19 | config.assets.enabled = true 20 | 21 | config.assets.version = '1.0' 22 | config.secret_key_base = 'fuuuuuuuuuuu' 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/session_limitable.rb: -------------------------------------------------------------------------------- 1 | require 'devise_security_extension/hooks/session_limitable' 2 | 3 | module Devise 4 | module Models 5 | # SessionLimited ensures, that there is only one session usable per account at once. 6 | # If someone logs in, and some other is logging in with the same credentials, 7 | # the session from the first one is invalidated and not usable anymore. 8 | # The first one is redirected to the sign page with a message, telling that 9 | # someone used his credentials to sign in. 10 | module SessionLimitable 11 | extend ActiveSupport::Concern 12 | 13 | def update_unique_session_id!(unique_session_id) 14 | self.unique_session_id = unique_session_id 15 | 16 | save(:validate => false) 17 | end 18 | 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/controller_security_question.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module ControllerSecurityQuestion 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | prepend_before_action :check_security_question, only: [:create] 7 | end 8 | 9 | private 10 | def check_security_question 11 | # only find via email, not login 12 | resource = resource_class.find_or_initialize_with_error_by(:email, params[resource_name][:email], :not_found) 13 | return if (resource.security_question_answer.present? && resource.security_question_answer == params[:security_question_answer]) 14 | 15 | flash[:alert] = t('devise.invalid_security_question') if is_navigational_format? 16 | respond_with({}, location: url_for(action: :new)) 17 | end 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /test/test_password_expired_controller.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Devise::PasswordExpiredControllerTest < ActionController::TestCase 4 | include Devise::Test::ControllerHelpers 5 | 6 | setup do 7 | @request.env["devise.mapping"] = Devise.mappings[:user] 8 | @user = User.create(username: 'hello', email: 'hello@path.travel', 9 | password: '1234', password_changed_at: 3.months.ago) 10 | 11 | sign_in(@user) 12 | end 13 | 14 | test 'should render show' do 15 | get :show 16 | assert_includes @response.body, 'Renew your password' 17 | end 18 | 19 | test 'shold update password' do 20 | put :update, { 21 | user: { 22 | current_password: '1234', 23 | password: '12345', 24 | password_confirmation: '12345' 25 | } 26 | } 27 | assert_redirected_to root_path 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/unlocks_controller_captcha.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module UnlocksControllerCaptcha 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do 6 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 7 | self.resource = resource_class.send_unlock_instructions(params[resource_name]) 8 | if successfully_sent?(resource) 9 | respond_with({}, :location => new_session_path(resource_name)) 10 | else 11 | respond_with(resource) 12 | end 13 | else 14 | flash[:alert] = t('devise.invalid_captcha') if is_navigational_format? 15 | respond_with({}, :location => new_unlock_path(resource_name)) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/devise_security_extension/install_generator.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | module Generators 3 | # Generator for Rails to create or append to a Devise initializer. 4 | class InstallGenerator < Rails::Generators::Base 5 | LOCALES = %w[ en de it ] 6 | 7 | source_root File.expand_path('../../templates', __FILE__) 8 | desc 'Install the devise security extension' 9 | 10 | def copy_initializer 11 | template('devise_security_extension.rb', 12 | 'config/initializers/devise_security_extension.rb', 13 | ) 14 | end 15 | 16 | def copy_locales 17 | LOCALES.each do |locale| 18 | copy_file( 19 | "../../../config/locales/#{locale}.yml", 20 | "config/locales/devise.security_extension.#{locale}.yml", 21 | ) 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test/rails_app/log/* 2 | test/rails_app/tmp/* 3 | *~ 4 | coverage/* 5 | *.sqlite3 6 | .bundle 7 | rdoc/* 8 | pkg 9 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 10 | # 11 | # * Create a file at ~/.gitignore 12 | # * Include files you want ignored 13 | # * Run: git config --global core.excludesfile ~/.gitignore 14 | # 15 | # After doing this, these files will be ignored in all your git projects, 16 | # saving you from having to 'pollute' every project you touch with them 17 | # 18 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 19 | # 20 | # For MacOS: 21 | # 22 | #.DS_Store 23 | # 24 | # For TextMate 25 | #*.tmproj 26 | #tmtags 27 | # 28 | # For emacs: 29 | #*~ 30 | #\#* 31 | #.\#* 32 | # 33 | # For vim: 34 | #*.swp 35 | 36 | log 37 | test/tmp/* 38 | *.gem 39 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | errors: 3 | messages: 4 | taken_in_past: "was used previously." 5 | equal_to_current_password: "must be different than the current password." 6 | password_format: "must contain big, small letters and digits" 7 | devise: 8 | invalid_captcha: "The captcha input was invalid." 9 | invalid_security_question: "The security question answer was invalid." 10 | paranoid_verify: 11 | code_required: "Please enter the code our support team provided" 12 | password_expired: 13 | updated: "Your new password is saved." 14 | change_required: "Your password is expired. Please renew your password." 15 | failure: 16 | session_limited: 'Your login credentials were used in another browser. Please sign in again to continue in this browser.' 17 | expired: 'Your account has expired due to inactivity. Please contact the site administrator.' 18 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/passwords_controller_captcha.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module PasswordsControllerCaptcha 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do 6 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 7 | self.resource = resource_class.send_reset_password_instructions(params[resource_name]) 8 | if successfully_sent?(resource) 9 | respond_with({}, :location => new_session_path(resource_name)) 10 | else 11 | respond_with(resource) 12 | end 13 | else 14 | flash[:alert] = t('devise.invalid_captcha') if is_navigational_format? 15 | respond_with({}, :location => new_password_path(resource_name)) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /config/locales/de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | errors: 3 | messages: 4 | taken_in_past: "wurde bereits in der Vergangenheit verwendet!" 5 | equal_to_current_password: "darf nicht dem aktuellen Passwort entsprechen!" 6 | password_format: "müssen große, kleine Buchstaben und Ziffern enthalten" 7 | devise: 8 | invalid_captcha: "Die Captchaeingabe ist nicht gültig!" 9 | paranoid_verify: 10 | code_required: "Bitte geben Sie den Code unser Support-Team zur Verfügung gestellt" 11 | password_expired: 12 | updated: "Das neue Passwort wurde übernommen." 13 | change_required: "Ihr Passwort ist abgelaufen. Bitte vergeben sie ein neues Passwort!" 14 | failure: 15 | session_limited: 'Ihre Anmeldedaten wurden in einem anderen Browser genutzt. Bitte melden Sie sich erneut an, um in diesem Browser fortzufahren.' 16 | expired: 'Ihr Account ist aufgrund zu langer Inaktiviät abgelaufen. Bitte kontaktieren Sie den Administrator.' 17 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/confirmations_controller_captcha.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module ConfirmationsControllerCaptcha 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do 6 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 7 | self.resource = resource_class.send_confirmation_instructions(params[resource_name]) 8 | 9 | if successfully_sent?(resource) 10 | respond_with({}, :location => after_resending_confirmation_instructions_path_for(resource_name)) 11 | else 12 | respond_with(resource) 13 | end 14 | else 15 | flash[:alert] = t('devise.invalid_captcha') if is_navigational_format? 16 | respond_with({}, :location => new_confirmation_path(resource_name)) 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | RailsApp::Application.configure do 2 | config.cache_classes = true 3 | config.eager_load = false 4 | 5 | if Rails.version > "5" 6 | config.public_file_server.enabled = true 7 | config.public_file_server.headers = { 'Cache-Control' => 'public, max-age=3600' } 8 | else 9 | config.serve_static_files = true 10 | config.static_cache_control = 'public, max-age=3600' 11 | end 12 | 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | config.action_dispatch.show_exceptions = false 17 | 18 | config.action_controller.allow_forgery_protection = false 19 | 20 | config.action_mailer.delivery_method = :test 21 | config.action_mailer.default_url_options = { host: 'test.host' } 22 | 23 | config.active_support.deprecation = :stderr 24 | I18n.enforce_available_locales = false 25 | 26 | config.active_support.test_order = :sorted 27 | end 28 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20120508165529_create_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateTables < MIGRATION_CLASS 2 | def self.up 3 | create_table :users do |t| 4 | t.string :username 5 | t.string :facebook_token 6 | 7 | ## Database authenticatable 8 | t.string :email, null: false, default: '' 9 | t.string :encrypted_password, null: false, default: '' 10 | 11 | t.datetime :password_changed_at 12 | t.timestamps null: false 13 | end 14 | 15 | create_table :secure_users do |t| 16 | t.string :email 17 | t.string :encrypted_password, null: false, default: '' 18 | t.timestamps null: false 19 | end 20 | 21 | create_table :old_passwords do |t| 22 | t.string :encrypted_password 23 | 24 | t.references :password_archivable, polymorphic: true 25 | end 26 | end 27 | 28 | def self.down 29 | drop_table :users 30 | drop_table :secure_users 31 | drop_table :old_passwords 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/test_password_expirable.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestPasswordArchivable < ActiveSupport::TestCase 4 | setup do 5 | Devise.expire_password_after = 2.month 6 | end 7 | 8 | teardown do 9 | Devise.expire_password_after = 90.days 10 | end 11 | 12 | test 'password expires' do 13 | user = User.create password: 'password1', password_confirmation: 'password1' 14 | refute user.need_change_password? 15 | 16 | user.update(password_changed_at: Time.now.ago(3.month)) 17 | assert user.need_change_password? 18 | end 19 | 20 | test 'override expire after at runtime' do 21 | user = User.new password: 'password1', password_confirmation: 'password1' 22 | user.instance_eval do 23 | def expire_password_after 24 | 4.month 25 | end 26 | end 27 | user.password_changed_at = Time.now.ago(3.month) 28 | refute user.need_change_password? 29 | user.password_changed_at = Time.now.ago(5.month) 30 | assert user.need_change_password? 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /config/locales/es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | errors: 3 | messages: 4 | taken_in_past: "la contraseña fue usada previamente, favor elegir otra." 5 | equal_to_current_password: "tiene que ser diferente a la contraseña actual." 6 | password_format: "tiene que contener mayúsculas, minúsculas y digitos " 7 | devise: 8 | invalid_captcha: "El captcha ingresado es inválido." 9 | invalid_security_question: "La respuesta a la pregunta de suguridad fue incorrecta." 10 | paranoid_verify: 11 | code_required: "Por favor ingrese el código provisto por nuestro equipo de soporte" 12 | password_expired: 13 | updated: "Su nueva contraña ha sido guardada." 14 | change_required: "Su contraña ha expirado. Por favor renueve su contraseña." 15 | failure: 16 | session_limited: 'Sus credenciales de inicio de sesión fueron usadas en otro navegador. Por favor inicie sesión nuevamente para continuar en éste navegador.' 17 | expired: 'Su cuenta ha expirado debido a inactividad. Por favor contacte al administrador de la aplicación.' 18 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/database_authenticatable_patch.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Models 3 | module DatabaseAuthenticatablePatch 4 | def update_with_password(params, *options) 5 | current_password = params.delete(:current_password) 6 | 7 | new_password = params[:password] 8 | new_password_confirmation = params[:password_confirmation] 9 | 10 | result = if valid_password?(current_password) && new_password.present? && new_password_confirmation.present? 11 | update_attributes(params, *options) 12 | else 13 | self.assign_attributes(params, *options) 14 | self.valid? 15 | self.errors.add(:current_password, current_password.blank? ? :blank : :invalid) 16 | self.errors.add(:password, new_password.blank? ? :blank : :invalid) 17 | self.errors.add(:password_confirmation, new_password_confirmation.blank? ? :blank : :invalid) 18 | false 19 | end 20 | 21 | clean_up_passwords 22 | result 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/sessions_controller_captcha.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module SessionsControllerCaptcha 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do |&block| 6 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 7 | self.resource = warden.authenticate!(auth_options) 8 | set_flash_message(:notice, :signed_in) if is_flashing_format? 9 | sign_in(resource_name, resource) 10 | block.call(resource) if block 11 | respond_with resource, :location => after_sign_in_path_for(resource) 12 | else 13 | flash[:alert] = t('devise.invalid_captcha') if is_flashing_format? 14 | respond_with({}, :location => new_session_path(resource_name)) 15 | end 16 | end 17 | 18 | # for bad protected use in controller 19 | define_method :auth_options do 20 | { :scope => resource_name, :recall => "#{controller_path}#new" } 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Marco Scholl 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/unlocks_controller_security_question.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module UnlocksControllerSecurityQuestion 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do 6 | # only find via email, not login 7 | resource = resource_class.find_or_initialize_with_error_by(:email, params[resource_name][:email], :not_found) 8 | 9 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 10 | (resource.security_question_answer.present? and resource.security_question_answer == params[:security_question_answer]) 11 | self.resource = resource_class.send_unlock_instructions(params[resource_name]) 12 | if successfully_sent?(resource) 13 | respond_with({}, :location => new_session_path(resource_name)) 14 | else 15 | respond_with(resource) 16 | end 17 | else 18 | flash[:alert] = t('devise.invalid_security_question') if is_navigational_format? 19 | respond_with({}, :location => new_unlock_path(resource_name)) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/passwords_controller_security_question.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module PasswordsControllerSecurityQuestion 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do 6 | # only find via email, not login 7 | resource = resource_class.find_or_initialize_with_error_by(:email, params[resource_name][:email], :not_found) 8 | 9 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 10 | (resource.security_question_answer.present? and resource.security_question_answer == params[:security_question_answer]) 11 | self.resource = resource_class.send_reset_password_instructions(params[resource_name]) 12 | if successfully_sent?(resource) 13 | respond_with({}, :location => new_session_path(resource_name)) 14 | else 15 | respond_with(resource) 16 | end 17 | else 18 | flash[:alert] = t('devise.invalid_security_question') if is_navigational_format? 19 | respond_with({}, :location => new_password_path(resource_name)) 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/confirmations_controller_security_question.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module ConfirmationsControllerSecurityQuestion 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do 6 | # only find via email, not login 7 | resource = resource_class.find_or_initialize_with_error_by(:email, params[resource_name][:email], :not_found) 8 | 9 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) or 10 | (resource.security_question_answer.present? and resource.security_question_answer == params[:security_question_answer]) 11 | self.resource = resource_class.send_confirmation_instructions(params[resource_name]) 12 | 13 | if successfully_sent?(resource) 14 | respond_with({}, :location => after_resending_confirmation_instructions_path_for(resource_name)) 15 | else 16 | respond_with(resource) 17 | end 18 | else 19 | flash[:alert] = t('devise.invalid_security_question') if is_navigational_format? 20 | respond_with({}, :location => new_confirmation_path(resource_name)) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/controllers/devise/paranoid_verification_code_controller.rb: -------------------------------------------------------------------------------- 1 | class Devise::ParanoidVerificationCodeController < DeviseController 2 | skip_before_action :handle_paranoid_verification 3 | prepend_before_action :authenticate_scope!, :only => [:show, :update] 4 | 5 | def show 6 | if !resource.nil? && resource.need_paranoid_verification? 7 | respond_with(resource) 8 | else 9 | redirect_to :root 10 | end 11 | end 12 | 13 | def update 14 | if resource.verify_code(resource_params[:paranoid_verification_code]) 15 | warden.session(scope)['paranoid_verify'] = false 16 | set_flash_message :notice, :updated 17 | bypass_sign_in resource, scope: scope 18 | redirect_to stored_location_for(scope) || :root 19 | else 20 | respond_with(resource, action: :show) 21 | end 22 | end 23 | 24 | private 25 | 26 | def resource_params 27 | if params.respond_to?(:permit) 28 | params.require(resource_name).permit(:paranoid_verification_code) 29 | else 30 | params[scope].slice(:paranoid_verification_code) 31 | end 32 | end 33 | 34 | def scope 35 | resource_name.to_sym 36 | end 37 | 38 | def authenticate_scope! 39 | send(:"authenticate_#{resource_name}!") 40 | self.resource = send("current_#{resource_name}") 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Include: 3 | - '**/Rakefile' 4 | - '**/config.ru' 5 | - 'lib/tasks/**/*' 6 | Exclude: 7 | - Gemfile* 8 | - 'db/**/*' 9 | - 'config/**/*' 10 | - 'bin/**/*' 11 | - 'vendor/bundle/**/*' 12 | - 'spec/support/**/*' # rspec support helpers have a strange api 13 | 14 | Rails: 15 | Enabled: true 16 | 17 | # We don't care about method length, since we check method cyclomatic 18 | # complexity. 19 | Metrics/MethodLength: 20 | Enabled: false 21 | 22 | # Trailing commas make for clearer diffs because the last line won't appear 23 | # to have been changed, as it would if it lacked a comma and had one added. 24 | Style/TrailingCommaInLiteral: 25 | EnforcedStyleForMultiline: comma 26 | Style/TrailingCommaInArguments: 27 | EnforcedStyleForMultiline: comma 28 | 29 | # Cop supports --auto-correct. 30 | # Configuration parameters: PreferredDelimiters. 31 | Style/PercentLiteralDelimiters: 32 | PreferredDelimiters: 33 | # Using `[]` for string arrays instead of `()`, since normal arrays are 34 | # indicated with `[]` not `()`. 35 | '%w': '[]' 36 | '%W': '[]' 37 | 38 | Style/AndOr: 39 | # Whether `and` and `or` are banned only in conditionals (conditionals) 40 | # or completely (always). 41 | # They read better, more like normal English. 42 | Enabled: false 43 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/paranoid_verification.rb: -------------------------------------------------------------------------------- 1 | require 'devise_security_extension/hooks/paranoid_verification' 2 | 3 | module Devise 4 | module Models 5 | # PasswordExpirable takes care of change password after 6 | module ParanoidVerification 7 | extend ActiveSupport::Concern 8 | 9 | def need_paranoid_verification? 10 | !!paranoid_verification_code 11 | end 12 | 13 | def verify_code(code) 14 | attempt = paranoid_verification_attempt 15 | 16 | if (attempt += 1) >= Devise.paranoid_code_regenerate_after_attempt 17 | generate_paranoid_code 18 | elsif code == paranoid_verification_code 19 | attempt = 0 20 | update_without_password paranoid_verification_code: nil, paranoid_verified_at: Time.now, paranoid_verification_attempt: attempt 21 | else 22 | update_without_password paranoid_verification_attempt: attempt 23 | end 24 | end 25 | 26 | def paranoid_attempts_remaining 27 | Devise.paranoid_code_regenerate_after_attempt - paranoid_verification_attempt 28 | end 29 | 30 | def generate_paranoid_code 31 | update_without_password paranoid_verification_code: Devise.verification_code_generator.call(), paranoid_verification_attempt: 0 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/generators/templates/devise_security_extension.rb: -------------------------------------------------------------------------------- 1 | Devise.setup do |config| 2 | # ==> Security Extension 3 | # Configure security extension for devise 4 | 5 | # Should the password expire (e.g 3.months) 6 | # config.expire_password_after = false 7 | 8 | # Need 1 char of A-Z, a-z and 0-9 9 | # config.password_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ 10 | 11 | # How many passwords to keep in archive 12 | # config.password_archiving_count = 5 13 | 14 | # Deny old password (true, false, count) 15 | # config.deny_old_passwords = true 16 | 17 | # enable email validation for :secure_validatable. (true, false, validation_options) 18 | # dependency: need an email validator like rails_email_validator 19 | # config.email_validation = true 20 | 21 | # captcha integration for recover form 22 | # config.captcha_for_recover = true 23 | 24 | # captcha integration for sign up form 25 | # config.captcha_for_sign_up = true 26 | 27 | # captcha integration for sign in form 28 | # config.captcha_for_sign_in = true 29 | 30 | # captcha integration for unlock form 31 | # config.captcha_for_unlock = true 32 | 33 | # captcha integration for confirmation form 34 | # config.captcha_for_confirmation = true 35 | 36 | # Time period for account expiry from last_activity_at 37 | # config.expire_after = 90.days 38 | end 39 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | module Patches 3 | autoload :ControllerCaptcha, 'devise_security_extension/patches/controller_captcha' 4 | autoload :ControllerSecurityQuestion, 'devise_security_extension/patches/controller_security_question' 5 | 6 | class << self 7 | def apply 8 | Devise::PasswordsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_recover || Devise.security_question_for_recover 9 | Devise::UnlocksController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_unlock || Devise.security_question_for_unlock 10 | Devise::ConfirmationsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_confirmation 11 | 12 | Devise::PasswordsController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_recover 13 | Devise::UnlocksController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_unlock 14 | Devise::ConfirmationsController.send(:include, Patches::ControllerSecurityQuestion) if Devise.security_question_for_confirmation 15 | 16 | Devise::RegistrationsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_sign_up 17 | Devise::SessionsController.send(:include, Patches::ControllerCaptcha) if Devise.captcha_for_sign_in 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/devise_security_extension/patches/registrations_controller_captcha.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension::Patches 2 | module RegistrationsControllerCaptcha 3 | extend ActiveSupport::Concern 4 | included do 5 | define_method :create do |&block| 6 | build_resource(sign_up_params) 7 | 8 | if ((defined? verify_recaptcha) && (verify_recaptcha)) or ((defined? valid_captcha?) && (valid_captcha? params[:captcha])) 9 | if resource.save 10 | block.call(resource) if block 11 | if resource.active_for_authentication? 12 | set_flash_message :notice, :signed_up if is_flashing_format? 13 | sign_up(resource_name, resource) 14 | respond_with resource, :location => after_sign_up_path_for(resource) 15 | else 16 | set_flash_message :notice, :"signed_up_but_#{resource.inactive_message}" if is_flashing_format? 17 | expire_data_after_sign_in! 18 | respond_with resource, :location => after_inactive_sign_up_path_for(resource) 19 | end 20 | else 21 | clean_up_passwords resource 22 | respond_with resource 23 | end 24 | 25 | else 26 | resource.errors.add :base, t('devise.invalid_captcha') 27 | clean_up_passwords resource 28 | respond_with resource 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/devise_security_extension/hooks/session_limitable.rb: -------------------------------------------------------------------------------- 1 | # After each sign in, update unique_session_id. 2 | # This is only triggered when the user is explicitly set (with set_user) 3 | # and on authentication. Retrieving the user from session (:fetch) does 4 | # not trigger it. 5 | Warden::Manager.after_set_user :except => :fetch do |record, warden, options| 6 | if record.respond_to?(:update_unique_session_id!) && warden.authenticated?(options[:scope]) 7 | unique_session_id = Devise.friendly_token 8 | warden.session(options[:scope])['unique_session_id'] = unique_session_id 9 | record.update_unique_session_id!(unique_session_id) 10 | end 11 | end 12 | 13 | # Each time a record is fetched from session we check if a new session from another 14 | # browser was opened for the record or not, based on a unique session identifier. 15 | # If so, the old account is logged out and redirected to the sign in page on the next request. 16 | Warden::Manager.after_set_user :only => :fetch do |record, warden, options| 17 | scope = options[:scope] 18 | env = warden.request.env 19 | 20 | if record.respond_to?(:unique_session_id) && warden.authenticated?(scope) && options[:store] != false 21 | if record.unique_session_id != warden.session(scope)['unique_session_id'] && !env['devise.skip_session_limitable'] 22 | warden.raw_session.clear 23 | warden.logout(scope) 24 | throw :warden, :scope => scope, :message => :session_limited 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /devise_security_extension.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $LOAD_PATH.unshift(File.expand_path('../lib', __FILE__)) 3 | require 'devise_security_extension/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'devise_security_extension' 7 | s.version = DeviseSecurityExtension::VERSION.dup 8 | s.platform = Gem::Platform::RUBY 9 | s.licenses = ['MIT'] 10 | s.summary = 'Security extension for devise' 11 | s.email = 'team@phatworx.de' 12 | s.homepage = 'https://github.com/phatworx/devise_security_extension' 13 | s.description = 'An enterprise security extension for devise, trying to meet industrial standard security demands for web applications.' 14 | s.authors = ['Marco Scholl', 'Alexander Dreher'] 15 | 16 | s.rubyforge_project = 'devise_security_extension' 17 | 18 | s.files = `git ls-files`.split("\n") 19 | s.test_files = `git ls-files -- test/*`.split("\n") 20 | s.require_paths = ['lib'] 21 | s.required_ruby_version = '>= 2.1.0' 22 | 23 | s.add_runtime_dependency 'railties', '>= 3.2.6', '< 6.0' 24 | s.add_runtime_dependency 'devise', '>= 3.0.0', '< 5.0' 25 | s.add_development_dependency 'bundler', '>= 1.3.0', '< 2.0' 26 | s.add_development_dependency 'sqlite3', '~> 1.3.10' 27 | s.add_development_dependency 'rubocop', '~> 0' 28 | s.add_development_dependency 'minitest' 29 | s.add_development_dependency 'easy_captcha', '~> 0' 30 | s.add_development_dependency 'rails_email_validator', '~> 0' 31 | s.add_development_dependency 'coveralls' 32 | end 33 | -------------------------------------------------------------------------------- /app/controllers/devise/password_expired_controller.rb: -------------------------------------------------------------------------------- 1 | class Devise::PasswordExpiredController < DeviseController 2 | skip_before_action :handle_password_change 3 | before_action :skip_password_change, only: [:show, :update] 4 | prepend_before_action :authenticate_scope!, :only => [:show, :update] 5 | 6 | def show 7 | respond_with(resource) 8 | end 9 | 10 | def update 11 | resource.extend(Devise::Models::DatabaseAuthenticatablePatch) 12 | if resource.update_with_password(resource_params) 13 | warden.session(scope)['password_expired'] = false 14 | set_flash_message :notice, :updated 15 | bypass_sign_in resource, scope: scope 16 | redirect_to stored_location_for(scope) || :root 17 | else 18 | clean_up_passwords(resource) 19 | respond_with(resource, action: :show) 20 | end 21 | end 22 | 23 | private 24 | 25 | def skip_password_change 26 | return if !resource.nil? && resource.need_change_password? 27 | redirect_to :root 28 | end 29 | 30 | def resource_params 31 | permitted_params = [:current_password, :password, :password_confirmation] 32 | 33 | if params.respond_to?(:permit) 34 | params.require(resource_name).permit(*permitted_params) 35 | else 36 | params[scope].slice(*permitted_params) 37 | end 38 | end 39 | 40 | def scope 41 | resource_name.to_sym 42 | end 43 | 44 | def authenticate_scope! 45 | send(:"authenticate_#{resource_name}!") 46 | self.resource = send("current_#{resource_name}") 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/test_captcha_controller.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestWithCaptcha < ActionController::TestCase 4 | include Devise::Test::ControllerHelpers 5 | tests Captcha::SessionsController 6 | 7 | setup do 8 | @request.env["devise.mapping"] = Devise.mappings[:captcha_user] 9 | end 10 | 11 | test 'When captcha is enabled, it is inserted correctly' do 12 | post :create, { 13 | captcha_user: { 14 | email: "wrong@email.com", 15 | password: "wrongpassword" 16 | } 17 | } 18 | 19 | assert_equal "The captcha input was invalid.", flash[:alert] 20 | assert_redirected_to new_captcha_user_session_path 21 | end 22 | 23 | test 'When captcha is valid, it runs as normal' do 24 | @controller.define_singleton_method(:verify_recaptcha) do 25 | true 26 | end 27 | 28 | post :create, { 29 | captcha: "ABCDE", 30 | captcha_user: { 31 | email: "wrong@email.com", 32 | password: "wrongpassword" 33 | } 34 | } 35 | 36 | assert_equal "Invalid Email or password.", flash[:alert] 37 | end 38 | end 39 | 40 | class TestWithoutCaptcha < ActionController::TestCase 41 | include Devise::Test::ControllerHelpers 42 | tests Devise::SessionsController 43 | 44 | setup do 45 | @request.env["devise.mapping"] = Devise.mappings[:user] 46 | end 47 | 48 | test 'When captcha is not enabled, it is not inserted' do 49 | post :create, { 50 | user: { 51 | email: "wrong@email.com", 52 | password: "wrongpassword" 53 | } 54 | } 55 | 56 | assert_equal "Invalid Email or password.", flash[:alert] 57 | end 58 | end 59 | 60 | -------------------------------------------------------------------------------- /test/test_password_archivable.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestPasswordArchivable < ActiveSupport::TestCase 4 | setup do 5 | Devise.password_archiving_count = 2 6 | end 7 | 8 | teardown do 9 | Devise.password_archiving_count = 1 10 | end 11 | 12 | def set_password(user, password) 13 | user.password = password 14 | user.password_confirmation = password 15 | user.save! 16 | end 17 | 18 | test 'cannot use same password' do 19 | user = User.create password: 'password1', password_confirmation: 'password1' 20 | 21 | assert_raises(ActiveRecord::RecordInvalid) { set_password(user, 'password1') } 22 | end 23 | 24 | test 'cannot use archived passwords' do 25 | assert_equal 2, Devise.password_archiving_count 26 | 27 | user = User.create password: 'password1', password_confirmation: 'password1' 28 | assert_equal 0, OldPassword.count 29 | 30 | set_password(user, 'password2') 31 | assert_equal 1, OldPassword.count 32 | 33 | assert_raises(ActiveRecord::RecordInvalid) { set_password(user, 'password1') } 34 | 35 | set_password(user, 'password3') 36 | assert_equal 2, OldPassword.count 37 | 38 | # rotate first password out of archive 39 | assert set_password(user, 'password4') 40 | 41 | # archive count was 2, so first password should work again 42 | assert set_password(user, 'password1') 43 | assert set_password(user, 'password2') 44 | end 45 | 46 | test 'the option should be dynamic during runtime' do 47 | class ::User 48 | def archive_count 49 | 1 50 | end 51 | end 52 | 53 | user = User.create password: 'password1', password_confirmation: 'password1' 54 | 55 | assert set_password(user, 'password2') 56 | 57 | assert_raises(ActiveRecord::RecordInvalid) { set_password(user, 'password2') } 58 | 59 | assert_raises(ActiveRecord::RecordInvalid) { set_password(user, 'password1') } 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/devise_security_extension/schema.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | # add schema helper for migrations 3 | module Schema 4 | # Add password_changed_at columns in the resource's database table. 5 | # 6 | # Examples 7 | # 8 | # # For a new resource migration: 9 | # create_table :the_resources do |t| 10 | # t.password_expirable 11 | # ... 12 | # end 13 | # 14 | # # or if the resource's table already exists, define a migration and put this in: 15 | # change_table :the_resources do |t| 16 | # t.datetime :password_changed_at 17 | # end 18 | # 19 | def password_expirable 20 | apply_devise_schema :password_changed_at, DateTime 21 | end 22 | 23 | # Add password_archivable columns 24 | # 25 | # Examples 26 | # 27 | # create_table :old_passwords do 28 | # t.password_archivable 29 | # end 30 | # add_index :old_passwords, [:password_archivable_type, :password_archivable_id], :name => :index_password_archivable 31 | # 32 | def password_archivable 33 | apply_devise_schema :encrypted_password, String, :limit => 128, :null => false 34 | apply_devise_schema :password_salt, String 35 | apply_devise_schema :password_archivable_id, Integer, :null => false 36 | apply_devise_schema :password_archivable_type, String, :null => false 37 | apply_devise_schema :created_at, DateTime 38 | end 39 | 40 | # Add session_limitable columns in the resource's database table. 41 | # 42 | # Examples 43 | # 44 | # # For a new resource migration: 45 | # create_table :the_resources do |t| 46 | # t.session_limitable 47 | # ... 48 | # end 49 | # 50 | # # or if the resource's table already exists, define a migration and put this in: 51 | # change_table :the_resources do |t| 52 | # t.string :unique_session_id, :limit => 20 53 | # end 54 | # 55 | def session_limitable 56 | apply_devise_schema :unique_session_id, String, :limit => 20 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/password_expirable.rb: -------------------------------------------------------------------------------- 1 | require 'devise_security_extension/hooks/password_expirable' 2 | 3 | module Devise 4 | module Models 5 | 6 | # PasswordExpirable takes care of change password after 7 | module PasswordExpirable 8 | extend ActiveSupport::Concern 9 | 10 | included do 11 | before_save :update_password_changed 12 | end 13 | 14 | # is an password change required? 15 | def need_change_password? 16 | if self.expire_password_after.is_a? Fixnum or self.expire_password_after.is_a? Float 17 | self.password_changed_at.nil? or self.password_changed_at < self.expire_password_after.seconds.ago 18 | else 19 | false 20 | end 21 | end 22 | 23 | # set a fake datetime so a password change is needed and save the record 24 | def need_change_password! 25 | if self.expire_password_after.is_a? Fixnum or self.expire_password_after.is_a? Float 26 | need_change_password 27 | self.save(:validate => false) 28 | end 29 | end 30 | 31 | # set a fake datetime so a password change is needed 32 | def need_change_password 33 | if self.expire_password_after.is_a? Fixnum or self.expire_password_after.is_a? Float 34 | self.password_changed_at = self.expire_password_after.seconds.ago 35 | end 36 | 37 | # is date not set it will set default to need set new password next login 38 | need_change_password if self.password_changed_at.nil? 39 | 40 | self.password_changed_at 41 | end 42 | 43 | def expire_password_after 44 | self.class.expire_password_after 45 | end 46 | 47 | private 48 | 49 | # is password changed then update password_cahanged_at 50 | def update_password_changed 51 | self.password_changed_at = Time.now if (self.new_record? or self.encrypted_password_changed?) and not self.password_changed_at_changed? 52 | end 53 | 54 | module ClassMethods 55 | ::Devise::Models.config(self, :expire_password_after) 56 | end 57 | end 58 | 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /test/test_security_question_controller.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestWithSecurityQuestion < ActionController::TestCase 4 | include Devise::Test::ControllerHelpers 5 | tests SecurityQuestion::UnlocksController 6 | 7 | setup do 8 | @user = User.create(username: 'hello', email: 'hello@path.travel', 9 | password: '1234', security_question_answer: "Right Answer") 10 | @user.lock_access! 11 | 12 | @request.env["devise.mapping"] = Devise.mappings[:security_question_user] 13 | end 14 | 15 | test 'When security question is enabled, it is inserted correctly' do 16 | post :create, { 17 | security_question_user: { 18 | email: @user.email 19 | }, security_question_answer: "wrong answer" 20 | } 21 | 22 | assert_equal "The security question answer was invalid.", flash[:alert] 23 | assert_redirected_to new_security_question_user_unlock_path 24 | end 25 | 26 | test 'When security_question is valid, it runs as normal' do 27 | post :create, { 28 | security_question_user: { 29 | email: @user.email 30 | }, security_question_answer: @user.security_question_answer 31 | } 32 | 33 | assert_equal "You will receive an email with instructions for how to unlock your account in a few minutes.", flash[:notice] 34 | assert_redirected_to new_security_question_user_session_path 35 | end 36 | end 37 | 38 | class TestWithoutSecurityQuestion < ActionController::TestCase 39 | include Devise::Test::ControllerHelpers 40 | tests Devise::UnlocksController 41 | 42 | setup do 43 | @user = User.create(username: 'hello', email: 'hello@path.travel', 44 | password: '1234', security_question_answer: "Right Answer") 45 | @user.lock_access! 46 | 47 | @request.env["devise.mapping"] = Devise.mappings[:user] 48 | end 49 | 50 | test 'When security question is not enabled it is not inserted' do 51 | post :create, { 52 | user: { 53 | email: @user.email 54 | } 55 | } 56 | 57 | assert_equal "You will receive an email with instructions for how to unlock your account in a few minutes.", flash[:notice] 58 | assert_redirected_to new_user_session_path 59 | end 60 | end 61 | 62 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/password_archivable.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Models 3 | # PasswordArchivable 4 | module PasswordArchivable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | has_many :old_passwords, as: :password_archivable, dependent: :destroy 9 | before_update :archive_password 10 | validate :validate_password_archive 11 | end 12 | 13 | def validate_password_archive 14 | errors.add(:password, :taken_in_past) if encrypted_password_changed? and password_archive_included? 15 | end 16 | 17 | # validate is the password used in the past 18 | def password_archive_included? 19 | unless deny_old_passwords.is_a? Fixnum 20 | if deny_old_passwords.is_a? TrueClass and archive_count > 0 21 | self.deny_old_passwords = archive_count 22 | else 23 | self.deny_old_passwords = 0 24 | end 25 | end 26 | 27 | if self.class.deny_old_passwords > 0 and not self.password.nil? 28 | old_passwords_including_cur_change = self.old_passwords.order(:id).reverse_order.limit(self.class.deny_old_passwords).to_a 29 | old_passwords_including_cur_change << OldPassword.new(old_password_params) # include most recent change in list, but don't save it yet! 30 | old_passwords_including_cur_change.each do |old_password| 31 | dummy = self.class.new 32 | dummy.encrypted_password = old_password.encrypted_password 33 | return true if dummy.valid_password?(password) 34 | end 35 | end 36 | 37 | false 38 | end 39 | 40 | def password_changed_to_same? 41 | pass_change = encrypted_password_change 42 | pass_change && pass_change.first == pass_change.last 43 | end 44 | 45 | def deny_old_passwords 46 | self.class.deny_old_passwords 47 | end 48 | 49 | def deny_old_passwords=(count) 50 | self.class.deny_old_passwords = count 51 | end 52 | 53 | def archive_count 54 | self.class.password_archiving_count 55 | end 56 | 57 | private 58 | 59 | # archive the last password before save and delete all to old passwords from archive 60 | def archive_password 61 | if encrypted_password_changed? 62 | if archive_count.to_i > 0 63 | old_passwords.create! old_password_params 64 | old_passwords.order(:id).reverse_order.offset(archive_count).destroy_all 65 | else 66 | old_passwords.destroy_all 67 | end 68 | end 69 | end 70 | 71 | def old_password_params 72 | { encrypted_password: encrypted_password_change.first } 73 | end 74 | 75 | module ClassMethods 76 | ::Devise::Models.config(self, :password_archiving_count, :deny_old_passwords) 77 | end 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/devise_security_extension/controllers/helpers.rb: -------------------------------------------------------------------------------- 1 | module DeviseSecurityExtension 2 | module Controllers 3 | module Helpers 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | before_action :handle_password_change 8 | before_action :handle_paranoid_verification 9 | end 10 | 11 | module ClassMethods 12 | # helper for captcha 13 | def init_recover_password_captcha 14 | include RecoverPasswordCaptcha 15 | end 16 | end 17 | 18 | module RecoverPasswordCaptcha 19 | def new 20 | super 21 | end 22 | end 23 | 24 | # controller instance methods 25 | 26 | private 27 | 28 | # lookup if an password change needed 29 | def handle_password_change 30 | return if warden.nil? 31 | 32 | if not devise_controller? and not ignore_password_expire? and not request.format.nil? and request.format.html? 33 | Devise.mappings.keys.flatten.any? do |scope| 34 | if signed_in?(scope) and warden.session(scope)['password_expired'] 35 | # re-check to avoid infinite loop if date changed after login attempt 36 | if send(:"current_#{scope}").try(:need_change_password?) 37 | store_location_for(scope, request.original_fullpath) if request.get? 38 | redirect_for_password_change scope 39 | return 40 | else 41 | warden.session(scope)[:password_expired] = false 42 | end 43 | end 44 | end 45 | end 46 | end 47 | 48 | # lookup if extra (paranoid) code verification is needed 49 | def handle_paranoid_verification 50 | return if warden.nil? 51 | 52 | if !devise_controller? && !request.format.nil? && request.format.html? 53 | Devise.mappings.keys.flatten.any? do |scope| 54 | if signed_in?(scope) && warden.session(scope)['paranoid_verify'] 55 | store_location_for(scope, request.original_fullpath) if request.get? 56 | redirect_for_paranoid_verification scope 57 | return 58 | end 59 | end 60 | end 61 | end 62 | 63 | # redirect for password update with alert message 64 | def redirect_for_password_change(scope) 65 | redirect_to change_password_required_path_for(scope), :alert => I18n.t('change_required', {:scope => 'devise.password_expired'}) 66 | end 67 | 68 | def redirect_for_paranoid_verification(scope) 69 | redirect_to paranoid_verification_code_path_for(scope), :alert => I18n.t('code_required', {:scope => 'devise.paranoid_verify'}) 70 | end 71 | 72 | # path for change password 73 | def change_password_required_path_for(resource_or_scope = nil) 74 | scope = Devise::Mapping.find_scope!(resource_or_scope) 75 | change_path = "#{scope}_password_expired_path" 76 | send(change_path) 77 | end 78 | 79 | def paranoid_verification_code_path_for(resource_or_scope = nil) 80 | scope = Devise::Mapping.find_scope!(resource_or_scope) 81 | change_path = "#{scope}_paranoid_verification_code_path" 82 | send(change_path) 83 | end 84 | 85 | protected 86 | 87 | # allow to overwrite for some special handlings 88 | def ignore_password_expire? 89 | false 90 | end 91 | 92 | 93 | end 94 | end 95 | 96 | end 97 | -------------------------------------------------------------------------------- /test/test_secure_validatable.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rails_email_validator' 3 | 4 | class TestSecureValidatable < ActiveSupport::TestCase 5 | class User < ActiveRecord::Base 6 | devise :database_authenticatable, :password_archivable, 7 | :paranoid_verification, :password_expirable, :secure_validatable 8 | end 9 | 10 | setup do 11 | Devise.password_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ 12 | end 13 | 14 | test 'email cannot be blank' do 15 | msg = "Email can't be blank" 16 | user = User.create password: 'passWord1', password_confirmation: 'passWord1' 17 | assert_equal(false, user.valid?) 18 | assert_equal([msg], user.errors.full_messages) 19 | assert_raises(ActiveRecord::RecordInvalid) do 20 | user.save! 21 | end 22 | end 23 | 24 | test 'email must be valid' do 25 | msg = 'Email is invalid' 26 | user = User.create email: 'bob', password: 'passWord1', password_confirmation: 'passWord1' 27 | assert_equal(false, user.valid?) 28 | assert_equal([msg], user.errors.full_messages) 29 | assert_raises(ActiveRecord::RecordInvalid) do 30 | user.save! 31 | end 32 | end 33 | 34 | test 'valid both email and password' do 35 | msgs = ['Email is invalid', 'Password must contain big, small letters and digits'] 36 | user = User.create email: 'bob@foo.tv', password: 'password1', password_confirmation: 'password1' 37 | assert_equal(false, user.valid?) 38 | assert_equal(msgs, user.errors.full_messages) 39 | assert_raises(ActiveRecord::RecordInvalid) { user.save! } 40 | end 41 | 42 | test 'password must have capital letter' do 43 | msgs = ['Email is invalid', 'Password must contain big, small letters and digits'] 44 | user = User.create email: 'bob@example.org', password: 'password1', password_confirmation: 'password1' 45 | assert_equal(false, user.valid?) 46 | assert_equal(msgs, user.errors.full_messages) 47 | assert_raises(ActiveRecord::RecordInvalid) { user.save! } 48 | end 49 | 50 | test 'password must have lowercase letter' do 51 | msg = 'Password must contain big, small letters and digits' 52 | user = User.create email: 'bob@microsoft.com', password: 'PASSWORD1', password_confirmation: 'PASSWORD1' 53 | assert_equal(false, user.valid?) 54 | assert_equal([msg], user.errors.full_messages) 55 | assert_raises(ActiveRecord::RecordInvalid) { user.save! } 56 | end 57 | 58 | test 'password must have number' do 59 | msg = 'Password must contain big, small letters and digits' 60 | user = User.create email: 'bob@microsoft.com', password: 'PASSword', password_confirmation: 'PASSword' 61 | assert_equal(false, user.valid?) 62 | assert_equal([msg], user.errors.full_messages) 63 | assert_raises(ActiveRecord::RecordInvalid) { user.save! } 64 | end 65 | 66 | test 'password must have minimum length' do 67 | msg = 'Password is too short (minimum is 6 characters)' 68 | user = User.create email: 'bob@microsoft.com', password: 'Pa3zZ', password_confirmation: 'Pa3zZ' 69 | assert_equal(false, user.valid?) 70 | assert_equal([msg], user.errors.full_messages) 71 | assert_raises(ActiveRecord::RecordInvalid) { user.save! } 72 | end 73 | 74 | test 'duplicate email validation message is added only once' do 75 | options = { 76 | email: 'test@example.org', 77 | password: 'Test12345', 78 | password_confirmation: 'Test12345', 79 | } 80 | SecureUser.create!(options) 81 | user = SecureUser.new(options) 82 | refute user.valid? 83 | assert_equal ['Email has already been taken'], user.errors.full_messages 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/secure_validatable.rb: -------------------------------------------------------------------------------- 1 | module Devise 2 | module Models 3 | # SecureValidatable creates better validations with more validation for security 4 | # 5 | # == Options 6 | # 7 | # SecureValidatable adds the following options to devise_for: 8 | # 9 | # * +email_regexp+: the regular expression used to validate e-mails; 10 | # * +password_length+: a range expressing password length. Defaults from devise 11 | # * +password_regex+: need strong password. Defaults to /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ 12 | # 13 | module SecureValidatable 14 | 15 | def self.included(base) 16 | base.extend ClassMethods 17 | assert_secure_validations_api!(base) 18 | 19 | base.class_eval do 20 | already_validated_email = false 21 | 22 | # validate login in a strict way if not yet validated 23 | unless has_uniqueness_validation_of_login? 24 | validation_condition = "#{login_attribute}_changed?".to_sym 25 | 26 | validates login_attribute, :uniqueness => { 27 | :scope => authentication_keys[1..-1], 28 | :case_sensitive => !!case_insensitive_keys 29 | }, 30 | :if => validation_condition 31 | 32 | already_validated_email = login_attribute.to_s == 'email' 33 | end 34 | 35 | unless devise_validation_enabled? 36 | validates :email, :presence => true, :if => :email_required? 37 | unless already_validated_email 38 | validates :email, :uniqueness => true, :allow_blank => true, :if => :email_changed? # check uniq for email ever 39 | end 40 | 41 | validates :password, :presence => true, :length => password_length, :confirmation => true, :if => :password_required? 42 | end 43 | 44 | # extra validations 45 | validates :email, :email => email_validation if email_validation # use rails_email_validator or similar 46 | validates :password, :format => { :with => password_regex, :message => :password_format }, :if => :password_required? 47 | 48 | # don't allow use same password 49 | validate :current_equal_password_validation 50 | end 51 | end 52 | 53 | def self.assert_secure_validations_api!(base) 54 | raise "Could not use SecureValidatable on #{base}" unless base.respond_to?(:validates) 55 | end 56 | 57 | def current_equal_password_validation 58 | if not self.new_record? and not self.encrypted_password_change.nil? 59 | dummy = self.class.new 60 | dummy.encrypted_password = self.encrypted_password_change.first 61 | dummy.password_salt = self.password_salt_change.first if self.respond_to? :password_salt_change and not self.password_salt_change.nil? 62 | self.errors.add(:password, :equal_to_current_password) if dummy.valid_password?(self.password) 63 | end 64 | end 65 | 66 | protected 67 | 68 | # Checks whether a password is needed or not. For validations only. 69 | # Passwords are always required if it's a new record, or if the password 70 | # or confirmation are being set somewhere. 71 | def password_required? 72 | !persisted? || !password.nil? || !password_confirmation.nil? 73 | end 74 | 75 | def email_required? 76 | true 77 | end 78 | 79 | module ClassMethods 80 | Devise::Models.config(self, :password_regex, :password_length, :email_validation) 81 | 82 | private 83 | def has_uniqueness_validation_of_login? 84 | validators.any? do |validator| 85 | validator.kind_of?(ActiveRecord::Validations::UniquenessValidator) && 86 | validator.attributes.include?(login_attribute) 87 | end 88 | end 89 | 90 | def login_attribute 91 | authentication_keys[0] 92 | end 93 | 94 | def devise_validation_enabled? 95 | self.ancestors.map(&:to_s).include? 'Devise::Models::Validatable' 96 | end 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/devise_security_extension.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_support/core_ext/integer' 3 | require 'active_support/ordered_hash' 4 | require 'active_support/concern' 5 | require 'devise' 6 | 7 | module Devise 8 | 9 | # Should the password expire (e.g 3.months) 10 | mattr_accessor :expire_password_after 11 | @@expire_password_after = 3.months 12 | 13 | # Validate password for strongness 14 | mattr_accessor :password_regex 15 | @@password_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ 16 | 17 | # Number of old passwords in archive 18 | mattr_accessor :password_archiving_count 19 | @@password_archiving_count = 5 20 | 21 | # Deny old password (true, false, count) 22 | mattr_accessor :deny_old_passwords 23 | @@deny_old_passwords = true 24 | 25 | # enable email validation for :secure_validatable. (true, false, validation_options) 26 | # dependency: need an email validator like rails_email_validator 27 | mattr_accessor :email_validation 28 | @@email_validation = true 29 | 30 | # captcha integration for recover form 31 | mattr_accessor :captcha_for_recover 32 | @@captcha_for_recover = false 33 | 34 | # captcha integration for sign up form 35 | mattr_accessor :captcha_for_sign_up 36 | @@captcha_for_sign_up = false 37 | 38 | # captcha integration for sign in form 39 | mattr_accessor :captcha_for_sign_in 40 | @@captcha_for_sign_in = false 41 | 42 | # captcha integration for unlock form 43 | mattr_accessor :captcha_for_unlock 44 | @@captcha_for_unlock = false 45 | 46 | # security_question integration for recover form 47 | # this automatically enables captchas (captcha_for_recover, as fallback) 48 | mattr_accessor :security_question_for_recover 49 | @@security_question_for_recover = false 50 | 51 | # security_question integration for unlock form 52 | # this automatically enables captchas (captcha_for_unlock, as fallback) 53 | mattr_accessor :security_question_for_unlock 54 | @@security_question_for_unlock = false 55 | 56 | # security_question integration for confirmation form 57 | # this automatically enables captchas (captcha_for_confirmation, as fallback) 58 | mattr_accessor :security_question_for_confirmation 59 | @@security_question_for_confirmation = false 60 | 61 | # captcha integration for confirmation form 62 | mattr_accessor :captcha_for_confirmation 63 | @@captcha_for_confirmation = false 64 | 65 | # captcha integration for confirmation form 66 | mattr_accessor :verification_code_generator 67 | @@verification_code_generator = -> { SecureRandom.hex[0..4] } 68 | 69 | # Time period for account expiry from last_activity_at 70 | mattr_accessor :expire_after 71 | @@expire_after = 90.days 72 | mattr_accessor :delete_expired_after 73 | @@delete_expired_after = 90.days 74 | 75 | # paranoid_verification will regenerate verifacation code after faild attempt 76 | mattr_accessor :paranoid_code_regenerate_after_attempt 77 | @@paranoid_code_regenerate_after_attempt = 10 78 | end 79 | 80 | # an security extension for devise 81 | module DeviseSecurityExtension 82 | autoload :Schema, 'devise_security_extension/schema' 83 | autoload :Patches, 'devise_security_extension/patches' 84 | 85 | module Controllers 86 | autoload :Helpers, 'devise_security_extension/controllers/helpers' 87 | end 88 | end 89 | 90 | # modules 91 | Devise.add_module :password_expirable, controller: :password_expirable, model: 'devise_security_extension/models/password_expirable', route: :password_expired 92 | Devise.add_module :secure_validatable, model: 'devise_security_extension/models/secure_validatable' 93 | Devise.add_module :password_archivable, model: 'devise_security_extension/models/password_archivable' 94 | Devise.add_module :session_limitable, model: 'devise_security_extension/models/session_limitable' 95 | Devise.add_module :session_non_transferable, model: 'devise_security_extension/models/session_non_transferable' 96 | Devise.add_module :expirable, model: 'devise_security_extension/models/expirable' 97 | Devise.add_module :security_questionable, model: 'devise_security_extension/models/security_questionable' 98 | Devise.add_module :paranoid_verification, controller: :paranoid_verification_code, model: 'devise_security_extension/models/paranoid_verification', route: :verification_code 99 | 100 | # requires 101 | require 'devise_security_extension/routes' 102 | require 'devise_security_extension/rails' 103 | require 'devise_security_extension/orm/active_record' 104 | require 'devise_security_extension/models/old_password' 105 | require 'devise_security_extension/models/database_authenticatable_patch' 106 | require 'devise_security_extension/models/paranoid_verification' 107 | -------------------------------------------------------------------------------- /lib/devise_security_extension/models/expirable.rb: -------------------------------------------------------------------------------- 1 | require 'devise_security_extension/hooks/expirable' 2 | 3 | module Devise 4 | module Models 5 | # Deactivate the account after a configurable amount of time. To be able to 6 | # tell, it tracks activity about your account with the following columns: 7 | # 8 | # * last_activity_at - A timestamp updated when the user requests a page (only signed in) 9 | # 10 | # == Options 11 | # +:expire_after+ - Time interval to expire accounts after 12 | # 13 | # == Additions 14 | # Best used with two cron jobs. One for expiring accounts after inactivity, 15 | # and another, that deletes accounts, which have expired for a given amount 16 | # of time (for example 90 days). 17 | # 18 | module Expirable 19 | extend ActiveSupport::Concern 20 | 21 | # Updates +last_activity_at+, called from a Warden::Manager.after_set_user hook. 22 | def update_last_activity! 23 | self.update_column(:last_activity_at, Time.now.utc) 24 | end 25 | 26 | # Tells if the account has expired 27 | # 28 | # @return [bool] 29 | def expired? 30 | # expired_at set (manually, via cron, etc.) 31 | return self.expired_at < Time.now.utc unless self.expired_at.nil? 32 | # if it is not set, check the last activity against configured expire_after time range 33 | return self.last_activity_at < self.class.expire_after.ago unless self.last_activity_at.nil? 34 | # if last_activity_at is nil as well, the user has to be 'fresh' and is therefore not expired 35 | false 36 | end 37 | 38 | # Expire an account. This is for cron jobs and manually expiring of accounts. 39 | # 40 | # @example 41 | # User.expire! 42 | # User.expire! 1.week.from_now 43 | # @note +expired_at+ can be in the future as well 44 | def expire!(at = Time.now.utc) 45 | self.expired_at = at 46 | save(:validate => false) 47 | end 48 | 49 | # Overwrites active_for_authentication? from Devise::Models::Activatable 50 | # for verifying whether a user is active to sign in or not. If the account 51 | # is expired, it should never be allowed. 52 | # 53 | # @return [bool] 54 | def active_for_authentication? 55 | super && !self.expired? 56 | end 57 | 58 | # The message sym, if {#active_for_authentication?} returns +false+. E.g. needed 59 | # for i18n. 60 | def inactive_message 61 | !self.expired? ? super : :expired 62 | end 63 | 64 | module ClassMethods 65 | ::Devise::Models.config(self, :expire_after, :delete_expired_after) 66 | 67 | # Sample method for daily cron to mark expired entries. 68 | # 69 | # @example You can overide this in your +resource+ model 70 | # def self.mark_expired 71 | # puts 'overwritten mark_expired' 72 | # end 73 | def mark_expired 74 | all.each do |u| 75 | u.expire! if u.expired? && u.expired_at.nil? 76 | end 77 | return 78 | end 79 | 80 | # Scope method to collect all expired users since +time+ ago 81 | def expired_for(time = delete_expired_after) 82 | where('expired_at < ?', time.seconds.ago) 83 | end 84 | 85 | # Sample method for daily cron to delete all expired entries after a 86 | # given amount of +time+. 87 | # 88 | # In your overwritten method you can "blank out" the object instead of 89 | # deleting it. 90 | # 91 | # *Word of warning*: You have to handle the dependent method 92 | # on the +resource+ relations (+:destroy+ or +:nullify+) and catch this 93 | # behavior (see http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Deleting+from+associations). 94 | # 95 | # @example 96 | # Resource.delete_all_expired_for 90.days 97 | # @example You can overide this in your +resource+ model 98 | # def self.delete_all_expired_for(time = 90.days) 99 | # puts 'overwritten delete call' 100 | # end 101 | # @example Overwritten version to blank out the object. 102 | # def self.delete_all_expired_for(time = 90.days) 103 | # expired_for(time).each do |u| 104 | # u.update_attributes first_name: nil, last_name: nil 105 | # end 106 | # end 107 | def delete_all_expired_for(time) 108 | expired_for(time).delete_all 109 | end 110 | 111 | # Version of {#delete_all_expired_for} without arguments (uses 112 | # configured +delete_expired_after+ default value). 113 | # @see #delete_all_expired_for 114 | def delete_all_expired 115 | delete_all_expired_for(delete_expired_after) 116 | end 117 | end 118 | end 119 | end 120 | end -------------------------------------------------------------------------------- /test/test_paranoid_verification.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestParanoidVerification < ActiveSupport::TestCase 4 | test 'need to paranoid verify if code present' do 5 | user = User.new 6 | user.generate_paranoid_code 7 | assert_equal(true, user.need_paranoid_verification?) 8 | end 9 | 10 | test 'no need to paranoid verify if no code' do 11 | user = User.new 12 | assert_equal(false, user.need_paranoid_verification?) 13 | end 14 | 15 | test 'generate code' do 16 | user = User.new 17 | user.generate_paranoid_code 18 | assert_equal(0, user.paranoid_verification_attempt) 19 | user.verify_code('wrong') 20 | assert_equal(1, user.paranoid_verification_attempt) 21 | user.generate_paranoid_code 22 | assert_equal(0, user.paranoid_verification_attempt) 23 | end 24 | 25 | test "generate code must reset attempt counter" do 26 | user = User.new 27 | user.generate_paranoid_code 28 | # default generator generates 5 char string 29 | assert_equal(user.paranoid_verification_code.class, String) 30 | assert_equal(user.paranoid_verification_code.length, 5) 31 | end 32 | 33 | test "when code match upon verify code, should mark record that it's no loger needed to verify" do 34 | user = User.new(paranoid_verification_code: 'abcde') 35 | 36 | assert_equal(true, user.need_paranoid_verification?) 37 | user.verify_code('abcde') 38 | assert_equal(false, user.need_paranoid_verification?) 39 | end 40 | 41 | test 'when code match upon verify code, should no longer need verification' do 42 | user = User.new(paranoid_verification_code: 'abcde') 43 | 44 | assert_equal(true, user.need_paranoid_verification?) 45 | user.verify_code('abcde') 46 | assert_equal(false, user.need_paranoid_verification?) 47 | end 48 | 49 | test 'when code match upon verification code, should set when verification was accepted' do 50 | user = User.new(paranoid_verification_code: 'abcde') 51 | user.verify_code('abcde') 52 | assert_in_delta(4, Time.now.to_i, user.paranoid_verified_at.to_i) 53 | end 54 | 55 | test 'when code not match upon verify code, should still need verification' do 56 | user = User.new(paranoid_verification_code: 'abcde') 57 | user.verify_code('wrong') 58 | assert_equal(true, user.need_paranoid_verification?) 59 | end 60 | 61 | test 'when code not match upon verification code, should not set paranoid_verified_at' do 62 | user = User.new(paranoid_verification_code: 'abcde') 63 | user.verify_code('wrong') 64 | assert_equal(nil, user.paranoid_verified_at) 65 | end 66 | 67 | test 'when code not match upon verification code too many attempts should generate new code' do 68 | original_regenerate = Devise.paranoid_code_regenerate_after_attempt 69 | Devise.paranoid_code_regenerate_after_attempt = 2 70 | 71 | user = User.create(paranoid_verification_code: 'abcde') 72 | user.verify_code('wrong') 73 | assert_equal 'abcde', user.paranoid_verification_code 74 | user.verify_code('wrong-again') 75 | assert_not_equal 'abcde', user.paranoid_verification_code 76 | 77 | Devise.paranoid_code_regenerate_after_attempt = original_regenerate 78 | end 79 | 80 | test 'upon generating new code due to too many attempts reset attempt counter' do 81 | original_regenerate = Devise.paranoid_code_regenerate_after_attempt 82 | Devise.paranoid_code_regenerate_after_attempt = 3 83 | 84 | user = User.create(paranoid_verification_code: 'abcde') 85 | user.verify_code('wrong') 86 | assert_equal 1, user.paranoid_verification_attempt 87 | user.verify_code('wrong-again') 88 | assert_equal 2, user.paranoid_verification_attempt 89 | user.verify_code('WRONG!') 90 | assert_equal 0, user.paranoid_verification_attempt 91 | 92 | Devise.paranoid_code_regenerate_after_attempt = original_regenerate 93 | end 94 | 95 | 96 | test 'by default paranoid code regenerate should have 10 attempts' do 97 | user = User.new(paranoid_verification_code: 'abcde') 98 | assert_equal 10, user.paranoid_attempts_remaining 99 | end 100 | 101 | test 'paranoid_attempts_remaining should re-callculate how many attemps remains after each wrong attempt' do 102 | original_regenerate = Devise.paranoid_code_regenerate_after_attempt 103 | Devise.paranoid_code_regenerate_after_attempt = 2 104 | 105 | user = User.create(paranoid_verification_code: 'abcde') 106 | assert_equal 2, user.paranoid_attempts_remaining 107 | 108 | user.verify_code('WRONG!') 109 | assert_equal 1, user.paranoid_attempts_remaining 110 | 111 | Devise.paranoid_code_regenerate_after_attempt = original_regenerate 112 | end 113 | 114 | test 'when code not match upon verification code too many times, reset paranoid_attempts_remaining' do 115 | original_regenerate = Devise.paranoid_code_regenerate_after_attempt 116 | Devise.paranoid_code_regenerate_after_attempt = 1 117 | 118 | user = User.create(paranoid_verification_code: 'abcde') 119 | user.verify_code('wrong') # at this point code was regenerated 120 | assert_equal Devise.paranoid_code_regenerate_after_attempt, user.paranoid_attempts_remaining 121 | 122 | Devise.paranoid_code_regenerate_after_attempt = original_regenerate 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | devise_security_extension (0.10.0) 5 | devise (>= 3.0.0, < 5.0) 6 | railties (>= 3.2.6, < 6.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionmailer (4.2.6) 12 | actionpack (= 4.2.6) 13 | actionview (= 4.2.6) 14 | activejob (= 4.2.6) 15 | mail (~> 2.5, >= 2.5.4) 16 | rails-dom-testing (~> 1.0, >= 1.0.5) 17 | actionpack (4.2.6) 18 | actionview (= 4.2.6) 19 | activesupport (= 4.2.6) 20 | rack (~> 1.6) 21 | rack-test (~> 0.6.2) 22 | rails-dom-testing (~> 1.0, >= 1.0.5) 23 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 24 | actionview (4.2.6) 25 | activesupport (= 4.2.6) 26 | builder (~> 3.1) 27 | erubis (~> 2.7.0) 28 | rails-dom-testing (~> 1.0, >= 1.0.5) 29 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 30 | activejob (4.2.6) 31 | activesupport (= 4.2.6) 32 | globalid (>= 0.3.0) 33 | activemodel (4.2.6) 34 | activesupport (= 4.2.6) 35 | builder (~> 3.1) 36 | activerecord (4.2.6) 37 | activemodel (= 4.2.6) 38 | activesupport (= 4.2.6) 39 | arel (~> 6.0) 40 | activesupport (4.2.6) 41 | i18n (~> 0.7) 42 | json (~> 1.7, >= 1.7.7) 43 | minitest (~> 5.1) 44 | thread_safe (~> 0.3, >= 0.3.4) 45 | tzinfo (~> 1.1) 46 | arel (6.0.3) 47 | ast (2.3.0) 48 | bcrypt (3.1.11) 49 | builder (3.2.2) 50 | concurrent-ruby (1.0.2) 51 | coveralls (0.8.13) 52 | json (~> 1.8) 53 | simplecov (~> 0.11.0) 54 | term-ansicolor (~> 1.3) 55 | thor (~> 0.19.1) 56 | tins (~> 1.6.0) 57 | devise (4.2.0) 58 | bcrypt (~> 3.0) 59 | orm_adapter (~> 0.1) 60 | railties (>= 4.1.0, < 5.1) 61 | responders 62 | warden (~> 1.2.3) 63 | diff-lcs (1.2.5) 64 | docile (1.1.5) 65 | easy_captcha (0.6.5) 66 | bundler (>= 1.1.0) 67 | rails (>= 3.0.0) 68 | rmagick (>= 2.13.1) 69 | rspec-rails (>= 2.8.1) 70 | simplecov (>= 0.3.8) 71 | yard (>= 0.7.0) 72 | erubis (2.7.0) 73 | globalid (0.3.6) 74 | activesupport (>= 4.1.0) 75 | i18n (0.7.0) 76 | json (1.8.3) 77 | loofah (2.0.3) 78 | nokogiri (>= 1.5.9) 79 | mail (2.6.4) 80 | mime-types (>= 1.16, < 4) 81 | mime-types (3.1) 82 | mime-types-data (~> 3.2015) 83 | mime-types-data (3.2016.0521) 84 | mini_portile2 (2.1.0) 85 | minitest (5.9.0) 86 | nokogiri (1.6.8) 87 | mini_portile2 (~> 2.1.0) 88 | pkg-config (~> 1.1.7) 89 | orm_adapter (0.5.0) 90 | parser (2.3.1.2) 91 | ast (~> 2.2) 92 | pkg-config (1.1.7) 93 | powerpack (0.1.1) 94 | rack (1.6.4) 95 | rack-test (0.6.3) 96 | rack (>= 1.0) 97 | rails (4.2.6) 98 | actionmailer (= 4.2.6) 99 | actionpack (= 4.2.6) 100 | actionview (= 4.2.6) 101 | activejob (= 4.2.6) 102 | activemodel (= 4.2.6) 103 | activerecord (= 4.2.6) 104 | activesupport (= 4.2.6) 105 | bundler (>= 1.3.0, < 2.0) 106 | railties (= 4.2.6) 107 | sprockets-rails 108 | rails-deprecated_sanitizer (1.0.3) 109 | activesupport (>= 4.2.0.alpha) 110 | rails-dom-testing (1.0.7) 111 | activesupport (>= 4.2.0.beta, < 5.0) 112 | nokogiri (~> 1.6.0) 113 | rails-deprecated_sanitizer (>= 1.0.1) 114 | rails-html-sanitizer (1.0.3) 115 | loofah (~> 2.0) 116 | rails_email_validator (0.1.4) 117 | activemodel (>= 3.0.0) 118 | railties (4.2.6) 119 | actionpack (= 4.2.6) 120 | activesupport (= 4.2.6) 121 | rake (>= 0.8.7) 122 | thor (>= 0.18.1, < 2.0) 123 | rainbow (2.1.0) 124 | rake (11.2.2) 125 | responders (2.2.0) 126 | railties (>= 4.2.0, < 5.1) 127 | rmagick (2.15.4) 128 | rspec-core (3.4.4) 129 | rspec-support (~> 3.4.0) 130 | rspec-expectations (3.4.0) 131 | diff-lcs (>= 1.2.0, < 2.0) 132 | rspec-support (~> 3.4.0) 133 | rspec-mocks (3.4.1) 134 | diff-lcs (>= 1.2.0, < 2.0) 135 | rspec-support (~> 3.4.0) 136 | rspec-rails (3.4.2) 137 | actionpack (>= 3.0, < 4.3) 138 | activesupport (>= 3.0, < 4.3) 139 | railties (>= 3.0, < 4.3) 140 | rspec-core (~> 3.4.0) 141 | rspec-expectations (~> 3.4.0) 142 | rspec-mocks (~> 3.4.0) 143 | rspec-support (~> 3.4.0) 144 | rspec-support (3.4.1) 145 | rubocop (0.40.0) 146 | parser (>= 2.3.1.0, < 3.0) 147 | powerpack (~> 0.1) 148 | rainbow (>= 1.99.1, < 3.0) 149 | ruby-progressbar (~> 1.7) 150 | unicode-display_width (~> 1.0, >= 1.0.1) 151 | ruby-progressbar (1.8.1) 152 | simplecov (0.11.2) 153 | docile (~> 1.1.0) 154 | json (~> 1.8) 155 | simplecov-html (~> 0.10.0) 156 | simplecov-html (0.10.0) 157 | sprockets (3.6.0) 158 | concurrent-ruby (~> 1.0) 159 | rack (> 1, < 3) 160 | sprockets-rails (3.0.4) 161 | actionpack (>= 4.0) 162 | activesupport (>= 4.0) 163 | sprockets (>= 3.0.0) 164 | sqlite3 (1.3.11) 165 | term-ansicolor (1.3.2) 166 | tins (~> 1.0) 167 | thor (0.19.1) 168 | thread_safe (0.3.5) 169 | tins (1.6.0) 170 | tzinfo (1.2.2) 171 | thread_safe (~> 0.1) 172 | unicode-display_width (1.0.5) 173 | warden (1.2.6) 174 | rack (>= 1.0) 175 | yard (0.8.7.6) 176 | 177 | PLATFORMS 178 | ruby 179 | 180 | DEPENDENCIES 181 | bundler (>= 1.3.0, < 2.0) 182 | coveralls 183 | devise_security_extension! 184 | easy_captcha (~> 0) 185 | minitest 186 | rails_email_validator (~> 0) 187 | rubocop (~> 0) 188 | sqlite3 (~> 1.3.10) 189 | 190 | BUNDLED WITH 191 | 1.12.5 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Devise Security Extension 2 | 3 | [![Build Status](https://travis-ci.org/phatworx/devise_security_extension.svg?branch=master)](https://travis-ci.org/phatworx/devise_security_extension) 4 | 5 | An enterprise security extension for [Devise](https://github.com/plataformatec/devise), trying to meet industrial standard security demands for web applications. 6 | 7 | It is composed of 7 additional Devise modules: 8 | 9 | * `:password_expirable` - passwords will expire after a configured time (and will need an update). You will most likely want to use `:password_expirable` together with the `:password_archivable` module to [prevent the current expired password being reused](https://github.com/phatworx/devise_security_extension/issues/175) immediately as the new password. 10 | * `:secure_validatable` - better way to validate a model (email, stronger password validation). Don't use with Devise `:validatable` module! 11 | * `:password_archivable` - save used passwords in an `old_passwords` table for history checks (don't be able to use a formerly used password) 12 | * `:session_limitable` - ensures, that there is only one session usable per account at once 13 | * `:expirable` - expires a user account after x days of inactivity (default 90 days) 14 | * `:security_questionable` - as accessible substitution for captchas (security question with captcha fallback) 15 | * `:paranoid_verification` - admin can generate verification code that user needs to fill in otherwise he wont be able to use the application. 16 | 17 | Configuration and database schema for each module below. 18 | 19 | ## Additional features 20 | 21 | * **captcha support** for `sign_up`, `sign_in`, `recover` and `unlock` (to make automated mass creation and brute forcing of accounts harder) 22 | 23 | ## Getting started 24 | 25 | Devise Security Extension works with Devise on Rails 3.2 onwards. You can add it to your Gemfile after you successfully set up Devise (see [Devise documentation](https://github.com/plataformatec/devise)) with: 26 | 27 | ```ruby 28 | gem 'devise_security_extension' 29 | ``` 30 | 31 | Run the bundle command to install it. 32 | 33 | After you installed Devise Security Extension you need to run the generator: 34 | 35 | ```console 36 | rails generate devise_security_extension:install 37 | ``` 38 | 39 | The generator adds optional configurations to `config/initializers/devise.rb`. Enable 40 | the modules you wish to use in the initializer you are ready to add Devise Security Extension modules on top of Devise modules to any of your Devise models: 41 | 42 | ```ruby 43 | devise :password_expirable, :secure_validatable, :password_archivable, :session_limitable, :expirable 44 | ``` 45 | 46 | for `:secure_validatable` you need to add 47 | 48 | ```ruby 49 | gem 'rails_email_validator' 50 | ``` 51 | 52 | ## Configuration 53 | 54 | ```ruby 55 | Devise.setup do |config| 56 | # ==> Security Extension 57 | # Configure security extension for devise 58 | 59 | # Should the password expire (e.g 3.months) 60 | # config.expire_password_after = 3.months 61 | 62 | # Need 1 char of A-Z, a-z and 0-9 63 | # config.password_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])/ 64 | 65 | # Number of old passwords in archive 66 | # config.password_archiving_count = 5 67 | 68 | # Deny old password (true, false, count) 69 | # config.deny_old_passwords = true 70 | 71 | # captcha integration for recover form 72 | # config.captcha_for_recover = true 73 | 74 | # captcha integration for sign up form 75 | # config.captcha_for_sign_up = true 76 | 77 | # captcha integration for sign in form 78 | # config.captcha_for_sign_in = true 79 | 80 | # captcha integration for unlock form 81 | # config.captcha_for_unlock = true 82 | 83 | # security_question integration for recover form 84 | # this automatically enables captchas (captcha_for_recover, as fallback) 85 | # config.security_question_for_recover = false 86 | 87 | # security_question integration for unlock form 88 | # this automatically enables captchas (captcha_for_unlock, as fallback) 89 | # config.security_question_for_unlock = false 90 | 91 | # security_question integration for confirmation form 92 | # this automatically enables captchas (captcha_for_confirmation, as fallback) 93 | # config.security_question_for_confirmation = false 94 | 95 | # ==> Configuration for :expirable 96 | # Time period for account expiry from last_activity_at 97 | # config.expire_after = 90.days 98 | end 99 | ``` 100 | 101 | ## Captcha-Support 102 | The captcha support depends on [EasyCaptcha](https://github.com/phatworx/easy_captcha). See further documention there. 103 | 104 | ### Installation 105 | 106 | 1. Add EasyCaptcha to your `Gemfile` with 107 | ```ruby 108 | gem 'easy_captcha' 109 | ``` 110 | 2. Run the initializer 111 | ```ruby 112 | rails generate easy_captcha:install 113 | ``` 114 | 3. Enable captcha - see "Configuration" of Devise Security Extension above. 115 | 4. Add the captcha in the generated devise views for each controller you have activated 116 | ```erb 117 |

<%= captcha_tag %>

118 |

<%= text_field_tag :captcha %>

119 | ``` 120 | 121 | ## Schema 122 | 123 | ### Password expirable 124 | ```ruby 125 | create_table :the_resources do |t| 126 | # other devise fields 127 | 128 | t.datetime :password_changed_at 129 | end 130 | add_index :the_resources, :password_changed_at 131 | ``` 132 | 133 | ### Password archivable 134 | ```ruby 135 | create_table :old_passwords do |t| 136 | t.string :encrypted_password, :null => false 137 | t.string :password_archivable_type, :null => false 138 | t.integer :password_archivable_id, :null => false 139 | t.datetime :created_at 140 | end 141 | add_index :old_passwords, [:password_archivable_type, :password_archivable_id], :name => :index_password_archivable 142 | ``` 143 | 144 | ### Session limitable 145 | ```ruby 146 | create_table :the_resources do |t| 147 | # other devise fields 148 | 149 | t.string :unique_session_id, :limit => 20 150 | end 151 | ``` 152 | 153 | ### Expirable 154 | ```ruby 155 | create_table :the_resources do |t| 156 | # other devise fields 157 | 158 | t.datetime :last_activity_at 159 | t.datetime :expired_at 160 | end 161 | add_index :the_resources, :last_activity_at 162 | add_index :the_resources, :expired_at 163 | ``` 164 | 165 | ### Paranoid verifiable 166 | ```ruby 167 | create_table :the_resources do |t| 168 | # other devise fields 169 | 170 | t.string :paranoid_verification_code 171 | t.integer :paranoid_verification_attempt, default: 0 172 | t.datetime :paranoid_verified_at 173 | end 174 | add_index :the_resources, :paranoid_verification_code 175 | add_index :the_resources, :paranoid_verified_at 176 | ``` 177 | 178 | [Documentation for Paranoid Verifiable module]( https://github.com/phatworx/devise_security_extension/wiki/Paranoid-Verification) 179 | 180 | ### Security questionable 181 | 182 | ```ruby 183 | # app/models/security_question.rb 184 | class SecurityQuestion < ActiveRecord::Base 185 | validates :locale, presence: true 186 | validates :name, presence: true, uniqueness: true 187 | end 188 | ``` 189 | 190 | ```ruby 191 | create_table :security_questions do |t| 192 | t.string :locale, :null => false 193 | t.string :name, :null => false 194 | end 195 | 196 | SecurityQuestion.create! locale: :de, name: 'Wie lautet der Geburstname Ihrer Mutter?' 197 | SecurityQuestion.create! locale: :de, name: 'Wo sind sie geboren?' 198 | SecurityQuestion.create! locale: :de, name: 'Wie lautet der Name Ihres ersten Haustieres?' 199 | SecurityQuestion.create! locale: :de, name: 'Was ist Ihr Lieblingsfilm?' 200 | SecurityQuestion.create! locale: :de, name: 'Was ist Ihr Lieblingsbuch?' 201 | SecurityQuestion.create! locale: :de, name: 'Was ist Ihr Lieblingstier?' 202 | SecurityQuestion.create! locale: :de, name: 'Was ist Ihr Lieblings-Reiseland?' 203 | ``` 204 | 205 | 206 | ```ruby 207 | add_column :the_resources, :security_question_id, :integer 208 | add_column :the_resources, :security_question_answer, :string 209 | ``` 210 | 211 | or 212 | 213 | ```ruby 214 | create_table :the_resources do |t| 215 | # other devise fields 216 | 217 | t.integer :security_question_id 218 | t.string :security_question_answer 219 | end 220 | ``` 221 | 222 | ## Requirements 223 | 224 | * Devise (https://github.com/plataformatec/devise) 225 | * Rails 3.2 onwards (http://github.com/rails/rails) 226 | * recommendations: 227 | * `autocomplete-off` (http://github.com/phatworx/autocomplete-off) 228 | * `easy_captcha` (http://github.com/phatworx/easy_captcha) 229 | * `rails_email_validator` (http://github.com/phatworx/rails_email_validator) 230 | 231 | 232 | ## Todo 233 | 234 | * see the github issues (feature requests) 235 | 236 | ## History 237 | * 0.1 expire passwords 238 | * 0.2 strong password validation 239 | * 0.3 password archivable with validation 240 | * 0.4 captcha support for sign_up, sign_in, recover and unlock 241 | * 0.5 session_limitable module 242 | * 0.6 expirable module 243 | * 0.7 security questionable module for recover and unlock 244 | * 0.8 Support for Rails 4 (+ variety of patches) 245 | 246 | ## Maintainers 247 | 248 | * Team Phatworx (https://github.com/phatworx) 249 | * Alexander Dreher (https://github.com/alexdreher) 250 | * Christoph Chilian (https://github.com/cc-web) 251 | * Marco Scholl (https://github.com/traxanos) 252 | * Thomas Powell (https://github.com/stringsn88keys) 253 | 254 | ## Contributing to devise_security_extension 255 | 256 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 257 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 258 | * Fork the project 259 | * Start a feature/bugfix branch 260 | * Commit and push until you are happy with your contribution 261 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 262 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 263 | 264 | ## Copyright 265 | 266 | Copyright (c) 2011-2015 Marco Scholl. See LICENSE.txt for further details. 267 | --------------------------------------------------------------------------------