devise-two-factor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of devise-two-factor might be problematic. Click here for more details.

@@ -0,0 +1,2 @@
1
+ require 'devise_two_factor/models/two_factor_authenticatable'
2
+ require 'devise_two_factor/models/two_factor_backupable'
@@ -0,0 +1,62 @@
1
+ require 'active_model'
2
+ require 'attr_encrypted'
3
+ require 'rotp'
4
+
5
+ module Devise
6
+ module Models
7
+ module TwoFactorAuthenticatable
8
+ extend ActiveSupport::Concern
9
+ include Devise::Models::DatabaseAuthenticatable
10
+
11
+ included do
12
+ attr_encrypted :otp_secret, :key => self.otp_secret_encryption_key,
13
+ :mode => :per_attribute_iv_and_salt
14
+
15
+ attr_accessor :otp_attempt
16
+ end
17
+
18
+ def self.required_fields(klass)
19
+ [:encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt]
20
+ end
21
+
22
+ # This defaults to the model's otp_secret
23
+ # If this hasn't been generated yet, pass a secret as an option
24
+ def valid_otp?(code, options = {})
25
+ otp_secret = options[:otp_secret] || self.otp_secret
26
+ return false unless otp_secret.present?
27
+
28
+ totp = self.otp(otp_secret)
29
+ totp.verify_with_drift(code, self.class.otp_allowed_drift)
30
+ end
31
+
32
+ def otp(otp_secret = self.otp_secret)
33
+ ROTP::TOTP.new(otp_secret)
34
+ end
35
+
36
+ def current_otp
37
+ otp.at(Time.now)
38
+ end
39
+
40
+ def otp_provisioning_uri(account, options = {})
41
+ otp_secret = options[:otp_secret] || self.otp_secret
42
+ ROTP::TOTP.new(otp_secret, options).provisioning_uri(account)
43
+ end
44
+
45
+ def clean_up_passwords
46
+ self.otp_attempt = nil
47
+ end
48
+
49
+ protected
50
+
51
+ module ClassMethods
52
+ Devise::Models.config(self, :otp_secret_length,
53
+ :otp_allowed_drift,
54
+ :otp_secret_encryption_key)
55
+
56
+ def generate_otp_secret(otp_secret_length = self.otp_secret_length)
57
+ ROTP::Base32.random_base32(otp_secret_length)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,66 @@
1
+ require 'active_model'
2
+
3
+ module Devise
4
+ module Models
5
+ # TwoFactorBackupable allows a user to generate backup codes which
6
+ # provide one-time access to their account in the event that they have
7
+ # lost access to their two-factor device
8
+ module TwoFactorBackupable
9
+ extend ActiveSupport::Concern
10
+
11
+ def self.required_fields(klass)
12
+ [:otp_backup_codes]
13
+ end
14
+
15
+ # 1) Invalidates all existing backup codes
16
+ # 2) Generates otp_number_of_backup_codes backup codes
17
+ # 3) Stores the hashed backup codes in the database
18
+ # 4) Returns a plaintext array of the generated backup codes
19
+ def generate_otp_backup_codes!
20
+ codes = []
21
+ number_of_codes = self.class.otp_number_of_backup_codes
22
+ code_length = self.class.otp_backup_code_length
23
+
24
+ number_of_codes.times do
25
+ codes << SecureRandom.hex(code_length / 2) # Hexstring has length 2*n
26
+ end
27
+
28
+ hashed_codes = codes.map { |code| Devise.bcrypt self.class, code }
29
+ self.otp_backup_codes = hashed_codes
30
+
31
+ codes
32
+ end
33
+
34
+ # Returns true and invalidates the given code
35
+ # iff that code is a valid backup code.
36
+ def invalidate_otp_backup_code!(code)
37
+ codes = self.otp_backup_codes || []
38
+
39
+ codes.each do |backup_code|
40
+ # We hashed the code with Devise.bcrypt, so if Devise changes that
41
+ # method, we'll have to adjust our comparison here to match it
42
+ # TODO Fork Devise and encapsulate this logic in a helper
43
+ bcrypt = ::BCrypt::Password.new(backup_code)
44
+ hashed_code = ::BCrypt::Engine.hash_secret("#{code}#{self.class.pepper}",
45
+ bcrypt.salt)
46
+
47
+ next unless Devise.secure_compare(hashed_code, backup_code)
48
+
49
+ codes.delete(backup_code)
50
+ self.otp_backup_codes = codes
51
+ return true
52
+ end
53
+
54
+ false
55
+ end
56
+
57
+ protected
58
+
59
+ module ClassMethods
60
+ Devise::Models.config(self, :otp_backup_code_length,
61
+ :otp_number_of_backup_codes,
62
+ :pepper)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,2 @@
1
+ require 'devise_two_factor/spec_helpers/two_factor_authenticatable_shared_examples'
2
+ require 'devise_two_factor/spec_helpers/two_factor_backupable_shared_examples'
@@ -0,0 +1,76 @@
1
+ shared_examples 'two_factor_authenticatable' do
2
+ before :each do
3
+ subject.otp_secret = subject.class.generate_otp_secret
4
+ end
5
+
6
+ describe 'required_fields' do
7
+ it 'should have the attr_encrypted fields for otp_secret' do
8
+ Devise::Models::TwoFactorAuthenticatable.required_fields(subject.class).should =~ ([:encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt])
9
+ end
10
+ end
11
+
12
+ describe '#otp_secret' do
13
+ it 'should be of the configured length' do
14
+ subject.otp_secret.length.should eq(subject.class.otp_secret_length)
15
+ end
16
+
17
+ it 'stores the encrypted otp_secret' do
18
+ subject.encrypted_otp_secret.should_not be_nil
19
+ end
20
+
21
+ it 'stores an iv for otp_secret' do
22
+ subject.encrypted_otp_secret_iv.should_not be_nil
23
+ end
24
+
25
+ it 'stores a salt for otp_secret' do
26
+ subject.encrypted_otp_secret_salt.should_not be_nil
27
+ end
28
+ end
29
+
30
+ describe '#valid_otp?' do
31
+ let(:otp_secret) { '2z6hxkdwi3uvrnpn' }
32
+
33
+ before :each do
34
+ Timecop.freeze(Time.current)
35
+ subject.otp_secret = otp_secret
36
+ end
37
+
38
+ after :each do
39
+ Timecop.return
40
+ end
41
+
42
+ it 'validates a precisely correct OTP' do
43
+ otp = ROTP::TOTP.new(otp_secret).at(Time.now)
44
+ subject.valid_otp?(otp).should be_true
45
+ end
46
+
47
+ it 'validates an OTP within the allowed drift' do
48
+ otp = ROTP::TOTP.new(otp_secret).at(Time.now + subject.class.otp_allowed_drift, true)
49
+ subject.valid_otp?(otp).should be_true
50
+ end
51
+
52
+ it 'does not validate an OTP above the allowed drift' do
53
+ otp = ROTP::TOTP.new(otp_secret).at(Time.now + subject.class.otp_allowed_drift * 2, true)
54
+ subject.valid_otp?(otp).should be_false
55
+ end
56
+
57
+ it 'does not validate an OTP below the allowed drift' do
58
+ otp = ROTP::TOTP.new(otp_secret).at(Time.now - subject.class.otp_allowed_drift * 2, true)
59
+ subject.valid_otp?(otp).should be_false
60
+ end
61
+ end
62
+
63
+ describe '#otp_provisioning_uri' do
64
+ let(:otp_secret_length) { subject.class.otp_secret_length }
65
+ let(:account) { Faker::Internet.email }
66
+ let(:issuer) { "Tinfoil" }
67
+
68
+ it "should return uri with specified account" do
69
+ subject.otp_provisioning_uri(account).should match(%r{otpauth://totp/#{account}\?secret=\w{#{otp_secret_length}}})
70
+ end
71
+
72
+ it 'should return uri with issuer option' do
73
+ subject.otp_provisioning_uri(account, issuer: issuer).should match(%r{otpauth://totp/#{account}\?issuer=#{issuer}&secret=\w{#{otp_secret_length}}$})
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,89 @@
1
+ shared_examples 'two_factor_backupable' do
2
+ describe 'required_fields' do
3
+ it 'has the attr_encrypted fields for otp_backup_codes' do
4
+ Devise::Models::TwoFactorBackupable.required_fields(subject.class).should =~ [:otp_backup_codes]
5
+ end
6
+ end
7
+
8
+ describe '#generate_otp_backup_codes!' do
9
+ context 'with no existing recovery codes' do
10
+ before do
11
+ @plaintext_codes = subject.generate_otp_backup_codes!
12
+ end
13
+
14
+ it 'generates the correct number of new recovery codes' do
15
+ subject.otp_backup_codes.length.should
16
+ eq(subject.class.otp_number_of_backup_codes)
17
+ end
18
+
19
+ it 'generates recovery codes of the correct length' do
20
+ @plaintext_codes.each do |code|
21
+ code.length.should eq(subject.class.otp_backup_code_length)
22
+ end
23
+ end
24
+
25
+ it 'generates distinct recovery codes' do
26
+ @plaintext_codes.uniq.should =~ @plaintext_codes
27
+ end
28
+
29
+ it 'stores the codes as BCrypt hashes' do
30
+ subject.otp_backup_codes.each do |code|
31
+ # $algorithm$cost$(22 character salt + 31 character hash)
32
+ code.should =~ /\A\$[0-9a-z]{2}\$[0-9]{2}\$[A-Za-z0-9\.\/]{53}\z/
33
+ end
34
+ end
35
+ end
36
+
37
+ context 'with existing recovery codes' do
38
+ let(:old_codes) { Faker::Lorem.words }
39
+ let(:old_codes_hashed) { old_codes.map { |x| Devise.bcrypt subject.class, x } }
40
+
41
+ before do
42
+ subject.otp_backup_codes = old_codes_hashed
43
+ @plaintext_codes = subject.generate_otp_backup_codes!
44
+ end
45
+
46
+ it 'invalidates the existing recovery codes' do
47
+ (subject.otp_backup_codes & old_codes_hashed).should =~ []
48
+ end
49
+ end
50
+ end
51
+
52
+ describe '#invalidate_otp_backup_code!' do
53
+ before do
54
+ @plaintext_codes = subject.generate_otp_backup_codes!
55
+ end
56
+
57
+ context 'given an invalid recovery code' do
58
+ it 'returns false' do
59
+ subject.invalidate_otp_backup_code!('password').should be_false
60
+ end
61
+ end
62
+
63
+ context 'given a valid recovery code' do
64
+ it 'returns true' do
65
+ @plaintext_codes.each do |code|
66
+ subject.invalidate_otp_backup_code!(code).should be_true
67
+ end
68
+ end
69
+
70
+ it 'invalidates that recovery code' do
71
+ code = @plaintext_codes.sample
72
+
73
+ subject.invalidate_otp_backup_code!(code)
74
+ subject.invalidate_otp_backup_code!(code).should be_false
75
+ end
76
+
77
+ it 'does not invalidate the other recovery codes' do
78
+ code = @plaintext_codes.sample
79
+ subject.invalidate_otp_backup_code!(code)
80
+
81
+ @plaintext_codes.delete(code)
82
+
83
+ @plaintext_codes.each do |code|
84
+ subject.invalidate_otp_backup_code!(code).should be_true
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,2 @@
1
+ require 'devise_two_factor/strategies/two_factor_authenticatable'
2
+ require 'devise_two_factor/strategies/two_factor_backupable'
@@ -0,0 +1,26 @@
1
+ module Devise
2
+ module Strategies
3
+ class TwoFactorAuthenticatable < Devise::Strategies::DatabaseAuthenticatable
4
+
5
+ def authenticate!
6
+ resource = mapping.to.find_for_database_authentication(authentication_hash)
7
+ # We authenticate in two cases:
8
+ # 1. The password and the OTP are correct
9
+ # 2. The password is correct, and OTP is not required for login
10
+ # We check the OTP, then defer to DatabaseAuthenticatable
11
+ if validate(resource) { !resource.otp_required_for_login ||
12
+ resource.valid_otp?(params[scope]['otp_attempt']) }
13
+ super
14
+ end
15
+
16
+ fail(:not_found_in_database) unless resource
17
+
18
+ # We want to cascade to the next strategy if this one fails,
19
+ # but database authenticatable automatically halts on a bad password
20
+ @halted = false if @result == :failure
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ Warden::Strategies.add(:two_factor_authenticatable, Devise::Strategies::TwoFactorAuthenticatable)
@@ -0,0 +1,25 @@
1
+ module Devise
2
+ module Strategies
3
+ class TwoFactorBackupable < Devise::Strategies::DatabaseAuthenticatable
4
+
5
+ def authenticate!
6
+ resource = mapping.to.find_for_database_authentication(authentication_hash)
7
+
8
+ if validate(resource) { resource.invalidate_otp_backup_code!(params[scope]['otp_attempt']) }
9
+ # Devise fails to authenticate invalidated resources, but if we've
10
+ # gotten here, the object changed (Since we deleted a recovery code)
11
+ resource.save!
12
+ super
13
+ end
14
+
15
+ fail(:not_found_in_database) unless resource
16
+
17
+ # We want to cascade to the next strategy if this one fails,
18
+ # but database authenticatable automatically halts on a bad password
19
+ @halted = false if @result == :failure
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ Warden::Strategies.add(:two_factor_backupable, Devise::Strategies::TwoFactorBackupable)
@@ -0,0 +1,3 @@
1
+ module DeviseTwoFactor
2
+ VERSION = '1.0.0'.freeze
3
+ end
@@ -0,0 +1,79 @@
1
+ require 'rails/generators'
2
+
3
+ module DeviseTwoFactor
4
+ module Generators
5
+ class DeviseTwoFactorGenerator < Rails::Generators::NamedBase
6
+ argument :encryption_key_env, :type => :string, :required => true
7
+
8
+ desc 'Creates a migration to add the required attributes to NAME, and ' \
9
+ 'adds the necessary Devise directives to the model'
10
+
11
+ def install_devise_two_factor
12
+ create_devise_two_factor_migration
13
+ inject_strategies_into_warden_config
14
+ inject_devise_directives_into_model
15
+ end
16
+
17
+ private
18
+
19
+ def create_devise_two_factor_migration
20
+ migration_arguments = [
21
+ "add_devise_two_factor_to_#{plural_name}",
22
+ "encrypted_otp_secret:string",
23
+ "encrypted_otp_secret_iv:string",
24
+ "encrypted_otp_secret_salt:string",
25
+ "otp_required_for_login:boolean"
26
+ ]
27
+
28
+ Rails::Generators.invoke('active_record:migration', migration_arguments)
29
+ end
30
+
31
+ def inject_strategies_into_warden_config
32
+ config_path = File.join('config', 'initializers', 'devise.rb')
33
+
34
+ content = " config.warden do |manager|\n" \
35
+ " manager.default_strategies(:scope => :#{singular_table_name}).unshift :two_factor_authenticatable\n" \
36
+ " end\n\n"
37
+
38
+ inject_into_file(config_path, content, after: "Devise.setup do |config|\n")
39
+ end
40
+
41
+ def inject_devise_directives_into_model
42
+ model_path = File.join('app', 'models', "#{file_path}.rb")
43
+
44
+ class_path = if namespaced?
45
+ class_name.to_s.split("::")
46
+ else
47
+ [class_name]
48
+ end
49
+
50
+ indent_depth = class_path.size
51
+
52
+ content = [
53
+ "devise :two_factor_authenticatable,",
54
+ " :otp_secret_encryption_key => ENV['#{encryption_key_env}']\n"
55
+ ]
56
+
57
+ content << "attr_accessible :otp_attempt\n" if needs_attr_accessible?
58
+ content = content.map { |line| " " * indent_depth + line }.join("\n") << "\n"
59
+
60
+ inject_into_class(model_path, class_path.last, content)
61
+
62
+ # Remove :database_authenticatable from the list of loaded models
63
+ gsub_file(model_path, /(devise.*):(, )?database_authenticatable(, )?/, '\1\2')
64
+ end
65
+
66
+ def needs_attr_accessible?
67
+ !strong_parameters_enabled? && mass_assignment_security_enabled?
68
+ end
69
+
70
+ def strong_parameters_enabled?
71
+ defined?(ActionController::StrongParameters)
72
+ end
73
+
74
+ def mass_assignment_security_enabled?
75
+ defined?(ActiveModel::MassAssignmentSecurity)
76
+ end
77
+ end
78
+ end
79
+ end