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.
- 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
|