devise-two-factor 1.0.0

Sign up to get free protection for your applications and to get access to all the features.

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