quo_vadis 1.4.2 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (126) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +11 -8
  3. data/CHANGELOG.md +5 -0
  4. data/Gemfile +14 -1
  5. data/Gemfile.lock +178 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +435 -127
  8. data/Rakefile +15 -9
  9. data/app/controllers/quo_vadis/confirmations_controller.rb +56 -0
  10. data/app/controllers/quo_vadis/logs_controller.rb +20 -0
  11. data/app/controllers/quo_vadis/password_resets_controller.rb +65 -0
  12. data/app/controllers/quo_vadis/passwords_controller.rb +26 -0
  13. data/app/controllers/quo_vadis/recovery_codes_controller.rb +54 -0
  14. data/app/controllers/quo_vadis/sessions_controller.rb +50 -132
  15. data/app/controllers/quo_vadis/totps_controller.rb +72 -0
  16. data/app/controllers/quo_vadis/twofas_controller.rb +26 -0
  17. data/app/mailers/quo_vadis/mailer.rb +73 -0
  18. data/app/models/quo_vadis/account.rb +59 -0
  19. data/app/models/quo_vadis/account_confirmation_token.rb +17 -0
  20. data/app/models/quo_vadis/log.rb +57 -0
  21. data/app/models/quo_vadis/password.rb +52 -0
  22. data/app/models/quo_vadis/password_reset_token.rb +17 -0
  23. data/app/models/quo_vadis/recovery_code.rb +26 -0
  24. data/app/models/quo_vadis/session.rb +55 -0
  25. data/app/models/quo_vadis/token.rb +42 -0
  26. data/app/models/quo_vadis/totp.rb +56 -0
  27. data/bin/console +15 -0
  28. data/bin/rails +21 -0
  29. data/bin/setup +8 -0
  30. data/config/locales/quo_vadis.en.yml +50 -23
  31. data/config/routes.rb +40 -12
  32. data/db/migrate/202102150904_setup.rb +48 -0
  33. data/lib/generators/quo_vadis/install_generator.rb +4 -23
  34. data/lib/quo_vadis.rb +100 -98
  35. data/lib/quo_vadis/controller.rb +227 -0
  36. data/lib/quo_vadis/crypt.rb +43 -0
  37. data/lib/quo_vadis/current_request_details.rb +11 -0
  38. data/lib/quo_vadis/defaults.rb +18 -0
  39. data/lib/quo_vadis/encrypted_type.rb +17 -0
  40. data/lib/quo_vadis/engine.rb +9 -11
  41. data/lib/quo_vadis/hmacable.rb +26 -0
  42. data/lib/quo_vadis/ip_masking.rb +31 -0
  43. data/lib/quo_vadis/model.rb +86 -0
  44. data/lib/quo_vadis/version.rb +3 -1
  45. data/quo_vadis.gemspec +18 -25
  46. metadata +46 -246
  47. data/app/controllers/controller_mixin.rb +0 -109
  48. data/app/mailers/quo_vadis/notifier.rb +0 -30
  49. data/app/models/model_mixin.rb +0 -128
  50. data/lib/generators/quo_vadis/templates/migration.rb.erb +0 -18
  51. data/lib/generators/quo_vadis/templates/quo_vadis.rb.erb +0 -96
  52. data/test/dummy/.gitignore +0 -2
  53. data/test/dummy/Rakefile +0 -7
  54. data/test/dummy/app/controllers/application_controller.rb +0 -3
  55. data/test/dummy/app/controllers/articles_controller.rb +0 -20
  56. data/test/dummy/app/controllers/users_controller.rb +0 -17
  57. data/test/dummy/app/helpers/application_helper.rb +0 -2
  58. data/test/dummy/app/helpers/articles_helper.rb +0 -2
  59. data/test/dummy/app/models/article.rb +0 -2
  60. data/test/dummy/app/models/person.rb +0 -3
  61. data/test/dummy/app/models/user.rb +0 -3
  62. data/test/dummy/app/views/articles/index.html.erb +0 -1
  63. data/test/dummy/app/views/articles/new.html.erb +0 -11
  64. data/test/dummy/app/views/layouts/application.html.erb +0 -30
  65. data/test/dummy/app/views/layouts/sessions.html.erb +0 -3
  66. data/test/dummy/app/views/quo_vadis/notifier/change_password.text.erb +0 -9
  67. data/test/dummy/app/views/quo_vadis/notifier/invite.text.erb +0 -8
  68. data/test/dummy/app/views/sessions/edit.html.erb +0 -11
  69. data/test/dummy/app/views/sessions/forgotten.html.erb +0 -13
  70. data/test/dummy/app/views/sessions/invite.html.erb +0 -31
  71. data/test/dummy/app/views/sessions/new.html.erb +0 -15
  72. data/test/dummy/app/views/users/new.html.erb +0 -14
  73. data/test/dummy/config.ru +0 -4
  74. data/test/dummy/config/application.rb +0 -21
  75. data/test/dummy/config/boot.rb +0 -10
  76. data/test/dummy/config/database.yml +0 -22
  77. data/test/dummy/config/environment.rb +0 -5
  78. data/test/dummy/config/environments/development.rb +0 -26
  79. data/test/dummy/config/environments/production.rb +0 -49
  80. data/test/dummy/config/environments/test.rb +0 -37
  81. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  82. data/test/dummy/config/initializers/inflections.rb +0 -10
  83. data/test/dummy/config/initializers/mime_types.rb +0 -5
  84. data/test/dummy/config/initializers/quo_vadis.rb +0 -77
  85. data/test/dummy/config/initializers/rack_patch.rb +0 -16
  86. data/test/dummy/config/initializers/secret_token.rb +0 -7
  87. data/test/dummy/config/initializers/session_store.rb +0 -8
  88. data/test/dummy/config/locales/en.yml +0 -5
  89. data/test/dummy/config/locales/quo_vadis.en.yml +0 -21
  90. data/test/dummy/config/routes.rb +0 -5
  91. data/test/dummy/db/migrate/20110124125037_create_users.rb +0 -13
  92. data/test/dummy/db/migrate/20110124131535_create_articles.rb +0 -14
  93. data/test/dummy/db/migrate/20110127094709_add_authentication_to_users.rb +0 -18
  94. data/test/dummy/db/migrate/20111004112209_create_people.rb +0 -13
  95. data/test/dummy/db/migrate/20111004132342_add_authentication_to_people.rb +0 -18
  96. data/test/dummy/db/schema.rb +0 -33
  97. data/test/dummy/public/404.html +0 -26
  98. data/test/dummy/public/422.html +0 -26
  99. data/test/dummy/public/500.html +0 -26
  100. data/test/dummy/public/favicon.ico +0 -0
  101. data/test/dummy/public/javascripts/application.js +0 -2
  102. data/test/dummy/public/javascripts/controls.js +0 -965
  103. data/test/dummy/public/javascripts/dragdrop.js +0 -974
  104. data/test/dummy/public/javascripts/effects.js +0 -1123
  105. data/test/dummy/public/javascripts/prototype.js +0 -6001
  106. data/test/dummy/public/javascripts/rails.js +0 -175
  107. data/test/dummy/public/stylesheets/.gitkeep +0 -0
  108. data/test/dummy/script/rails +0 -6
  109. data/test/integration/activation_test.rb +0 -108
  110. data/test/integration/authenticate_test.rb +0 -39
  111. data/test/integration/blocked_test.rb +0 -23
  112. data/test/integration/config_test.rb +0 -118
  113. data/test/integration/cookie_test.rb +0 -67
  114. data/test/integration/csrf_test.rb +0 -41
  115. data/test/integration/forgotten_test.rb +0 -93
  116. data/test/integration/helper_test.rb +0 -18
  117. data/test/integration/locale_test.rb +0 -197
  118. data/test/integration/navigation_test.rb +0 -7
  119. data/test/integration/sign_in_person_test.rb +0 -26
  120. data/test/integration/sign_in_test.rb +0 -24
  121. data/test/integration/sign_out_test.rb +0 -20
  122. data/test/integration/sign_up_test.rb +0 -21
  123. data/test/quo_vadis_test.rb +0 -7
  124. data/test/support/integration_case.rb +0 -11
  125. data/test/test_helper.rb +0 -86
  126. data/test/unit/user_test.rb +0 -75
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class TotpsController < ApplicationController
5
+ before_action :require_password_authentication
6
+
7
+
8
+ def new
9
+ @totp = authenticated_model.qv_account.build_totp
10
+ end
11
+
12
+
13
+ def create
14
+ @totp = authenticated_model.qv_account.build_totp(
15
+ key: totp_params[:key],
16
+ provided_hmac_key: totp_params[:hmac_key]
17
+ )
18
+ if @totp.verify params[:totp][:otp]
19
+ qv.log authenticated_model.qv_account, Log::TOTP_SETUP
20
+ QuoVadis.notify :totp_setup_notification, email: authenticated_model.email
21
+ qv.session_authenticated_with_second_factor
22
+ flash[:recovery_codes] = generate_recovery_codes
23
+ redirect_to recovery_codes_path
24
+ else
25
+ redirect_to new_totp_path, alert: QuoVadis.translate('flash.totp.unverified')
26
+ end
27
+ end
28
+
29
+
30
+ def challenge
31
+ account = authenticated_model.qv_account
32
+
33
+ unless account.has_two_factors?
34
+ redirect_to new_totp_path, alert: QuoVadis.translate('flash.totp.setup') and return
35
+ end
36
+
37
+ @totp = account.totp
38
+ end
39
+
40
+
41
+ def authenticate
42
+ @totp = authenticated_model.qv_account.totp
43
+ if @totp.verify params[:totp]
44
+ qv.log authenticated_model.qv_account, Log::TOTP_SUCCESS
45
+ qv.replace_session
46
+ qv.session_authenticated_with_second_factor
47
+ redirect_to qv.path_after_authentication, notice: QuoVadis.translate('flash.login.success')
48
+ else
49
+ if @totp.reused? params[:totp]
50
+ qv.log authenticated_model.qv_account, Log::TOTP_REUSE
51
+ QuoVadis.notify :totp_reuse_notification, email: authenticated_model.email
52
+ else
53
+ qv.log authenticated_model.qv_account, Log::TOTP_FAILURE
54
+ end
55
+ flash.now[:alert] = QuoVadis.translate('flash.totp.unverified')
56
+ render :challenge
57
+ end
58
+ end
59
+
60
+
61
+ private
62
+
63
+ def totp_params
64
+ params.require(:totp).permit(:key, :hmac_key, :otp)
65
+ end
66
+
67
+ def generate_recovery_codes
68
+ authenticated_model.qv_account.generate_recovery_codes
69
+ end
70
+
71
+ end
72
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class TwofasController < ApplicationController
5
+ before_action :require_password_authentication
6
+
7
+ def show
8
+ @recovery_codes_count = account.recovery_codes.count
9
+ end
10
+
11
+ def destroy
12
+ account.totp&.destroy
13
+ account.recovery_codes.delete_all
14
+ account.sessions.each &:reset_authenticated_with_second_factor # OWASP ASV v4.0, 2.8.6
15
+ qv.log account, Log::TWOFA_DEACTIVATED
16
+ QuoVadis.notify :twofa_deactivated_notification, email: authenticated_model.email
17
+ redirect_to twofa_path, notice: QuoVadis.translate('flash.2fa.invalidated')
18
+ end
19
+
20
+ private
21
+
22
+ def account
23
+ authenticated_model.qv_account
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class Mailer < ::ActionMailer::Base
5
+
6
+ def reset_password
7
+ @url = params[:url]
8
+ _mail params[:email], QuoVadis.translate('mailer.password_reset.subject')
9
+ end
10
+
11
+ def account_confirmation
12
+ @url = params[:url]
13
+ _mail params[:email], QuoVadis.translate('mailer.confirmation.subject')
14
+ end
15
+
16
+ def email_change_notification
17
+ @timestamp = Time.now
18
+ @ip = QuoVadis::CurrentRequestDetails.ip
19
+ _mail params[:email], QuoVadis.translate('mailer.notification.email_change')
20
+ end
21
+
22
+ def identifier_change_notification
23
+ @timestamp = Time.now
24
+ @identifier = params[:identifier]
25
+ @ip = QuoVadis::CurrentRequestDetails.ip
26
+ _mail params[:email], QuoVadis.translate('mailer.notification.identifier_change',
27
+ identifier: params[:identifier])
28
+ end
29
+
30
+ def password_change_notification
31
+ @timestamp = Time.now
32
+ @ip = QuoVadis::CurrentRequestDetails.ip
33
+ _mail params[:email], QuoVadis.translate('mailer.notification.password_change')
34
+ end
35
+
36
+ def password_reset_notification
37
+ @timestamp = Time.now
38
+ @ip = QuoVadis::CurrentRequestDetails.ip
39
+ _mail params[:email], QuoVadis.translate('mailer.notification.password_reset')
40
+ end
41
+
42
+ def totp_setup_notification
43
+ @timestamp = Time.now
44
+ @ip = QuoVadis::CurrentRequestDetails.ip
45
+ _mail params[:email], QuoVadis.translate('mailer.notification.totp_setup')
46
+ end
47
+
48
+ def totp_reuse_notification
49
+ @timestamp = Time.now
50
+ @ip = QuoVadis::CurrentRequestDetails.ip
51
+ _mail params[:email], QuoVadis.translate('mailer.notification.totp_reuse')
52
+ end
53
+
54
+ def twofa_deactivated_notification
55
+ @timestamp = Time.now
56
+ @ip = QuoVadis::CurrentRequestDetails.ip
57
+ _mail params[:email], QuoVadis.translate('mailer.notification.twofa_deactivated')
58
+ end
59
+
60
+ def recovery_codes_generation_notification
61
+ @timestamp = Time.now
62
+ @ip = QuoVadis::CurrentRequestDetails.ip
63
+ _mail params[:email], QuoVadis.translate('mailer.notification.recovery_codes_generation')
64
+ end
65
+
66
+ private
67
+
68
+ def _mail(to, subject)
69
+ mail QuoVadis.mail_headers.merge(to: to, subject: subject)
70
+ end
71
+
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class Account < ActiveRecord::Base
5
+
6
+ MAX_NUMBER_OF_RECOVERY_CODES = 5
7
+
8
+ belongs_to :model, polymorphic: true
9
+
10
+ has_one :password, dependent: :destroy
11
+ has_many :sessions, dependent: :destroy
12
+ has_one :totp, dependent: :destroy
13
+ has_many :recovery_codes, dependent: :destroy
14
+ has_many :logs, dependent: :destroy
15
+
16
+ validates :identifier, presence: true, uniqueness: {case_sensitive: false}
17
+
18
+ after_update :log_identifier_change, if: :saved_change_to_identifier?
19
+ after_update :notify_identifier_change, if: :saved_change_to_identifier?
20
+
21
+ def confirmed?
22
+ confirmed_at.present?
23
+ end
24
+
25
+ def confirmed!
26
+ touch :confirmed_at
27
+ end
28
+
29
+ def has_two_factors?
30
+ password.present? && totp.present?
31
+ end
32
+
33
+ # Returns an array of the recovery codes' codes.
34
+ def generate_recovery_codes
35
+ Array.new(MAX_NUMBER_OF_RECOVERY_CODES) { recovery_codes.create }.map &:code
36
+ end
37
+
38
+ private
39
+
40
+ def log_identifier_change
41
+ from, to = saved_change_to_identifier
42
+ Log.create(
43
+ account: self,
44
+ action: Log::IDENTIFIER_CHANGE,
45
+ ip: (CurrentRequestDetails.ip || ''),
46
+ metadata: {from: from, to: to}
47
+ )
48
+ end
49
+
50
+ def notify_identifier_change
51
+ # No need to notify if the identifier is :email because
52
+ # the email-is-changed notification in the model mixin handles it.
53
+ QuoVadis.notify(:identifier_change_notification,
54
+ email: model.email,
55
+ identifier: QuoVadis.humanise_identifier(model.class.name).downcase
56
+ ) unless QuoVadis.identifier(model.class.name) == :email
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class AccountConfirmationToken < Token
5
+ class << self
6
+
7
+ def expires_at
8
+ QuoVadis.account_confirmation_token_lifetime.from_now.to_i
9
+ end
10
+
11
+ def data_for_hmac(data, account)
12
+ "#{data}-#{account.confirmed?}"
13
+ end
14
+
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class Log < ActiveRecord::Base
5
+ include IpMasking
6
+
7
+ LOGIN_SUCCESS = 'login.success'
8
+ LOGIN_FAILURE = 'login.failure'
9
+ LOGIN_UNKNOWN = 'login.unknown'
10
+ TOTP_SETUP = 'totp.setup'
11
+ TOTP_SUCCESS = 'totp.success'
12
+ TOTP_FAILURE = 'totp.failure'
13
+ TOTP_REUSE = 'totp.reuse'
14
+ RECOVERY_CODE_SUCCESS = 'recovery_code.success'
15
+ RECOVERY_CODE_FAILURE = 'recovery_code.failure'
16
+ RECOVERY_CODE_GENERATE = 'recovery_code.generate'
17
+ TWOFA_DEACTIVATED = '2fa.deactivated'
18
+ IDENTIFIER_CHANGE = 'identifier.change'
19
+ EMAIL_CHANGE = 'email.change'
20
+ PASSWORD_CHANGE = 'password.change'
21
+ PASSWORD_RESET = 'password.reset'
22
+ ACCOUNT_CONFIRMATION = 'account.confirmation'
23
+ LOGOUT_OTHER = 'logout.other'
24
+ LOGOUT = 'logout'
25
+
26
+ ACTIONS = [
27
+ LOGIN_SUCCESS,
28
+ LOGIN_FAILURE,
29
+ LOGIN_UNKNOWN,
30
+ TOTP_SETUP,
31
+ TOTP_SUCCESS,
32
+ TOTP_FAILURE,
33
+ TOTP_REUSE,
34
+ RECOVERY_CODE_SUCCESS,
35
+ RECOVERY_CODE_FAILURE,
36
+ RECOVERY_CODE_GENERATE,
37
+ TWOFA_DEACTIVATED,
38
+ IDENTIFIER_CHANGE,
39
+ EMAIL_CHANGE,
40
+ PASSWORD_CHANGE,
41
+ PASSWORD_RESET,
42
+ ACCOUNT_CONFIRMATION,
43
+ LOGOUT_OTHER,
44
+ LOGOUT
45
+ ]
46
+
47
+ belongs_to :account, optional: true # optional only for LOGIN_UNKNOWN
48
+
49
+ validates :action, inclusion: {in: ACTIONS}
50
+
51
+ scope :new_to_old, -> { order created_at: :desc }
52
+
53
+ scope :page, ->(page, per_page) {
54
+ limit(per_page).offset((page - 1) * per_page)
55
+ }
56
+ end
57
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class Password < ActiveRecord::Base
5
+ belongs_to :account
6
+
7
+ has_secure_password
8
+
9
+ validates_length_of :password, minimum: QuoVadis.password_minimum_length, allow_blank: true
10
+
11
+ attr_accessor :new_password
12
+
13
+
14
+ def change(current_plaintext, new_plaintext, new_plaintext_confirmation)
15
+ unless authenticate current_plaintext
16
+ errors.add :password, :incorrect
17
+ return false
18
+ end
19
+
20
+ # has_secure_password ignores empty passwords ("") on update so reject them here.
21
+ if new_plaintext.empty?
22
+ errors.add :new_password, :blank
23
+ return false
24
+ end
25
+
26
+ self.password = new_plaintext
27
+ self.password_confirmation = new_plaintext_confirmation
28
+
29
+ if save
30
+ true
31
+ else
32
+ errors.delete(:password)&.each { |e| errors.add :new_password, e }
33
+ errors.delete(:password_confirmation)&.each { |e| errors.add :new_password_confirmation, e }
34
+ false
35
+ end
36
+ end
37
+
38
+
39
+ def reset(new_plaintext, new_plaintext_confirmation)
40
+ # has_secure_password ignores empty passwords ("") on update so reject them here.
41
+ if new_plaintext.empty?
42
+ errors.add :password, :blank
43
+ return false
44
+ end
45
+
46
+ self.password = new_plaintext
47
+ self.password_confirmation = new_plaintext_confirmation
48
+ save
49
+ end
50
+
51
+ end
52
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class PasswordResetToken < Token
5
+ class << self
6
+
7
+ def expires_at
8
+ QuoVadis.password_reset_token_lifetime.from_now.to_i
9
+ end
10
+
11
+ def data_for_hmac(data, account)
12
+ "#{data}-#{account.password.password_digest}"
13
+ end
14
+
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class RecoveryCode < ActiveRecord::Base
5
+ belongs_to :account
6
+
7
+ has_secure_password :code
8
+ before_validation { send("code=", self.class.generate_code) unless code }
9
+
10
+ # Returns true and destroys this instance if the plaintext code is authentic, false otherwise.
11
+ def authenticate_code(plaintext_code)
12
+ !!(destroy if super)
13
+ end
14
+
15
+ private
16
+
17
+ CODE_LENGTH = 11 # odd number
18
+
19
+ # Returns a string of length CODE_LENGTH, with two hexadecimal groups
20
+ # separated by a hyphen.
21
+ def self.generate_code
22
+ group_length = (CODE_LENGTH - 1) / 2
23
+ SecureRandom.hex(group_length).insert(group_length, '-')
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+
5
+ # A session is started once a user logs in with a password,
6
+ # regardless of whether 2FA is also required.
7
+ class Session < ActiveRecord::Base
8
+ include IpMasking
9
+
10
+ belongs_to :account
11
+ validates :ip, presence: true
12
+
13
+ attribute :last_seen_at, :datetime, default: -> { Time.now.utc }
14
+
15
+ def logout_other_sessions
16
+ account.sessions.reject { |s| s == self }.each &:destroy
17
+ end
18
+
19
+ def authenticated_with_second_factor
20
+ touch :second_factor_at
21
+ end
22
+
23
+ def reset_authenticated_with_second_factor
24
+ update second_factor_at: nil
25
+ end
26
+
27
+ def second_factor_authenticated?
28
+ !second_factor_at.nil?
29
+ end
30
+
31
+ def expired?
32
+ exceeded_lifetime? || exceeded_idle_timeout?
33
+ end
34
+
35
+ def replace
36
+ destroy.dup.tap &:save
37
+ end
38
+
39
+ private
40
+
41
+ def exceeded_lifetime?
42
+ return false if browser_session?
43
+ lifetime_expires_at < Time.now.utc
44
+ end
45
+
46
+ def browser_session?
47
+ lifetime_expires_at.nil?
48
+ end
49
+
50
+ def exceeded_idle_timeout?
51
+ return false if QuoVadis.session_idle_timeout == :lifetime
52
+ QuoVadis.session_idle_timeout.since(last_seen_at) < Time.now.utc
53
+ end
54
+ end
55
+ end