devise-otp 0.2.0 → 0.3.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.
Files changed (99) hide show
  1. checksums.yaml +5 -5
  2. data/.github/workflows/ci.yml +36 -0
  3. data/.gitignore +0 -0
  4. data/Gemfile +1 -22
  5. data/LICENSE.txt +0 -0
  6. data/README.md +43 -66
  7. data/Rakefile +0 -0
  8. data/app/assets/javascripts/devise-otp.js +1 -0
  9. data/app/assets/javascripts/qrcode.js +609 -0
  10. data/app/controllers/devise_otp/devise/credentials_controller.rb +102 -0
  11. data/app/controllers/devise_otp/devise/tokens_controller.rb +112 -0
  12. data/app/views/devise/credentials/refresh.html.erb +19 -0
  13. data/app/views/devise/credentials/show.html.erb +31 -0
  14. data/app/views/devise/tokens/_token_secret.html.erb +23 -0
  15. data/app/views/devise/tokens/_trusted_devices.html.erb +12 -0
  16. data/app/views/devise/tokens/recovery.html.erb +21 -0
  17. data/app/views/devise/tokens/recovery_codes.text.erb +3 -0
  18. data/app/views/devise/tokens/show.html.erb +21 -0
  19. data/config/locales/en.yml +10 -10
  20. data/devise-otp.gemspec +14 -9
  21. data/docs/QR_CODES.md +48 -0
  22. data/lib/devise-otp/version.rb +1 -1
  23. data/lib/devise-otp.rb +22 -14
  24. data/lib/devise_otp_authenticatable/controllers/helpers.rb +29 -16
  25. data/lib/devise_otp_authenticatable/controllers/url_helpers.rb +6 -9
  26. data/lib/devise_otp_authenticatable/engine.rb +22 -13
  27. data/lib/devise_otp_authenticatable/hooks/sessions.rb +8 -7
  28. data/lib/devise_otp_authenticatable/hooks.rb +1 -1
  29. data/lib/devise_otp_authenticatable/models/otp_authenticatable.rb +28 -28
  30. data/lib/devise_otp_authenticatable/routes.rb +9 -10
  31. data/lib/generators/active_record/devise_otp_generator.rb +1 -1
  32. data/lib/generators/active_record/templates/migration.rb +1 -2
  33. data/lib/generators/devise_otp/devise_otp_generator.rb +0 -0
  34. data/lib/generators/devise_otp/install_generator.rb +30 -5
  35. data/lib/generators/devise_otp/views_generator.rb +2 -3
  36. data/test/dummy/README.rdoc +0 -0
  37. data/test/dummy/Rakefile +0 -0
  38. data/test/dummy/app/assets/config/manifest.js +2 -0
  39. data/test/dummy/app/assets/javascripts/application.js +1 -0
  40. data/test/dummy/app/assets/stylesheets/application.css +0 -0
  41. data/test/dummy/app/controllers/application_controller.rb +1 -1
  42. data/test/dummy/app/controllers/posts_controller.rb +2 -0
  43. data/test/dummy/app/helpers/application_helper.rb +0 -0
  44. data/test/dummy/app/helpers/posts_helper.rb +0 -0
  45. data/test/dummy/app/mailers/.gitkeep +0 -0
  46. data/test/dummy/app/models/post.rb +0 -0
  47. data/test/dummy/app/models/user.rb +1 -1
  48. data/test/dummy/app/views/layouts/application.html.erb +0 -0
  49. data/test/dummy/app/views/posts/_form.html.erb +0 -0
  50. data/test/dummy/app/views/posts/edit.html.erb +0 -0
  51. data/test/dummy/app/views/posts/index.html.erb +0 -0
  52. data/test/dummy/app/views/posts/new.html.erb +0 -0
  53. data/test/dummy/app/views/posts/show.html.erb +0 -0
  54. data/test/dummy/config/application.rb +2 -1
  55. data/test/dummy/config/boot.rb +0 -0
  56. data/test/dummy/config/database.yml +1 -1
  57. data/test/dummy/config/environment.rb +0 -0
  58. data/test/dummy/config/environments/development.rb +0 -7
  59. data/test/dummy/config/environments/production.rb +0 -4
  60. data/test/dummy/config/environments/test.rb +0 -0
  61. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -0
  62. data/test/dummy/config/initializers/devise.rb +0 -0
  63. data/test/dummy/config/initializers/inflections.rb +0 -0
  64. data/test/dummy/config/initializers/mime_types.rb +0 -0
  65. data/test/dummy/config/initializers/secret_token.rb +0 -0
  66. data/test/dummy/config/initializers/session_store.rb +0 -0
  67. data/test/dummy/config/initializers/wrap_parameters.rb +0 -0
  68. data/test/dummy/config/locales/en.yml +0 -0
  69. data/test/dummy/config/routes.rb +0 -0
  70. data/test/dummy/config.ru +0 -0
  71. data/test/dummy/db/migrate/20130125101430_create_users.rb +1 -1
  72. data/test/dummy/db/migrate/20130131092406_add_devise_to_users.rb +1 -1
  73. data/test/dummy/db/migrate/20130131142320_create_posts.rb +1 -1
  74. data/test/dummy/db/migrate/20130131160351_devise_otp_add_to_users.rb +2 -2
  75. data/test/dummy/db/test.sqlite3-journal +0 -0
  76. data/test/dummy/lib/assets/.gitkeep +0 -0
  77. data/test/dummy/public/404.html +0 -0
  78. data/test/dummy/public/422.html +0 -0
  79. data/test/dummy/public/500.html +0 -0
  80. data/test/dummy/public/favicon.ico +0 -0
  81. data/test/integration/persistence_test.rb +81 -0
  82. data/test/integration/refresh_test.rb +2 -18
  83. data/test/integration/sign_in_test.rb +14 -4
  84. data/test/integration/token_test.rb +31 -0
  85. data/test/integration_tests_helper.rb +19 -2
  86. data/test/model_tests_helper.rb +0 -0
  87. data/test/models/otp_authenticatable_test.rb +14 -9
  88. data/test/orm/active_record.rb +3 -1
  89. data/test/test_helper.rb +71 -2
  90. metadata +135 -24
  91. data/.travis.yml +0 -11
  92. data/app/controllers/devise_otp/credentials_controller.rb +0 -106
  93. data/app/controllers/devise_otp/tokens_controller.rb +0 -105
  94. data/app/views/devise_otp/credentials/refresh.html.erb +0 -20
  95. data/app/views/devise_otp/credentials/show.html.erb +0 -23
  96. data/app/views/devise_otp/tokens/_token_secret.html.erb +0 -17
  97. data/app/views/devise_otp/tokens/recovery.html.erb +0 -21
  98. data/app/views/devise_otp/tokens/show.html.erb +0 -31
  99. data/lib/devise_otp_authenticatable/mapping.rb +0 -19
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
data/test/dummy/config.ru CHANGED
File without changes
@@ -1,4 +1,4 @@
1
- class CreateUsers < ActiveRecord::Migration
1
+ class CreateUsers < ActiveRecord::Migration[5.0]
2
2
  def change
