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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/.gitignore +49 -0
- data/.rspec +1 -0
- data/.travis.yml +6 -0
- data/CONTRIBUTING.md +38 -0
- data/Gemfile +2 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/Rakefile +26 -0
- data/certs/tinfoil-cacert.pem +41 -0
- data/certs/tinfoilsecurity-gems-cert.pem +33 -0
- data/devise-two-factor.gemspec +39 -0
- data/lib/devise-two-factor.rb +33 -0
- data/lib/devise_two_factor/models.rb +2 -0
- data/lib/devise_two_factor/models/two_factor_authenticatable.rb +62 -0
- data/lib/devise_two_factor/models/two_factor_backupable.rb +66 -0
- data/lib/devise_two_factor/spec_helpers.rb +2 -0
- data/lib/devise_two_factor/spec_helpers/two_factor_authenticatable_shared_examples.rb +76 -0
- data/lib/devise_two_factor/spec_helpers/two_factor_backupable_shared_examples.rb +89 -0
- data/lib/devise_two_factor/strategies.rb +2 -0
- data/lib/devise_two_factor/strategies/two_factor_authenticatable.rb +26 -0
- data/lib/devise_two_factor/strategies/two_factor_backupable.rb +25 -0
- data/lib/devise_two_factor/version.rb +3 -0
- data/lib/generators/devise_two_factor/devise_two_factor_generator.rb +79 -0
- data/spec/devise/models/two_factor_authenticatable_spec.rb +16 -0
- data/spec/devise/models/two_factor_backupable_spec.rb +19 -0
- data/spec/spec_helper.rb +32 -0
- metadata +302 -0
- metadata.gz.sig +0 -0
@@ -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,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,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,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
|