two_factor_authentication 2.1.1 → 3.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.
- checksums.yaml +5 -5
- data/.github/workflows/ci.yml +77 -0
- data/.gitignore +3 -0
- data/CHANGELOG.md +37 -2
- data/Gemfile +29 -9
- data/MIGRATION_GUIDE.md +143 -0
- data/README.md +217 -28
- data/app/controllers/devise/two_factor_authentication_controller.rb +3 -2
- data/app/views/devise/two_factor_authentication/show.html.erb +4 -4
- data/config/locales/de.yml +8 -0
- data/lib/generators/active_record/templates/migration.rb +9 -1
- data/lib/generators/active_record/two_factor_authentication_generator.rb +18 -0
- data/lib/generators/two_factor_authentication/two_factor_authentication_generator.rb +1 -1
- data/lib/two_factor_authentication/controllers/helpers.rb +8 -4
- data/lib/two_factor_authentication/models/two_factor_authenticatable.rb +16 -6
- data/lib/two_factor_authentication/orm/active_record.rb +2 -0
- data/lib/two_factor_authentication/routes.rb +1 -1
- data/lib/two_factor_authentication/schema.rb +7 -7
- data/lib/two_factor_authentication/version.rb +1 -1
- data/lib/two_factor_authentication.rb +1 -2
- data/spec/controllers/two_factor_authentication_controller_spec.rb +87 -2
- data/spec/features/two_factor_authenticatable_spec.rb +119 -14
- data/spec/generators/active_record/two_factor_authentication_generator_spec.rb +52 -4
- data/spec/generators/two_factor_authentication/two_factor_authentication_generator_spec.rb +37 -0
- data/spec/lib/two_factor_authentication/models/two_factor_authenticatable_spec.rb +97 -10
- data/spec/lib/two_factor_authentication/schema_spec.rb +41 -0
- data/spec/rails_app/app/assets/config/manifest.js +2 -0
- data/spec/rails_app/app/controllers/home_controller.rb +6 -1
- data/spec/rails_app/app/models/guest_user.rb +1 -1
- data/spec/rails_app/app/models/secure_user.rb +13 -0
- data/spec/rails_app/config/application.rb +1 -1
- data/spec/rails_app/config/environments/test.rb +3 -8
- data/spec/rails_app/config/initializers/inflections.rb +4 -0
- data/spec/rails_app/config/routes.rb +2 -0
- data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +1 -1
- data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +1 -1
- data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +1 -1
- data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +1 -1
- data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +1 -1
- data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +1 -1
- data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +1 -1
- data/spec/rails_app/db/migrate/20260929000000_add_otp_columns_to_users.rb +8 -0
- data/spec/rails_app/db/migrate/20260929000001_devise_create_secure_users.rb +22 -0
- data/spec/rails_app/db/schema.rb +51 -36
- data/spec/rails_app/lib/sms_provider.rb +2 -4
- data/spec/requests/two_factor_authentication_helpers_spec.rb +48 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/authenticated_model_helper.rb +5 -30
- data/spec/support/controller_helper.rb +5 -4
- data/spec/support/features_spec_helper.rb +1 -0
- data/spec/support/totp_helper.rb +1 -1
- data/two_factor_authentication.gemspec +5 -6
- metadata +45 -28
- data/.travis.yml +0 -29
- data/spec/rails_app/config/initializers/secret_token.rb +0 -7
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
<p><%= flash[:notice] %></p>
|
|
8
8
|
|
|
9
9
|
<%= form_tag([resource_name, :two_factor_authentication], :method => :put) do %>
|
|
10
|
-
<%= text_field_tag :code %>
|
|
10
|
+
<%= text_field_tag :code, '', autofocus: true %>
|
|
11
11
|
<%= submit_tag "Submit" %>
|
|
12
12
|
<% end %>
|
|
13
13
|
|
|
14
14
|
<% if resource.direct_otp %>
|
|
15
|
-
<%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path")
|
|
15
|
+
<%= link_to "Resend Code", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
|
|
16
16
|
<% else %>
|
|
17
|
-
<%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path")
|
|
17
|
+
<%= link_to "Send me a code instead", send("resend_code_#{resource_name}_two_factor_authentication_path") %>
|
|
18
18
|
<% end %>
|
|
19
|
-
<%=
|
|
19
|
+
<%= button_to "Sign out", send("destroy_#{resource_name}_session_path"), :method => Array(Devise.sign_out_via).first %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
de:
|
|
2
|
+
devise:
|
|
3
|
+
two_factor_authentication:
|
|
4
|
+
success: "Ihre Zwei-Faktor-Authentifizierung war erfolgreich."
|
|
5
|
+
attempt_failed: "Authentifizierungsversuch fehlgeschlagen."
|
|
6
|
+
max_login_attempts_reached: "Ihr Zugang wurde ganz verweigert, da Sie Ihr Versuchslimit erreicht haben."
|
|
7
|
+
contact_administrator: "Kontaktieren Sie bitte einen Ihrer Administratoren."
|
|
8
|
+
code_has_been_sent: "Ihr Einmal-Passwort wurde verschickt."
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration
|
|
1
|
+
class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
<% if postgresql? -%>
|
|
3
|
+
disable_ddl_transaction!
|
|
4
|
+
|
|
5
|
+
<% end -%>
|
|
2
6
|
def change
|
|
3
7
|
add_column :<%= table_name %>, :second_factor_attempts_count, :integer, default: 0
|
|
4
8
|
add_column :<%= table_name %>, :encrypted_otp_secret_key, :string
|
|
@@ -8,6 +12,10 @@ class TwoFactorAuthenticationAddTo<%= table_name.camelize %> < ActiveRecord::Mig
|
|
|
8
12
|
add_column :<%= table_name %>, :direct_otp_sent_at, :datetime
|
|
9
13
|
add_column :<%= table_name %>, :totp_timestamp, :timestamp
|
|
10
14
|
|
|
15
|
+
<% if postgresql? -%>
|
|
16
|
+
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently
|
|
17
|
+
<% else -%>
|
|
11
18
|
add_index :<%= table_name %>, :encrypted_otp_secret_key, unique: true
|
|
19
|
+
<% end -%>
|
|
12
20
|
end
|
|
13
21
|
end
|
|
@@ -9,6 +9,24 @@ module ActiveRecord
|
|
|
9
9
|
migration_template "migration.rb", "db/migrate/two_factor_authentication_add_to_#{table_name}.rb"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def migration_version
|
|
15
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# CREATE INDEX CONCURRENTLY is PostgreSQL-only; other adapters reject the option.
|
|
19
|
+
def postgresql?
|
|
20
|
+
config = if ActiveRecord::Base.respond_to?(:connection_db_config)
|
|
21
|
+
ActiveRecord::Base.connection_db_config
|
|
22
|
+
else
|
|
23
|
+
ActiveRecord::Base.connection_config
|
|
24
|
+
end
|
|
25
|
+
adapter = config.respond_to?(:adapter) ? config.adapter : config[:adapter]
|
|
26
|
+
adapter.to_s.include?("postg")
|
|
27
|
+
rescue StandardError
|
|
28
|
+
false
|
|
29
|
+
end
|
|
12
30
|
end
|
|
13
31
|
end
|
|
14
32
|
end
|
|
@@ -7,7 +7,7 @@ module TwoFactorAuthenticatable
|
|
|
7
7
|
|
|
8
8
|
def inject_two_factor_authentication_content
|
|
9
9
|
path = File.join("app", "models", "#{file_path}.rb")
|
|
10
|
-
inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.
|
|
10
|
+
inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exist?(File.join(destination_root, path))
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
hook_for :orm
|
|
@@ -20,11 +20,14 @@ module TwoFactorAuthentication
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def handle_failed_second_factor(scope)
|
|
23
|
-
if request.format
|
|
23
|
+
if request.format&.html?
|
|
24
24
|
session["#{scope}_return_to"] = request.original_fullpath if request.get?
|
|
25
25
|
redirect_to two_factor_authentication_path_for(scope)
|
|
26
|
+
elsif request.format&.json?
|
|
27
|
+
session["#{scope}_return_to"] = root_path(format: :html)
|
|
28
|
+
render json: { redirect_to: two_factor_authentication_path_for(scope) }, status: :unauthorized
|
|
26
29
|
else
|
|
27
|
-
|
|
30
|
+
head :unauthorized
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -41,8 +44,9 @@ end
|
|
|
41
44
|
module Devise
|
|
42
45
|
module Controllers
|
|
43
46
|
module Helpers
|
|
44
|
-
def is_fully_authenticated?
|
|
45
|
-
|
|
47
|
+
def is_fully_authenticated?(scope = nil)
|
|
48
|
+
scope ||= devise_controller? ? resource_name : Devise.default_scope
|
|
49
|
+
!session["warden.user.#{scope}.session"].try(:[], TwoFactorAuthentication::NEED_AUTHENTICATION)
|
|
46
50
|
end
|
|
47
51
|
end
|
|
48
52
|
end
|
|
@@ -16,7 +16,7 @@ module Devise
|
|
|
16
16
|
::Devise::Models.config(
|
|
17
17
|
self, :max_login_attempts, :allowed_otp_drift_seconds, :otp_length,
|
|
18
18
|
:remember_otp_session_for_seconds, :otp_secret_encryption_key,
|
|
19
|
-
:direct_otp_length, :direct_otp_valid_for, :
|
|
19
|
+
:direct_otp_length, :direct_otp_valid_for, :delete_cookie_on_logout
|
|
20
20
|
)
|
|
21
21
|
end
|
|
22
22
|
|
|
@@ -39,9 +39,12 @@ module Devise
|
|
|
39
39
|
drift = options[:drift] || self.class.allowed_otp_drift_seconds
|
|
40
40
|
raise "authenticate_totp called with no otp_secret_key set" if totp_secret.nil?
|
|
41
41
|
totp = ROTP::TOTP.new(totp_secret, digits: digits)
|
|
42
|
-
new_timestamp = totp.
|
|
42
|
+
new_timestamp = totp.verify(
|
|
43
|
+
without_spaces(code),
|
|
44
|
+
drift_ahead: drift, drift_behind: drift, after: totp_timestamp
|
|
45
|
+
)
|
|
43
46
|
return false unless new_timestamp
|
|
44
|
-
self.totp_timestamp = new_timestamp
|
|
47
|
+
self.totp_timestamp = Time.at(new_timestamp).utc
|
|
45
48
|
true
|
|
46
49
|
end
|
|
47
50
|
|
|
@@ -89,13 +92,16 @@ module Devise
|
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
def generate_totp_secret
|
|
92
|
-
ROTP
|
|
95
|
+
# ROTP gem since version 5 to version 5.1
|
|
96
|
+
# at version 5.1 ROTP gem reinstates.
|
|
97
|
+
# Details: https://github.com/mdp/rotp/blob/master/CHANGELOG.md#510
|
|
98
|
+
ROTP::Base32.try(:random) || ROTP::Base32.random_base32
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
def create_direct_otp(options = {})
|
|
96
102
|
# Create a new random OTP and store it in the database
|
|
97
103
|
digits = options[:length] || self.class.direct_otp_length || 6
|
|
98
|
-
|
|
104
|
+
update(
|
|
99
105
|
direct_otp: random_base10(digits),
|
|
100
106
|
direct_otp_sent_at: Time.now.utc
|
|
101
107
|
)
|
|
@@ -103,6 +109,10 @@ module Devise
|
|
|
103
109
|
|
|
104
110
|
private
|
|
105
111
|
|
|
112
|
+
def without_spaces(code)
|
|
113
|
+
code.gsub(/\s/, '')
|
|
114
|
+
end
|
|
115
|
+
|
|
106
116
|
def random_base10(digits)
|
|
107
117
|
SecureRandom.random_number(10**digits).to_s.rjust(digits, '0')
|
|
108
118
|
end
|
|
@@ -112,7 +122,7 @@ module Devise
|
|
|
112
122
|
end
|
|
113
123
|
|
|
114
124
|
def clear_direct_otp
|
|
115
|
-
|
|
125
|
+
update(direct_otp: nil, direct_otp_sent_at: nil)
|
|
116
126
|
end
|
|
117
127
|
end
|
|
118
128
|
|
|
@@ -3,7 +3,7 @@ module ActionDispatch::Routing
|
|
|
3
3
|
protected
|
|
4
4
|
|
|
5
5
|
def devise_two_factor_authentication(mapping, controllers)
|
|
6
|
-
resource :two_factor_authentication, :only => [:show, :update
|
|
6
|
+
resource :two_factor_authentication, :only => [:show, :update], :path => mapping.path_names[:two_factor_authentication], :controller => controllers[:two_factor_authentication] do
|
|
7
7
|
collection { get "resend_code" }
|
|
8
8
|
end
|
|
9
9
|
end
|
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
module TwoFactorAuthentication
|
|
2
2
|
module Schema
|
|
3
3
|
def second_factor_attempts_count
|
|
4
|
-
|
|
4
|
+
integer :second_factor_attempts_count, :default => 0
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def encrypted_otp_secret_key
|
|
8
|
-
|
|
8
|
+
string :encrypted_otp_secret_key
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def encrypted_otp_secret_key_iv
|
|
12
|
-
|
|
12
|
+
string :encrypted_otp_secret_key_iv
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def encrypted_otp_secret_key_salt
|
|
16
|
-
|
|
16
|
+
string :encrypted_otp_secret_key_salt
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def direct_otp
|
|
20
|
-
|
|
20
|
+
string :direct_otp
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def direct_otp_sent_at
|
|
24
|
-
|
|
24
|
+
datetime :direct_otp_sent_at
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def totp_timestamp
|
|
28
|
-
|
|
28
|
+
timestamp :totp_timestamp
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -2,7 +2,6 @@ require 'two_factor_authentication/version'
|
|
|
2
2
|
require 'devise'
|
|
3
3
|
require 'active_support/concern'
|
|
4
4
|
require "active_model"
|
|
5
|
-
require "active_record"
|
|
6
5
|
require "active_support/core_ext/class/attribute_accessors"
|
|
7
6
|
require "cgi"
|
|
8
7
|
|
|
@@ -47,7 +46,7 @@ end
|
|
|
47
46
|
|
|
48
47
|
Devise.add_module :two_factor_authenticatable, :model => 'two_factor_authentication/models/two_factor_authenticatable', :controller => :two_factor_authentication, :route => :two_factor_authentication
|
|
49
48
|
|
|
50
|
-
require 'two_factor_authentication/orm/active_record'
|
|
49
|
+
require 'two_factor_authentication/orm/active_record' if defined?(ActiveRecord::Base)
|
|
51
50
|
require 'two_factor_authentication/routes'
|
|
52
51
|
require 'two_factor_authentication/models/two_factor_authenticatable'
|
|
53
52
|
require 'two_factor_authentication/rails'
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe Devise::TwoFactorAuthenticationController, type: :controller do
|
|
4
|
+
def post_code(code)
|
|
5
|
+
if Rails::VERSION::MAJOR >= 5
|
|
6
|
+
post :update, params: { code: code }
|
|
7
|
+
else
|
|
8
|
+
post :update, code: code
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
4
12
|
describe 'is_fully_authenticated? helper' do
|
|
5
13
|
before do
|
|
6
14
|
sign_in
|
|
@@ -9,7 +17,7 @@ describe Devise::TwoFactorAuthenticationController, type: :controller do
|
|
|
9
17
|
context 'after user enters valid OTP code' do
|
|
10
18
|
it 'returns true' do
|
|
11
19
|
controller.current_user.send_new_otp
|
|
12
|
-
|
|
20
|
+
post_code controller.current_user.direct_otp
|
|
13
21
|
expect(subject.is_fully_authenticated?).to eq true
|
|
14
22
|
end
|
|
15
23
|
end
|
|
@@ -24,10 +32,87 @@ describe Devise::TwoFactorAuthenticationController, type: :controller do
|
|
|
24
32
|
|
|
25
33
|
context 'when user enters an invalid OTP' do
|
|
26
34
|
it 'returns false' do
|
|
27
|
-
|
|
35
|
+
post_code '12345'
|
|
28
36
|
|
|
29
37
|
expect(subject.is_fully_authenticated?).to eq false
|
|
30
38
|
end
|
|
31
39
|
end
|
|
32
40
|
end
|
|
41
|
+
|
|
42
|
+
describe 'PUT update' do
|
|
43
|
+
render_views
|
|
44
|
+
|
|
45
|
+
let(:user) { create_user }
|
|
46
|
+
|
|
47
|
+
before do
|
|
48
|
+
sign_in user
|
|
49
|
+
user.send_new_otp
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'responds with 422 and re-renders the form for an invalid code' do
|
|
53
|
+
post_code '00000000'
|
|
54
|
+
|
|
55
|
+
expect(response).to have_http_status(422)
|
|
56
|
+
expect(response.body).to include('Enter the code that was sent to you')
|
|
57
|
+
expect(user.reload.second_factor_attempts_count).to eq(1)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'responds with 422 and signs out when the last attempt fails' do
|
|
61
|
+
user.update!(second_factor_attempts_count: User.max_login_attempts - 1)
|
|
62
|
+
|
|
63
|
+
post_code '00000000'
|
|
64
|
+
|
|
65
|
+
expect(response).to have_http_status(422)
|
|
66
|
+
expect(response.body).to include('Access completely denied')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'resets the failed attempt counter after a valid code' do
|
|
70
|
+
user.update!(second_factor_attempts_count: 1)
|
|
71
|
+
|
|
72
|
+
post_code user.reload.direct_otp
|
|
73
|
+
|
|
74
|
+
expect(response).to redirect_to(root_path)
|
|
75
|
+
expect(user.reload.second_factor_attempts_count).to eq(0)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe 'GET resend_code' do
|
|
80
|
+
let(:user) { create_user }
|
|
81
|
+
|
|
82
|
+
before { sign_in user }
|
|
83
|
+
|
|
84
|
+
it 'sends a new code and redirects back to the form' do
|
|
85
|
+
get :resend_code
|
|
86
|
+
|
|
87
|
+
expect(SMSProvider.messages.size).to eq(1)
|
|
88
|
+
expect(SMSProvider.last_message.body).to eq(user.reload.direct_otp)
|
|
89
|
+
expect(response).to redirect_to(user_two_factor_authentication_path)
|
|
90
|
+
expect(flash[:notice]).to eq('Your authentication code has been sent.')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
context 'with a second devise scope' do
|
|
95
|
+
let(:secure_user) { create_secure_user }
|
|
96
|
+
|
|
97
|
+
before do
|
|
98
|
+
@request.env['devise.mapping'] = Devise.mappings[:secure_user]
|
|
99
|
+
sign_in secure_user, scope: :secure_user
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'reports the scope of the current resource as not fully authenticated' do
|
|
103
|
+
get :show
|
|
104
|
+
|
|
105
|
+
expect(response).to have_http_status(200)
|
|
106
|
+
expect(subject.is_fully_authenticated?).to eq false
|
|
107
|
+
expect(subject.is_fully_authenticated?(:secure_user)).to eq false
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'reports the scope as fully authenticated after a valid code' do
|
|
111
|
+
secure_user.send_new_otp
|
|
112
|
+
post_code secure_user.reload.direct_otp
|
|
113
|
+
|
|
114
|
+
expect(subject.is_fully_authenticated?).to eq true
|
|
115
|
+
expect(subject.is_fully_authenticated?(:secure_user)).to eq true
|
|
116
|
+
end
|
|
117
|
+
end
|
|
33
118
|
end
|
|
@@ -3,20 +3,14 @@ include AuthenticatedModelHelper
|
|
|
3
3
|
|
|
4
4
|
feature "User of two factor authentication" do
|
|
5
5
|
context 'sending two factor authentication code via SMS' do
|
|
6
|
-
shared_examples 'sends and authenticates code' do |
|
|
7
|
-
before do
|
|
8
|
-
user.reload
|
|
9
|
-
if type == 'encrypted'
|
|
10
|
-
allow(User).to receive(:has_one_time_password).with(encrypted: true)
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
|
|
6
|
+
shared_examples 'sends and authenticates code' do |scope|
|
|
14
7
|
it 'does not send an SMS before the user has signed in' do
|
|
8
|
+
user
|
|
15
9
|
expect(SMSProvider.messages).to be_empty
|
|
16
10
|
end
|
|
17
11
|
|
|
18
12
|
it 'sends code via SMS after sign in' do
|
|
19
|
-
visit
|
|
13
|
+
visit send("new_#{scope}_session_path")
|
|
20
14
|
complete_sign_in_form_for(user)
|
|
21
15
|
|
|
22
16
|
expect(page).to have_content 'Enter the code that was sent to you'
|
|
@@ -28,11 +22,9 @@ feature "User of two factor authentication" do
|
|
|
28
22
|
end
|
|
29
23
|
|
|
30
24
|
it 'authenticates a valid OTP code' do
|
|
31
|
-
visit
|
|
25
|
+
visit send("new_#{scope}_session_path")
|
|
32
26
|
complete_sign_in_form_for(user)
|
|
33
27
|
|
|
34
|
-
expect(page).to have_content('You are signed in as Marissa')
|
|
35
|
-
|
|
36
28
|
fill_in 'code', with: SMSProvider.last_message.body
|
|
37
29
|
click_button 'Submit'
|
|
38
30
|
|
|
@@ -42,10 +34,101 @@ feature "User of two factor authentication" do
|
|
|
42
34
|
|
|
43
35
|
expect(current_path).to eq root_path
|
|
44
36
|
end
|
|
37
|
+
|
|
38
|
+
it 'sends a new code when the user asks to resend it' do
|
|
39
|
+
visit send("new_#{scope}_session_path")
|
|
40
|
+
complete_sign_in_form_for(user)
|
|
41
|
+
|
|
42
|
+
click_link 'Resend Code'
|
|
43
|
+
|
|
44
|
+
expect(page).to have_content('Your authentication code has been sent.')
|
|
45
|
+
expect(SMSProvider.messages.size).to eq(2)
|
|
46
|
+
|
|
47
|
+
fill_in 'code', with: SMSProvider.last_message.body
|
|
48
|
+
click_button 'Submit'
|
|
49
|
+
|
|
50
|
+
expect(page).to have_content('Two factor authentication successful.')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context 'for a user with a plain OTP secret' do
|
|
55
|
+
let(:user) { create_user }
|
|
56
|
+
|
|
57
|
+
it_behaves_like 'sends and authenticates code', :user
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context 'for a second devise scope with an encrypted OTP secret' do
|
|
61
|
+
let(:user) { create_secure_user }
|
|
62
|
+
|
|
63
|
+
it_behaves_like 'sends and authenticates code', :secure_user
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
context 'authenticating with an authenticator app (TOTP)' do
|
|
68
|
+
shared_examples 'authenticates a TOTP code' do |scope|
|
|
69
|
+
let(:secret) { user.generate_totp_secret }
|
|
70
|
+
|
|
71
|
+
before do
|
|
72
|
+
user.update!(otp_secret_key: secret)
|
|
73
|
+
visit send("new_#{scope}_session_path")
|
|
74
|
+
complete_sign_in_form_for(user)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'asks for the authenticator code without sending an SMS' do
|
|
78
|
+
expect(page).to have_content('Enter the code from your authenticator app')
|
|
79
|
+
expect(SMSProvider.messages).to be_empty
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'accepts a valid TOTP code' do
|
|
83
|
+
fill_in 'code', with: ROTP::TOTP.new(secret).now
|
|
84
|
+
click_button 'Submit'
|
|
85
|
+
|
|
86
|
+
expect(page).to have_content('Two factor authentication successful.')
|
|
87
|
+
expect(current_path).to eq root_path
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'rejects an invalid TOTP code' do
|
|
91
|
+
fill_in 'code', with: 'incorrect'
|
|
92
|
+
click_button 'Submit'
|
|
93
|
+
|
|
94
|
+
expect(page).to have_content('Attempt failed')
|
|
95
|
+
expect(page).to have_content('Enter the code from your authenticator app')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'sends a code by SMS on request instead' do
|
|
99
|
+
click_link 'Send me a code instead'
|
|
100
|
+
|
|
101
|
+
expect(SMSProvider.messages.size).to eq(1)
|
|
102
|
+
fill_in 'code', with: SMSProvider.last_message.body
|
|
103
|
+
click_button 'Submit'
|
|
104
|
+
|
|
105
|
+
expect(page).to have_content('Two factor authentication successful.')
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
context 'for a user with a plain OTP secret' do
|
|
110
|
+
let(:user) { create_user }
|
|
111
|
+
|
|
112
|
+
it_behaves_like 'authenticates a TOTP code', :user
|
|
45
113
|
end
|
|
46
114
|
|
|
47
|
-
|
|
48
|
-
|
|
115
|
+
context 'for a second devise scope with an encrypted OTP secret' do
|
|
116
|
+
let(:user) { create_secure_user }
|
|
117
|
+
|
|
118
|
+
it_behaves_like 'authenticates a TOTP code', :secure_user
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
scenario 'user who does not need two factor authentication goes straight in' do
|
|
123
|
+
allow_any_instance_of(User).to receive(:need_two_factor_authentication?).and_return(false)
|
|
124
|
+
user = create_user
|
|
125
|
+
|
|
126
|
+
visit new_user_session_path
|
|
127
|
+
complete_sign_in_form_for(user)
|
|
128
|
+
visit dashboard_path
|
|
129
|
+
|
|
130
|
+
expect(page).to have_content('Your Personal Dashboard')
|
|
131
|
+
expect(SMSProvider.messages).to be_empty
|
|
49
132
|
end
|
|
50
133
|
|
|
51
134
|
scenario "must be logged in" do
|
|
@@ -94,6 +177,28 @@ feature "User of two factor authentication" do
|
|
|
94
177
|
expect(page).to have_content("You are signed out")
|
|
95
178
|
end
|
|
96
179
|
|
|
180
|
+
scenario "failed attempts are reset after a successful code" do
|
|
181
|
+
visit user_two_factor_authentication_path
|
|
182
|
+
|
|
183
|
+
fill_in "code", with: "incorrect"
|
|
184
|
+
click_button "Submit"
|
|
185
|
+
expect(user.reload.second_factor_attempts_count).to eq(1)
|
|
186
|
+
|
|
187
|
+
fill_in "code", with: SMSProvider.last_message.body
|
|
188
|
+
click_button "Submit"
|
|
189
|
+
|
|
190
|
+
expect(page).to have_content("Two factor authentication successful.")
|
|
191
|
+
expect(user.reload.second_factor_attempts_count).to eq(0)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
scenario "can sign out from the two factor page" do
|
|
195
|
+
visit user_two_factor_authentication_path
|
|
196
|
+
|
|
197
|
+
click_button "Sign out"
|
|
198
|
+
|
|
199
|
+
expect(page).to have_content("You are signed out")
|
|
200
|
+
end
|
|
201
|
+
|
|
97
202
|
scenario "cannot retry authentication after max attempts" do
|
|
98
203
|
user.update_attribute(:second_factor_attempts_count, User.max_login_attempts)
|
|
99
204
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
|
+
require 'tmpdir'
|
|
2
3
|
|
|
3
4
|
require 'generators/active_record/two_factor_authentication_generator'
|
|
4
5
|
|
|
5
6
|
describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :generator do
|
|
6
|
-
destination File.
|
|
7
|
+
destination File.join(Dir.tmpdir, 'two_factor_authentication_generator')
|
|
7
8
|
|
|
8
9
|
before do
|
|
9
10
|
prepare_destination
|
|
@@ -21,16 +22,63 @@ describe ActiveRecord::Generators::TwoFactorAuthenticationGenerator, type: :gene
|
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
describe 'the migration' do
|
|
24
|
-
subject { migration_file('db/migrate/two_factor_authentication_add_to_users.rb') }
|
|
25
|
+
subject { Pathname.new(migration_file('db/migrate/two_factor_authentication_add_to_users.rb')) }
|
|
25
26
|
|
|
26
|
-
it { is_expected.to exist }
|
|
27
|
+
it { is_expected.to satisfy { |path| File.exist?(path) } }
|
|
27
28
|
it { is_expected.to be_a_migration }
|
|
29
|
+
it { is_expected.to contain /< ActiveRecord::Migration\[#{ActiveRecord::VERSION::MAJOR}\.#{ActiveRecord::VERSION::MINOR}\]/ }
|
|
28
30
|
it { is_expected.to contain /def change/ }
|
|
29
31
|
it { is_expected.to contain /add_column :users, :second_factor_attempts_count, :integer, default: 0/ }
|
|
30
32
|
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key, :string/ }
|
|
31
33
|
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_iv, :string/ }
|
|
32
34
|
it { is_expected.to contain /add_column :users, :encrypted_otp_secret_key_salt, :string/ }
|
|
33
|
-
it { is_expected.to contain /
|
|
35
|
+
it { is_expected.to contain /add_column :users, :direct_otp, :string/ }
|
|
36
|
+
it { is_expected.to contain /add_column :users, :direct_otp_sent_at, :datetime/ }
|
|
37
|
+
it { is_expected.to contain /add_column :users, :totp_timestamp, :timestamp/ }
|
|
38
|
+
it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true$/ }
|
|
39
|
+
it { is_expected.not_to contain /concurrently/ }
|
|
40
|
+
it { is_expected.not_to contain /disable_ddl_transaction!/ }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe 'on PostgreSQL' do
|
|
45
|
+
subject { Pathname.new(migration_file('db/migrate/two_factor_authentication_add_to_users.rb')) }
|
|
46
|
+
|
|
47
|
+
before do
|
|
48
|
+
gen = generator %w(users)
|
|
49
|
+
allow(gen).to receive(:postgresql?).and_return(true)
|
|
50
|
+
gen.invoke_all
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it { is_expected.to contain /disable_ddl_transaction!/ }
|
|
54
|
+
it { is_expected.to contain /add_index :users, :encrypted_otp_secret_key, unique: true, algorithm: :concurrently/ }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe 'running the generated migration' do
|
|
58
|
+
let(:connection) { ActiveRecord::Base.connection }
|
|
59
|
+
|
|
60
|
+
before do
|
|
61
|
+
connection.create_table(:accounts) { |t| t.string :email }
|
|
62
|
+
run_generator %w(accounts)
|
|
63
|
+
load migration_file('db/migrate/two_factor_authentication_add_to_accounts.rb')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
after do
|
|
67
|
+
connection.drop_table(:accounts, if_exists: true)
|
|
68
|
+
Object.send(:remove_const, :TwoFactorAuthenticationAddToAccounts)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'adds the two factor columns and index' do
|
|
72
|
+
ActiveRecord::Migration.suppress_messages do
|
|
73
|
+
TwoFactorAuthenticationAddToAccounts.migrate(:up)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
expect(connection.columns(:accounts).map(&:name)).to include(
|
|
77
|
+
'second_factor_attempts_count', 'encrypted_otp_secret_key',
|
|
78
|
+
'encrypted_otp_secret_key_iv', 'encrypted_otp_secret_key_salt',
|
|
79
|
+
'direct_otp', 'direct_otp_sent_at', 'totp_timestamp'
|
|
80
|
+
)
|
|
81
|
+
expect(connection.index_exists?(:accounts, :encrypted_otp_secret_key, unique: true)).to eq(true)
|
|
34
82
|
end
|
|
35
83
|
end
|
|
36
84
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
require 'generators/two_factor_authentication/two_factor_authentication_generator'
|
|
4
|
+
|
|
5
|
+
describe TwoFactorAuthenticatable::Generators::TwoFactorAuthenticationGenerator, type: :generator do
|
|
6
|
+
destination File.expand_path('../../../tmp/generators', __dir__)
|
|
7
|
+
|
|
8
|
+
let(:model_path) { File.join(destination_root, 'app/models/account.rb') }
|
|
9
|
+
|
|
10
|
+
before do
|
|
11
|
+
prepare_destination
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(model_path))
|
|
13
|
+
File.write(model_path, <<~RUBY)
|
|
14
|
+
class Account < ApplicationRecord
|
|
15
|
+
devise :database_authenticatable, :validatable
|
|
16
|
+
end
|
|
17
|
+
RUBY
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'adds :two_factor_authenticatable to the devise call in the model' do
|
|
21
|
+
run_generator %w(account --orm=active_record)
|
|
22
|
+
|
|
23
|
+
expect(File.read(model_path)).to include('devise :two_factor_authenticatable, :database_authenticatable, :validatable')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'generates the migration through the orm hook' do
|
|
27
|
+
run_generator %w(account --orm=active_record)
|
|
28
|
+
|
|
29
|
+
expect(Pathname.new(migration_file('db/migrate/two_factor_authentication_add_to_accounts.rb'))).to exist
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'does not fail when the model file is missing' do
|
|
33
|
+
File.delete(model_path)
|
|
34
|
+
|
|
35
|
+
expect { run_generator %w(account --orm=active_record) }.not_to raise_error
|
|
36
|
+
end
|
|
37
|
+
end
|