3
3
  create_table :users do |t|
4
4
  t.string :name
@@ -1,4 +1,4 @@
1
- class AddDeviseToUsers < ActiveRecord::Migration
1
+ class AddDeviseToUsers < ActiveRecord::Migration[5.0]
2
2
  def self.up
3
3
  change_table(:users) do |t|
4
4
  ## Database authenticatable
@@ -1,4 +1,4 @@
1
- class CreatePosts < ActiveRecord::Migration
1
+ class CreatePosts < ActiveRecord::Migration[5.0]
2
2
  def change
3
3
  create_table :posts do |t|
4
4
  t.string :title
@@ -1,4 +1,4 @@
1
- class DeviseOtpAddToUsers < ActiveRecord::Migration
1
+ class DeviseOtpAddToUsers < ActiveRecord::Migration[5.0]
2
2
  def self.up
3
3
  change_table :users do |t|
4
4
  t.string :otp_auth_secret
@@ -18,7 +18,7 @@ class DeviseOtpAddToUsers < ActiveRecord::Migration
18
18
  add_index :users, :otp_session_challenge, :unique => true
19
19
  add_index :users, :otp_challenge_expires
20
20
  end
21
-
21
+
22
22
  def self.down
23
23
  change_table :users do |t|
24
24
  t.remove :otp_auth_secret, :otp_recovery_secret, :otp_enabled, :otp_mandatory, :otp_enabled_on, :otp_session_challenge,
