devise-multi-factor 3.1.5
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 +7 -0
- data/.codeclimate.yml +21 -0
- data/.github/workflows/gem-push.yml +42 -0
- data/.gitignore +23 -0
- data/.rubocop.yml +295 -0
- data/.travis.yml +28 -0
- data/CHANGELOG.md +119 -0
- data/Gemfile +32 -0
- data/LICENSE +19 -0
- data/README.md +322 -0
- data/Rakefile +12 -0
- data/app/controllers/devise/totp_controller.rb +79 -0
- data/app/controllers/devise/two_factor_authentication_controller.rb +84 -0
- data/app/views/devise/two_factor_authentication/max_login_attempts_reached.html.erb +3 -0
- data/app/views/devise/two_factor_authentication/new.html.erb +14 -0
- data/app/views/devise/two_factor_authentication/show.html.erb +19 -0
- data/config/locales/de.yml +8 -0
- data/config/locales/en.yml +8 -0
- data/config/locales/es.yml +8 -0
- data/config/locales/fr.yml +8 -0
- data/config/locales/ru.yml +8 -0
- data/devise-multi-factor.gemspec +40 -0
- data/lib/devise-multi-factor.rb +1 -0
- data/lib/devise_multi_factor.rb +56 -0
- data/lib/devise_multi_factor/controllers/helpers.rb +57 -0
- data/lib/devise_multi_factor/hooks/two_factor_authenticatable.rb +17 -0
- data/lib/devise_multi_factor/models/totp_enrollable.rb +7 -0
- data/lib/devise_multi_factor/models/two_factor_authenticatable.rb +142 -0
- data/lib/devise_multi_factor/orm/active_record.rb +14 -0
- data/lib/devise_multi_factor/rails.rb +7 -0
- data/lib/devise_multi_factor/routes.rb +15 -0
- data/lib/devise_multi_factor/schema.rb +23 -0
- data/lib/devise_multi_factor/version.rb +3 -0
- data/lib/generators/active_record/devise_multi_factor_generator.rb +13 -0
- data/lib/generators/active_record/templates/migration.rb +11 -0
- data/lib/generators/devise_multi_factor/devise_multi_factor_generator.rb +17 -0
- data/spec/controllers/two_factor_authentication_controller_spec.rb +41 -0
- data/spec/features/two_factor_authenticatable_spec.rb +237 -0
- data/spec/generators/active_record/devise_multi_factor_generator_spec.rb +34 -0
- data/spec/lib/devise_multi_factor/models/two_factor_authenticatable_spec.rb +282 -0
- data/spec/rails_app/.gitignore +3 -0
- data/spec/rails_app/README.md +3 -0
- data/spec/rails_app/Rakefile +7 -0
- data/spec/rails_app/app/assets/config/manifest.js +2 -0
- data/spec/rails_app/app/assets/javascripts/application.js +1 -0
- data/spec/rails_app/app/assets/stylesheets/application.css +4 -0
- data/spec/rails_app/app/controllers/application_controller.rb +3 -0
- data/spec/rails_app/app/controllers/home_controller.rb +10 -0
- data/spec/rails_app/app/helpers/application_helper.rb +8 -0
- data/spec/rails_app/app/mailers/.gitkeep +0 -0
- data/spec/rails_app/app/models/.gitkeep +0 -0
- data/spec/rails_app/app/models/admin.rb +6 -0
- data/spec/rails_app/app/models/encrypted_user.rb +7 -0
- data/spec/rails_app/app/models/guest_user.rb +7 -0
- data/spec/rails_app/app/models/test_user.rb +38 -0
- data/spec/rails_app/app/models/user.rb +18 -0
- data/spec/rails_app/app/views/home/dashboard.html.erb +11 -0
- data/spec/rails_app/app/views/home/index.html.erb +3 -0
- data/spec/rails_app/app/views/layouts/application.html.erb +20 -0
- data/spec/rails_app/config.ru +4 -0
- data/spec/rails_app/config/application.rb +61 -0
- data/spec/rails_app/config/boot.rb +10 -0
- data/spec/rails_app/config/database.yml +19 -0
- data/spec/rails_app/config/environment.rb +5 -0
- data/spec/rails_app/config/environments/development.rb +28 -0
- data/spec/rails_app/config/environments/production.rb +68 -0
- data/spec/rails_app/config/environments/test.rb +41 -0
- data/spec/rails_app/config/initializers/backtrace_silencers.rb +7 -0
- data/spec/rails_app/config/initializers/cookies_serializer.rb +3 -0
- data/spec/rails_app/config/initializers/devise.rb +258 -0
- data/spec/rails_app/config/initializers/inflections.rb +15 -0
- data/spec/rails_app/config/initializers/mime_types.rb +5 -0
- data/spec/rails_app/config/initializers/secret_token.rb +7 -0
- data/spec/rails_app/config/initializers/session_store.rb +8 -0
- data/spec/rails_app/config/initializers/wrap_parameters.rb +14 -0
- data/spec/rails_app/config/locales/devise.en.yml +59 -0
- data/spec/rails_app/config/locales/en.yml +5 -0
- data/spec/rails_app/config/routes.rb +65 -0
- data/spec/rails_app/db/migrate/20140403184646_devise_create_users.rb +42 -0
- data/spec/rails_app/db/migrate/20140407172619_two_factor_authentication_add_to_users.rb +17 -0
- data/spec/rails_app/db/migrate/20140407215513_add_nickanme_to_users.rb +7 -0
- data/spec/rails_app/db/migrate/20151224171231_add_encrypted_columns_to_user.rb +7 -0
- data/spec/rails_app/db/migrate/20151224180310_populate_otp_column.rb +19 -0
- data/spec/rails_app/db/migrate/20151228230340_remove_otp_secret_key_from_user.rb +5 -0
- data/spec/rails_app/db/migrate/20160209032439_devise_create_admins.rb +42 -0
- data/spec/rails_app/db/schema.rb +55 -0
- data/spec/rails_app/lib/assets/.gitkeep +0 -0
- data/spec/rails_app/lib/sms_provider.rb +17 -0
- data/spec/rails_app/public/404.html +26 -0
- data/spec/rails_app/public/422.html +26 -0
- data/spec/rails_app/public/500.html +25 -0
- data/spec/rails_app/public/favicon.ico +0 -0
- data/spec/rails_app/script/rails +6 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/support/authenticated_model_helper.rb +29 -0
- data/spec/support/capybara.rb +3 -0
- data/spec/support/controller_helper.rb +16 -0
- data/spec/support/features_spec_helper.rb +42 -0
- data/spec/support/sms_provider.rb +5 -0
- data/spec/support/totp_helper.rb +11 -0
- metadata +315 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
|
|
3
|
+
module DeviseMultiFactor
|
|
4
|
+
module Orm
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Schema
|
|
7
|
+
include DeviseMultiFactor::Schema
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ActiveRecord::ConnectionAdapters::Table.send :include, DeviseMultiFactor::Orm::ActiveRecord::Schema
|
|
14
|
+
ActiveRecord::ConnectionAdapters::TableDefinition.send :include, DeviseMultiFactor::Orm::ActiveRecord::Schema
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module ActionDispatch::Routing
|
|
2
|
+
class Mapper
|
|
3
|
+
protected
|
|
4
|
+
|
|
5
|
+
def devise_two_factor_authentication(mapping, controllers)
|
|
6
|
+
resource :two_factor_authentication, only: [:show, :update, :resend_code], path: mapping.path_names[:two_factor_authentication], controller: controllers[:two_factor_authentication] do
|
|
7
|
+
collection { get 'resend_code' }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def devise_totp(mapping, controllers)
|
|
12
|
+
resource :totp, only: [:new, :create, :show, :destroy], path: mapping.path_names[:totp], controller: controllers[:totp]
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module DeviseMultiFactor
|
|
2
|
+
module Schema
|
|
3
|
+
def second_factor_attempts_count
|
|
4
|
+
apply_devise_schema :second_factor_attempts_count, Integer, :default => 0
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def encrypted_otp_secret_key
|
|
8
|
+
apply_devise_schema :encrypted_otp_secret_key, String
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def direct_otp
|
|
12
|
+
apply_devise_schema :direct_otp, String
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def direct_otp_sent_at
|
|
16
|
+
apply_devise_schema :direct_otp_sent_at, DateTime
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def totp_timestamp
|
|
20
|
+
apply_devise_schema :totp_timestamp, Integer
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require 'rails/generators/active_record'
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Generators
|
|
5
|
+
class DeviseMultiFactorGenerator < ActiveRecord::Generators::Base
|
|
6
|
+
source_root File.expand_path("../templates", __FILE__)
|
|
7
|
+
|
|
8
|
+
def copy_devise_multi_factor_migration
|
|
9
|
+
migration_template "migration.rb", "db/migrate/devise_multi_factor_add_to_#{table_name}.rb"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class DeviseMultiFactorAddTo<%= table_name.camelize %> < ActiveRecord::Migration[6.1]
|
|
2
|
+
def change
|
|
3
|
+
change_table :<%= table_name %>, bulk: true do |t|
|
|
4
|
+
t.integer :second_factor_attempts_count, default: 0, null: false
|
|
5
|
+
t.string :encrypted_otp_secret_key
|
|
6
|
+
t.string :direct_otp
|
|
7
|
+
t.datetime :direct_otp_sent_at
|
|
8
|
+
t.integer :totp_timestamp
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module TwoFactorAuthenticatable
|
|
2
|
+
module Generators
|
|
3
|
+
class DeviseMultiFactorGenerator < Rails::Generators::NamedBase
|
|
4
|
+
namespace "devise_multi_factor"
|
|
5
|
+
|
|
6
|
+
desc "Adds :two_factor_authenticable directive in the given model. It also generates an active record migration."
|
|
7
|
+
|
|
8
|
+
def inject_devise_multi_factor_content
|
|
9
|
+
path = File.join("app", "models", "#{file_path}.rb")
|
|
10
|
+
inject_into_file(path, "two_factor_authenticatable, :", :after => "devise :") if File.exists?(path)
|
|
11
|
+
inject_into_file(path, "totp_enrollable, :", :after => "devise :") if File.exists?(path)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
hook_for :orm
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe Devise::TwoFactorAuthenticationController, type: :controller do
|
|
4
|
+
describe 'is_fully_authenticated? helper' do
|
|
5
|
+
def post_code(code)
|
|
6
|
+
if Rails::VERSION::MAJOR >= 5
|
|
7
|
+
post :update, params: { code: code }
|
|
8
|
+
else
|
|
9
|
+
post :update, code: code
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
before do
|
|
14
|
+
sign_in
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
context 'after user enters valid OTP code' do
|
|
18
|
+
it 'returns true' do
|
|
19
|
+
controller.current_user.send_new_otp
|
|
20
|
+
post_code controller.current_user.direct_otp
|
|
21
|
+
expect(subject.is_fully_authenticated?).to eq true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
context 'when user has not entered any OTP yet' do
|
|
26
|
+
it 'returns false' do
|
|
27
|
+
get :show
|
|
28
|
+
|
|
29
|
+
expect(subject.is_fully_authenticated?).to eq false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
context 'when user enters an invalid OTP' do
|
|
34
|
+
it 'returns false' do
|
|
35
|
+
post_code '12345'
|
|
36
|
+
|
|
37
|
+
expect(subject.is_fully_authenticated?).to eq false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
include AuthenticatedModelHelper
|
|
3
|
+
|
|
4
|
+
feature "User of two factor authentication" do
|
|
5
|
+
context 'sending two factor authentication code via SMS' do
|
|
6
|
+
shared_examples 'sends and authenticates code' do |user, type|
|
|
7
|
+
before do
|
|
8
|
+
user.reload
|
|
9
|
+
if type == 'encrypted'
|
|
10
|
+
allow(User).to receive(:has_one_time_password)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'does not send an SMS before the user has signed in' do
|
|
15
|
+
expect(SMSProvider.messages).to be_empty
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'sends code via SMS after sign in' do
|
|
19
|
+
visit new_user_session_path
|
|
20
|
+
complete_sign_in_form_for(user)
|
|
21
|
+
|
|
22
|
+
expect(page).to have_content 'Enter the code that was sent to you'
|
|
23
|
+
|
|
24
|
+
expect(SMSProvider.messages.size).to eq(1)
|
|
25
|
+
message = SMSProvider.last_message
|
|
26
|
+
expect(message.to).to eq(user.phone_number)
|
|
27
|
+
expect(message.body).to eq(user.reload.direct_otp)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'authenticates a valid OTP code' do
|
|
31
|
+
visit new_user_session_path
|
|
32
|
+
complete_sign_in_form_for(user)
|
|
33
|
+
|
|
34
|
+
expect(page).to have_content('You are signed in as Marissa')
|
|
35
|
+
|
|
36
|
+
fill_in 'code', with: SMSProvider.last_message.body
|
|
37
|
+
click_button 'Submit'
|
|
38
|
+
|
|
39
|
+
within('.flash.notice') do
|
|
40
|
+
expect(page).to have_content('Two factor authentication successful.')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
expect(current_path).to eq root_path
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it_behaves_like 'sends and authenticates code', create_user('not_encrypted')
|
|
48
|
+
it_behaves_like 'sends and authenticates code', create_user, 'encrypted'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
scenario "must be logged in" do
|
|
52
|
+
visit user_two_factor_authentication_path
|
|
53
|
+
|
|
54
|
+
expect(page).to have_content("Welcome Home")
|
|
55
|
+
expect(page).to have_content("You are signed out")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
context "when logged in" do
|
|
59
|
+
let(:user) { create_user }
|
|
60
|
+
|
|
61
|
+
background do
|
|
62
|
+
login_as user
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
scenario "is redirected to TFA when path requires authentication" do
|
|
66
|
+
visit dashboard_path + "?A=param%20a&B=param%20b"
|
|
67
|
+
|
|
68
|
+
expect(page).to_not have_content("Your Personal Dashboard")
|
|
69
|
+
|
|
70
|
+
fill_in "code", with: SMSProvider.last_message.body
|
|
71
|
+
click_button "Submit"
|
|
72
|
+
|
|
73
|
+
expect(page).to have_content("Your Personal Dashboard")
|
|
74
|
+
expect(page).to have_content("You are signed in as Marissa")
|
|
75
|
+
expect(page).to have_content("Param A is param a")
|
|
76
|
+
expect(page).to have_content("Param B is param b")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
scenario "is locked out after max failed attempts" do
|
|
80
|
+
visit user_two_factor_authentication_path
|
|
81
|
+
|
|
82
|
+
max_attempts = User.max_login_attempts
|
|
83
|
+
|
|
84
|
+
max_attempts.times do
|
|
85
|
+
fill_in "code", with: "incorrect#{rand(100)}"
|
|
86
|
+
click_button "Submit"
|
|
87
|
+
|
|
88
|
+
within(".flash.alert") do
|
|
89
|
+
expect(page).to have_content("Attempt failed")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
expect(page).to have_content("Access completely denied")
|
|
94
|
+
expect(page).to have_content("You are signed out")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
scenario "cannot retry authentication after max attempts" do
|
|
98
|
+
user.update_attribute(:second_factor_attempts_count, User.max_login_attempts)
|
|
99
|
+
|
|
100
|
+
visit user_two_factor_authentication_path
|
|
101
|
+
|
|
102
|
+
expect(page).to have_content("Access completely denied")
|
|
103
|
+
expect(page).to have_content("You are signed out")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe "rememberable TFA" do
|
|
107
|
+
before do
|
|
108
|
+
@original_remember_otp_session_for_seconds = User.remember_otp_session_for_seconds
|
|
109
|
+
User.remember_otp_session_for_seconds = 30.days
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
after do
|
|
113
|
+
User.remember_otp_session_for_seconds = @original_remember_otp_session_for_seconds
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
scenario "doesn't require TFA code again within 30 days" do
|
|
117
|
+
sms_sign_in
|
|
118
|
+
|
|
119
|
+
logout
|
|
120
|
+
|
|
121
|
+
login_as user
|
|
122
|
+
visit dashboard_path
|
|
123
|
+
expect(page).to have_content("Your Personal Dashboard")
|
|
124
|
+
expect(page).to have_content("You are signed in as Marissa")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
scenario "requires TFA code again after 30 days" do
|
|
128
|
+
sms_sign_in
|
|
129
|
+
|
|
130
|
+
logout
|
|
131
|
+
|
|
132
|
+
Timecop.travel(30.days.from_now)
|
|
133
|
+
login_as user
|
|
134
|
+
visit dashboard_path
|
|
135
|
+
expect(page).to have_content("You are signed in as Marissa")
|
|
136
|
+
expect(page).to have_content("Enter the code that was sent to you")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
scenario 'TFA should be different for different users' do
|
|
140
|
+
sms_sign_in
|
|
141
|
+
|
|
142
|
+
tfa_cookie1 = get_tfa_cookie()
|
|
143
|
+
|
|
144
|
+
logout
|
|
145
|
+
reset_session!
|
|
146
|
+
|
|
147
|
+
user2 = create_user()
|
|
148
|
+
login_as(user2)
|
|
149
|
+
sms_sign_in
|
|
150
|
+
|
|
151
|
+
tfa_cookie2 = get_tfa_cookie()
|
|
152
|
+
|
|
153
|
+
expect(tfa_cookie1).not_to eq tfa_cookie2
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sms_sign_in
|
|
157
|
+
SMSProvider.messages.clear()
|
|
158
|
+
visit user_two_factor_authentication_path
|
|
159
|
+
fill_in 'code', with: SMSProvider.last_message.body
|
|
160
|
+
click_button 'Submit'
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
scenario 'TFA should be unique for specific user' do
|
|
164
|
+
sms_sign_in
|
|
165
|
+
|
|
166
|
+
tfa_cookie1 = get_tfa_cookie()
|
|
167
|
+
|
|
168
|
+
logout
|
|
169
|
+
reset_session!
|
|
170
|
+
|
|
171
|
+
user2 = create_user()
|
|
172
|
+
set_tfa_cookie(tfa_cookie1)
|
|
173
|
+
login_as(user2)
|
|
174
|
+
visit dashboard_path
|
|
175
|
+
expect(page).to have_content("Enter the code that was sent to you")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
scenario 'Delete cookie when user logs out if enabled' do
|
|
179
|
+
user.class.delete_cookie_on_logout = true
|
|
180
|
+
|
|
181
|
+
login_as user
|
|
182
|
+
logout
|
|
183
|
+
|
|
184
|
+
login_as user
|
|
185
|
+
|
|
186
|
+
visit dashboard_path
|
|
187
|
+
expect(page).to have_content("Enter the code that was sent to you")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'sets the warden session need_two_factor_authentication key to true' do
|
|
192
|
+
session_hash = { 'need_two_factor_authentication' => true }
|
|
193
|
+
|
|
194
|
+
expect(page.get_rack_session_key('warden.user.user.session')).to eq session_hash
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
describe 'signing in' do
|
|
199
|
+
let(:user) { create_user }
|
|
200
|
+
let(:admin) { create_admin }
|
|
201
|
+
|
|
202
|
+
scenario 'user signs is' do
|
|
203
|
+
visit new_user_session_path
|
|
204
|
+
complete_sign_in_form_for(user)
|
|
205
|
+
|
|
206
|
+
expect(page).to have_content('Signed in successfully.')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
scenario 'admin signs in' do
|
|
210
|
+
visit new_admin_session_path
|
|
211
|
+
complete_sign_in_form_for(admin)
|
|
212
|
+
|
|
213
|
+
expect(page).to have_content('Signed in successfully.')
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
describe 'signing out' do
|
|
218
|
+
let(:user) { create_user }
|
|
219
|
+
let(:admin) { create_admin }
|
|
220
|
+
|
|
221
|
+
scenario 'user signs out' do
|
|
222
|
+
visit new_user_session_path
|
|
223
|
+
complete_sign_in_form_for(user)
|
|
224
|
+
visit destroy_user_session_path
|
|
225
|
+
|
|
226
|
+
expect(page).to have_content('Signed out successfully.')
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
scenario 'admin signs out' do
|
|
230
|
+
visit new_admin_session_path
|
|
231
|
+
complete_sign_in_form_for(admin)
|
|
232
|
+
visit destroy_admin_session_path
|
|
233
|
+
|
|
234
|
+
expect(page).to have_content('Signed out successfully.')
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
require 'generators/active_record/devise_multi_factor_generator'
|
|
4
|
+
|
|
5
|
+
describe ActiveRecord::Generators::DeviseMultiFactorGenerator, type: :generator do
|
|
6
|
+
destination File.expand_path('../../../../../tmp', __FILE__)
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
prepare_destination
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'runs all methods in the generator' do
|
|
13
|
+
gen = generator %w(users)
|
|
14
|
+
expect(gen).to receive(:copy_devise_multi_factor_migration)
|
|
15
|
+
gen.invoke_all
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe 'the generated files' do
|
|
19
|
+
before do
|
|
20
|
+
run_generator %w(users)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'the migration' do
|
|
24
|
+
subject { migration_file('db/migrate/devise_multi_factor_add_to_users.rb') }
|
|
25
|
+
|
|
26
|
+
it { is_expected.to exist }
|
|
27
|
+
it { is_expected.to be_a_migration }
|
|
28
|
+
it { is_expected.to contain /def change/ }
|
|
29
|
+
it { is_expected.to contain /def change_table :user, bulk: true do |t|/ }
|
|
30
|
+
it { is_expected.to contain /t.integer :second_factor_attempts_count, default: 0, null: false/ }
|
|
31
|
+
it { is_expected.to contain /t.string :encrypted_otp_secret_key/ }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
include AuthenticatedModelHelper
|
|
3
|
+
|
|
4
|
+
describe Devise::Models::TwoFactorAuthenticatable do
|
|
5
|
+
describe '#create_direct_otp' do
|
|
6
|
+
let(:instance) { build_guest_user }
|
|
7
|
+
|
|
8
|
+
it 'set direct_otp field' do
|
|
9
|
+
expect(instance.direct_otp).to be_nil
|
|
10
|
+
instance.create_direct_otp
|
|
11
|
+
expect(instance.direct_otp).not_to be_nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'set direct_otp_send_at field to current time' do
|
|
15
|
+
Timecop.freeze() do
|
|
16
|
+
instance.create_direct_otp
|
|
17
|
+
expect(instance.direct_otp_sent_at).to eq(Time.now)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'honors .direct_otp_length' do
|
|
22
|
+
expect(instance.class).to receive(:direct_otp_length).and_return(10)
|
|
23
|
+
instance.create_direct_otp
|
|
24
|
+
expect(instance.direct_otp.length).to equal(10)
|
|
25
|
+
|
|
26
|
+
expect(instance.class).to receive(:direct_otp_length).and_return(6)
|
|
27
|
+
instance.create_direct_otp
|
|
28
|
+
expect(instance.direct_otp.length).to equal(6)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "honors 'direct_otp_length' in options paramater" do
|
|
32
|
+
instance.create_direct_otp(length: 8)
|
|
33
|
+
expect(instance.direct_otp.length).to equal(8)
|
|
34
|
+
instance.create_direct_otp(length: 10)
|
|
35
|
+
expect(instance.direct_otp.length).to equal(10)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#authenticate_direct_otp' do
|
|
40
|
+
let(:instance) { build_guest_user }
|
|
41
|
+
it 'fails if no direct_otp has been set' do
|
|
42
|
+
expect(instance.authenticate_direct_otp('12345')).to eq(false)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context 'after generating an OTP' do
|
|
46
|
+
before :each do
|
|
47
|
+
instance.create_direct_otp
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'accepts correct OTP' do
|
|
51
|
+
Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
|
|
52
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'rejects invalid OTP' do
|
|
56
|
+
Timecop.freeze(Time.now + instance.class.direct_otp_valid_for - 1.second)
|
|
57
|
+
expect(instance.authenticate_direct_otp('12340')).to eq(false)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'rejects expired OTP' do
|
|
61
|
+
Timecop.freeze(Time.now + instance.class.direct_otp_valid_for + 1.second)
|
|
62
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'prevents code re-use' do
|
|
66
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(true)
|
|
67
|
+
expect(instance.authenticate_direct_otp(instance.direct_otp)).to eq(false)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe '#authenticate_totp' do
|
|
73
|
+
shared_examples 'authenticate_totp' do |instance|
|
|
74
|
+
before :each do
|
|
75
|
+
instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
|
|
76
|
+
instance.totp_timestamp = nil
|
|
77
|
+
@totp_helper = TotpHelper.new(instance.otp_secret_key, instance.class.otp_length)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def do_invoke(code, user)
|
|
81
|
+
user.authenticate_totp(code)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'authenticates a recently created code' do
|
|
85
|
+
code = @totp_helper.totp_code
|
|
86
|
+
expect(do_invoke(code, instance)).to eq(true)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'authenticates a code entered with a space' do
|
|
90
|
+
code = @totp_helper.totp_code.insert(3, ' ')
|
|
91
|
+
expect(do_invoke(code, instance)).to eq(true)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'does not authenticate an old code' do
|
|
95
|
+
code = @totp_helper.totp_code(1.minutes.ago.to_i)
|
|
96
|
+
expect(do_invoke(code, instance)).to eq(false)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'prevents code reuse' do
|
|
100
|
+
code = @totp_helper.totp_code
|
|
101
|
+
expect(do_invoke(code, instance)).to eq(true)
|
|
102
|
+
expect(do_invoke(code, instance)).to eq(false)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it_behaves_like 'authenticate_totp', GuestUser.new
|
|
107
|
+
it_behaves_like 'authenticate_totp', EncryptedUser.new
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe '#send_two_factor_authentication_code' do
|
|
111
|
+
let(:instance) { build_guest_user }
|
|
112
|
+
|
|
113
|
+
it 'raises an error by default' do
|
|
114
|
+
expect { instance.send_two_factor_authentication_code(123) }.
|
|
115
|
+
to raise_error(NotImplementedError)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'is overrideable' do
|
|
119
|
+
def instance.send_two_factor_authentication_code(code)
|
|
120
|
+
'Code sent'
|
|
121
|
+
end
|
|
122
|
+
expect(instance.send_two_factor_authentication_code(123)).to eq('Code sent')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe '#provisioning_uri' do
|
|
127
|
+
|
|
128
|
+
shared_examples 'provisioning_uri' do |instance|
|
|
129
|
+
it 'fails until generate_totp_secret is called' do
|
|
130
|
+
expect { instance.provisioning_uri }.to raise_error(Exception)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe 'with secret set' do
|
|
134
|
+
before do
|
|
135
|
+
instance.email = 'houdini@example.com'
|
|
136
|
+
instance.otp_secret_key = instance.generate_totp_secret
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "returns uri with user's email" do
|
|
140
|
+
expect(instance.provisioning_uri).
|
|
141
|
+
to match(%r{otpauth://totp/houdini%40example.com\?secret=\w{32}})
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'returns uri with issuer option' do
|
|
145
|
+
expect(instance.provisioning_uri('houdini')).
|
|
146
|
+
to match(%r{otpauth://totp/houdini\?secret=\w{32}$})
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'returns uri with issuer option' do
|
|
150
|
+
require 'cgi'
|
|
151
|
+
uri = URI.parse(instance.provisioning_uri('houdini', issuer: 'Magic'))
|
|
152
|
+
params = CGI.parse(uri.query)
|
|
153
|
+
|
|
154
|
+
expect(uri.scheme).to eq('otpauth')
|
|
155
|
+
expect(uri.host).to eq('totp')
|
|
156
|
+
expect(uri.path).to eq('/Magic:houdini')
|
|
157
|
+
expect(params['issuer'].shift).to eq('Magic')
|
|
158
|
+
expect(params['secret'].shift).to match(/\w{32}/)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it_behaves_like 'provisioning_uri', GuestUser.new
|
|
164
|
+
it_behaves_like 'provisioning_uri', EncryptedUser.new
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
describe '#generate_totp_secret' do
|
|
168
|
+
shared_examples 'generate_totp_secret' do |klass|
|
|
169
|
+
let(:instance) { klass.new }
|
|
170
|
+
|
|
171
|
+
it 'returns a 32 character string' do
|
|
172
|
+
secret = instance.generate_totp_secret
|
|
173
|
+
|
|
174
|
+
expect(secret).to match(/\w{32}/)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it_behaves_like 'generate_totp_secret', GuestUser
|
|
179
|
+
it_behaves_like 'generate_totp_secret', EncryptedUser
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
describe '#enroll_totp!' do
|
|
183
|
+
shared_examples 'enroll_totp!' do |klass|
|
|
184
|
+
let(:instance) { klass.new }
|
|
185
|
+
let(:secret) { instance.generate_totp_secret }
|
|
186
|
+
let(:totp_helper) { TotpHelper.new(secret, instance.class.otp_length) }
|
|
187
|
+
|
|
188
|
+
describe 'when given correct code' do
|
|
189
|
+
it 'populates otp_secret_key column' do
|
|
190
|
+
instance.enroll_totp!(secret, totp_helper.totp_code)
|
|
191
|
+
|
|
192
|
+
expect(instance.otp_secret_key).to match(secret)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'updates the encrypted_otp_secret_key and otp totp_timestamp' do
|
|
196
|
+
allow(instance).to receive(:update_columns).and_return(true)
|
|
197
|
+
allow_any_instance_of(ROTP::TOTP).to receive(:verify).and_return(15445051)
|
|
198
|
+
|
|
199
|
+
instance.enroll_totp!(secret, totp_helper.totp_code)
|
|
200
|
+
|
|
201
|
+
expect(instance).to have_received(:update_columns)
|
|
202
|
+
.with(totp_timestamp: 15445051, otp_secret_key: secret)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'returns true' do
|
|
206
|
+
expect(instance.enroll_totp!(secret, totp_helper.totp_code)).to be true
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe 'when given incorrect code' do
|
|
211
|
+
it 'does not populate otp_secret_key' do
|
|
212
|
+
instance.enroll_totp!(secret, '123')
|
|
213
|
+
expect(instance.otp_secret_key).to be_nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'returns false' do
|
|
217
|
+
expect(instance.enroll_totp!(secret, '123')).to be false
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it_behaves_like 'enroll_totp!', GuestUser
|
|
223
|
+
it_behaves_like 'enroll_totp!', EncryptedUser
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe '#max_login_attempts' do
|
|
227
|
+
let(:instance) { build_guest_user }
|
|
228
|
+
|
|
229
|
+
before do
|
|
230
|
+
@original_max_login_attempts = GuestUser.max_login_attempts
|
|
231
|
+
GuestUser.max_login_attempts = 3
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
after { GuestUser.max_login_attempts = @original_max_login_attempts }
|
|
235
|
+
|
|
236
|
+
it 'returns class setting' do
|
|
237
|
+
expect(instance.max_login_attempts).to eq(3)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'returns false as boolean' do
|
|
241
|
+
instance.second_factor_attempts_count = nil
|
|
242
|
+
expect(instance.max_login_attempts?).to be_falsey
|
|
243
|
+
instance.second_factor_attempts_count = 0
|
|
244
|
+
expect(instance.max_login_attempts?).to be_falsey
|
|
245
|
+
instance.second_factor_attempts_count = 1
|
|
246
|
+
expect(instance.max_login_attempts?).to be_falsey
|
|
247
|
+
instance.second_factor_attempts_count = 2
|
|
248
|
+
expect(instance.max_login_attempts?).to be_falsey
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'returns true as boolean after too many attempts' do
|
|
252
|
+
instance.second_factor_attempts_count = 3
|
|
253
|
+
expect(instance.max_login_attempts?).to be_truthy
|
|
254
|
+
instance.second_factor_attempts_count = 4
|
|
255
|
+
expect(instance.max_login_attempts?).to be_truthy
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
describe '.has_one_time_password' do
|
|
260
|
+
context 'when encrypted: true option is passed' do
|
|
261
|
+
let(:instance) { EncryptedUser.new }
|
|
262
|
+
|
|
263
|
+
it 'encrypts otp_secret_key' do
|
|
264
|
+
instance.otp_secret_key = '2z6hxkdwi3uvrnpn'
|
|
265
|
+
|
|
266
|
+
expect(instance.encrypted_otp_secret_key).to match(/.{44}/)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'does not encrypt a nil otp_secret_key' do
|
|
270
|
+
instance.otp_secret_key = nil
|
|
271
|
+
|
|
272
|
+
expect(instance.encrypted_otp_secret_key).to be_nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it 'does not encrypt an empty otp_secret_key' do
|
|
276
|
+
instance.otp_secret_key = ''
|
|
277
|
+
|
|
278
|
+
expect(instance.encrypted_otp_secret_key).to eq ''
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|