Binary file
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,81 @@
1
+ require 'test_helper'
2
+ require 'integration_tests_helper'
3
+
4
+ class PersistenceTest < ActionDispatch::IntegrationTest
5
+
6
+ def setup
7
+ @old_persistence = User.otp_trust_persistence
8
+ User.otp_trust_persistence = 3.seconds
9
+ end
10
+
11
+ def teardown
12
+ User.otp_trust_persistence = @old_persistence
13
+ Capybara.reset_sessions!
14
+ end
15
+
16
+ test 'a user should be requested the otp challenge every log in' do
17
+ # log in 1fa
18
+ user = enable_otp_and_sign_in
19
+ otp_challenge_for user
20
+
21
+ visit user_otp_token_path
22
+ assert_equal user_otp_token_path, current_path
23
+
24
+ sign_out
25
+ sign_user_in
26
+
27
+ assert_equal user_otp_credential_path, current_path
28
+ end
29
+
30
+ test 'a user should be able to set their browser as trusted' do
31
+ # log in 1fa
32
+ user = enable_otp_and_sign_in
33
+ otp_challenge_for user
34
+
35
+ visit user_otp_token_path
36
+ assert_equal user_otp_token_path, current_path
37
+
38
+ click_link('Trust this browser')
39
+ assert_text 'Your browser is trusted.'
40
+ sign_out
41
+
42
+ sign_user_in
43
+
44
+ assert_equal root_path, current_path
45
+ end
46
+
47
+ test 'a user should be able to download its recovery codes' do
48
+ # log in 1fa
49
+ user = enable_otp_and_sign_in
50
+ otp_challenge_for user
51
+
52
+ visit user_otp_token_path
53
+ assert_equal user_otp_token_path, current_path
54
+
55
+ enable_chrome_headless_downloads(page, "/tmp/devise-otp")
56
+
57
+ DownloadHelper.wait_for_download(count: 1) do
58
+ click_link('Download recovery codes')
59
+ end
60
+
61
+ assert_equal 1, DownloadHelper.downloads.size
62
+ end
63
+
64
+ test 'trusted status should expire' do
65
+ # log in 1fa
66
+ user = enable_otp_and_sign_in
67
+ otp_challenge_for user
68
+
69
+ visit user_otp_token_path
70
+ assert_equal user_otp_token_path, current_path
71
+
72
+ click_link('Trust this browser')
73
+ assert_text 'Your browser is trusted.'
74
+ sign_out
75
+
76
+ sleep User.otp_trust_persistence.to_i + 1
77
+ sign_user_in
78
+
79
+ assert_equal user_otp_credential_path, current_path
80
+ end
81
+ end
@@ -21,7 +21,6 @@ class RefreshTest < ActionDispatch::IntegrationTest
21
21
  end
22
22
 
23
23
  test 'a user should be prompted for credentials when the credentials_refresh time is expired' do
24
-
25
24
  sign_user_in
26
25
  visit user_otp_token_path
27
26
  assert_equal user_otp_token_path, current_path
@@ -45,7 +44,6 @@ class RefreshTest < ActionDispatch::IntegrationTest
45
44
  fill_in 'user_refresh_password', :with => '12345678'
46
45
  click_button 'Continue...'
47
46
  assert_equal user_otp_token_path, current_path
48
-
49
47
  end
50
48
 
51
49
  test 'a user should NOT be able to access their OTP settings unless refreshing' do
@@ -63,20 +61,7 @@ class RefreshTest < ActionDispatch::IntegrationTest
63
61
  assert_equal refresh_user_otp_credential_path, current_path
64
62
  end
65
63
 
66
- test 'user should be asked their OTP challenge in order to refresh, if they have OTP' do
67
- enable_otp_and_sign_in_with_otp
68
-
69
- sleep(2)
70
- visit user_otp_token_path
71
- assert_equal refresh_user_otp_credential_path, current_path
72
-
73
- fill_in 'user_refresh_password', :with => '12345678'
74
- click_button 'Continue...'
75
-
76
- assert_equal refresh_user_otp_credential_path, current_path
77
- end
78
-
79
- test 'user should be finally be able to access their settings, if they provide both a password and a valid OTP token' do
64
+ test 'user should be finally be able to access their settings, and just password is enough' do
80
65
  user = enable_otp_and_sign_in_with_otp
81
66
 
82
67
  sleep(2)
@@ -84,9 +69,8 @@ class RefreshTest < ActionDispatch::IntegrationTest
84
69
  assert_equal refresh_user_otp_credential_path, current_path
85
70
 
86
71
  fill_in 'user_refresh_password', :with => '12345678'
87
- fill_in 'user_token', :with => ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
88
72
  click_button 'Continue...'
89
73
 
90
74
  assert_equal user_otp_token_path, current_path
91
75
  end
92
- end
76
+ end
@@ -10,12 +10,12 @@ class SignInTest < ActionDispatch::IntegrationTest
10
10
  test 'a new user should be able to sign in without using their token' do
11
11
  create_full_user
12
12
 
13
- visit new_user_session_path
13
+ visit posts_path
14
14
  fill_in 'user_email', :with => 'user@email.invalid'
15
15
  fill_in 'user_password', :with => '12345678'
16
- click_button 'Sign in'
16
+ page.has_content?('Log in') ? click_button('Log in') : click_button('Sign in')
17
17
 
18
- assert_equal root_path, current_path
18
+ assert_equal posts_path, current_path
19
19
  end
20
20
 
21
21
  test 'a new user, just signed in, should be able to sign in and enable their OTP authentication' do
@@ -50,6 +50,16 @@ class SignInTest < ActionDispatch::IntegrationTest
50
50
  assert_equal new_user_session_path, current_path
51
51
  end
52
52
 
53
+ test 'fail blank token authentication' do
54
+ enable_otp_and_sign_in
55
+ assert_equal user_otp_credential_path, current_path
56
+
57
+ fill_in 'user_token', :with => ''
58
+ click_button 'Submit Token'
59
+
60
+ assert_equal user_otp_credential_path, current_path
61
+ end
62
+
53
63
  test 'successful token authentication' do
54
64
  user = enable_otp_and_sign_in
55
65
 
@@ -74,4 +84,4 @@ class SignInTest < ActionDispatch::IntegrationTest
74
84
  User.otp_authentication_timeout = old_timeout
75
85
  assert_equal new_user_session_path, current_path
76
86
  end
77
- end
87
+ end
@@ -0,0 +1,31 @@
1
+ require 'test_helper'
2
+ require 'integration_tests_helper'
3
+
4
+ class TokenTest < ActionDispatch::IntegrationTest
5
+
6
+ def teardown
7
+ Capybara.reset_sessions!
8
+ end
9
+
10
+ test 'disabling OTP after successfully enabling' do
11
+ # log in 1fa
12
+ user = enable_otp_and_sign_in
13
+ assert_equal user_otp_credential_path, current_path
14
+
15
+ # otp 2fa
16
+ fill_in 'user_token', :with => ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
17
+ click_button 'Submit Token'
18
+ assert_equal root_path, current_path
19
+
20
+ # disable OTP
21
+ disable_otp
22
+
23
+ # logout
24
+ sign_out
25
+
26
+ # log back in 1fa
27
+ sign_user_in(user)
28
+
29
+ assert_equal root_path, current_path
30
+ end
31
+ end
@@ -1,4 +1,5 @@
1
1
  class ActionDispatch::IntegrationTest
2
+ include Warden::Test::Helpers
2
3
 
3
4
  def warden
4
5
  request.env['warden']
@@ -22,7 +23,6 @@ class ActionDispatch::IntegrationTest
22
23
  end
23
24
  end
24
25
 
25
-
26
26
  def enable_otp_and_sign_in
27
27
  user = create_full_user
28
28
  sign_user_in(user)
@@ -36,13 +36,30 @@ class ActionDispatch::IntegrationTest
36
36
  user
37
37
  end
38
38
 
39
+ def otp_challenge_for(user)
40
+ fill_in 'user_token', :with => ROTP::TOTP.new(user.otp_auth_secret).at(Time.now)
41
+ click_button 'Submit Token'
42
+ end
43
+
44
+ def disable_otp
45
+ visit user_otp_token_path
46
+ uncheck 'user_otp_enabled'
47
+ click_button 'Continue...'
48
+ end
49
+
50
+ def sign_out
51
+ logout :user
52
+ end
53
+
39
54
  def sign_user_in(user = nil)
40
55
  user ||= create_full_user
41
56
  resource_name = user.class.name.underscore
42
57
  visit send("new_#{resource_name}_session_path")
43
58
  fill_in "#{resource_name}_email", :with => user.email
44
59
  fill_in "#{resource_name}_password", :with => user.password
45
- click_button 'Sign in'
60
+
61
+ page.has_content?('Log in') ? click_button('Log in') : click_button('Sign in')
46
62
  user
47
63
  end
64
+
48
65
  end
File without changes
@@ -3,17 +3,17 @@ require 'model_tests_helper'
3
3
 
4
4
  class OtpAuthenticatableTest < ActiveSupport::TestCase
5
5
 
6
- def setup
7
- new_user
6
+ def setup
7
+ new_user
8
8
  end
9
9
 
10
10
  test 'new users have a non-nil secret set' do
11
- assert_not_nil User.first.otp_auth_secret
12
- end
11
+ assert_not_nil User.first.otp_auth_secret
12
+ end
13
13
 
14
14
  test 'new users have OTP disabled by default' do
15
- assert !User.first.otp_enabled
16
- end
15
+ assert !User.first.otp_enabled
16
+ end
17
17
 
18
18
  test 'users should have an instance of TOTP/ROTP objects' do
19
19
  u = User.first
@@ -80,6 +80,12 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
80
80
  assert_equal false, u.otp_challenge_valid?
81
81
  end
82
82
 
83
+ test 'null otp challenge' do
84
+ u = User.first
85
+ u.update_attribute(:otp_enabled, true)
86
+ assert_equal false, u.validate_otp_token('')
87
+ assert_equal false, u.validate_otp_token(nil)
88
+ end
83
89
 
84
90
  test 'generated otp token should be valid for the user' do
85
91
  u = User.first
@@ -109,8 +115,7 @@ class OtpAuthenticatableTest < ActiveSupport::TestCase
109
115
  recovery = u.next_otp_recovery_tokens
110
116
 
111
117
  assert u.valid_otp_recovery_token? recovery.fetch(0)
112
- assert_equal false, u.valid_otp_recovery_token?(recovery.fetch(0))
118
+ assert_nil u.valid_otp_recovery_token?(recovery.fetch(0))
113
119
  assert u.valid_otp_recovery_token? recovery.fetch(2)
114
120
  end
115
-
116
- end
121
+ end
@@ -1,4 +1,6 @@
1
1
  ActiveRecord::Migration.verbose = false
2
2
  ActiveRecord::Base.logger = Logger.new(nil)
3
3
 
4
- ActiveRecord::Migrator.migrate(File.expand_path("../../dummy/db/migrate/", __FILE__))
4
+ migrations_path = File.expand_path("../../dummy/db/migrate/", __FILE__)
5
+
6
+ ActiveRecord::MigrationContext.new(migrations_path, ActiveRecord::SchemaMigration).migrate
data/test/test_helper.rb CHANGED
@@ -1,12 +1,12 @@
1
1
  ENV["RAILS_ENV"] = "test"
2
2
  DEVISE_ORM = (ENV["DEVISE_ORM"] || :active_record).to_sym
3
3
 
4
- $:.unshift File.dirname(__FILE__)
5
4
  puts "\n==> Devise.orm = #{DEVISE_ORM.inspect}"
6
5
  require "dummy/config/environment"
7
6
  require "orm/#{DEVISE_ORM}"
8
7
  require 'rails/test_help'
9
8
  require 'capybara/rails'
9
+ require 'capybara/cuprite'
10
10
  require 'minitest/reporters'
11
11
 
12
12
  MiniTest::Reporters.use!
@@ -15,8 +15,77 @@ MiniTest::Reporters.use!
15
15
 
16
16
  #ActiveSupport::Deprecation.silenced = true
17
17
 
18
- #Capybara.default_driver = :selenium
18
+ # Use a module to not pollute the global namespace
19
+ module CapybaraHelper
20
+ def self.register_driver(driver_name, args = [])
21
+ opts = { headless: true, js_errors: true, window_size: [1920, 1200], browser_options: {} }
22
+ args.each do |arg|
23
+ opts[:browser_options][arg] = nil
24
+ end
25
+
26
+ Capybara.register_driver(driver_name) do |app|
27
+ Capybara::Cuprite::Driver.new(app, opts)
28
+ end
29
+ end
30
+ end
31
+
32
+ # Register our own custom drivers
33
+ CapybaraHelper.register_driver(:headless_chrome, %w[disable-gpu no-sandbox disable-dev-shm-usage])
34
+
35
+ # Configure Capybara JS driver
36
+ Capybara.current_driver = :headless_chrome
37
+ Capybara.javascript_driver = :headless_chrome
38
+
39
+ # Configure Capybara server
40
+ Capybara.run_server = true
41
+ Capybara.server = :puma, { Silent: true }
19
42
 
20
43
  class ActionDispatch::IntegrationTest
21
44
  include Capybara::DSL
45
+
46
+ # What capybara calls a "page" in its DSL is actually a Capybara::Session
47
+ # and doesn't know about the *command* method that allows us to play with
48
+ # the Chrome API.
49
+ # See: https://rubydoc.info/github/jnicklas/capybara/master/Capybara/Session
50
+ #
51
+ # To enable downloads we need to do it on the browser's page object, so fetch it
52
+ # from this long method chain.
53
+ # See: https://github.com/rubycdp/ferrum/blob/master/lib/ferrum/page.rb
54
+ def enable_chrome_headless_downloads(session, directory)
55
+ page = session.driver.browser.page
56
+ page.command('Page.setDownloadBehavior', behavior: 'allow', downloadPath: directory)
57
+ end
58
+ end
59
+
60
+ # From https://collectiveidea.com/blog/archives/2012/01/27/testing-file-downloads-with-capybara-and-chromedriver
61
+ module DownloadHelper
62
+ extend self
63
+
64
+ TIMEOUT = 10
65
+
66
+ def downloads
67
+ Dir["/tmp/devise-otp/*"]
68
+ end
69
+
70
+ def wait_for_download(count: 1)
71
+ yield if block_given?
72
+
73
+ Timeout.timeout(TIMEOUT) do
74
+ sleep 0.2 until downloaded?(count)
75
+ end
76
+ end
77
+
78
+ def downloaded?(count)
79
+ !downloading? && downloads.size == count
80
+ end
81
+
82
+ def downloading?
83
+ downloads.grep(/\.crdownload$/).any?
84
+ end
85
+
86
+ def clear_downloads
87
+ FileUtils.rm_f(downloads)
88
+ end
22
89
  end
90
+
91
+ require "devise-otp"