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,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ module Controller
5
+
6
+ def self.included(base)
7
+ base.before_action { CurrentRequestDetails.request = request }
8
+
9
+ base.helper_method :authenticated_model, :logged_in?
10
+
11
+ # Remember the last activity time so we can timeout idle sessions.
12
+ # This has to be done after that timestamp is checked (in `#authenticated_model`)
13
+ # otherwise sessions could never look idle.
14
+ base.after_action { |controller| controller.qv.touch_session_last_seen_at }
15
+ end
16
+
17
+
18
+ def require_password_authentication
19
+ return if logged_in?
20
+ flash[:notice] = QuoVadis.translate 'flash.require_authentication'
21
+ session[:qv_bookmark] = request.original_fullpath
22
+ redirect_to quo_vadis.login_path
23
+ end
24
+ alias_method :require_authentication, :require_password_authentication
25
+
26
+
27
+ # implies require_password_authentication
28
+ def require_two_factor_authentication
29
+ return require_authentication unless logged_in?
30
+ return unless qv.second_factor_required?
31
+ return if qv.second_factor_authenticated?
32
+ redirect_to quo_vadis.challenge_totps_path and return
33
+ end
34
+
35
+
36
+ # To be called with a model which has authenticated with a password.
37
+ #
38
+ # browser_session - true: login only for duration of browser session
39
+ # false: login for QuoVadis.session_lifetime (which may be browser session anyway)
40
+ def login(model, browser_session = true)
41
+ qv.log model.qv_account, Log::LOGIN_SUCCESS
42
+
43
+ qv.prevent_rails_session_fixation
44
+
45
+ lifetime_expires_at = qv.lifetime_expires_at browser_session
46
+
47
+ qv_session = model.qv_account.sessions.create!(
48
+ ip: request.remote_ip,
49
+ user_agent: (request.user_agent || ''),
50
+ lifetime_expires_at: lifetime_expires_at
51
+ )
52
+
53
+ qv.store_session_id qv_session.id, lifetime_expires_at
54
+
55
+ # It is not necessary to set the instance variable here -- the
56
+ # `authenticated_model` method will figure it out from the qv.session --
57
+ # but doing so saves that method a couple of database calls.
58
+ @authenticated_model = model
59
+ end
60
+
61
+
62
+ def logged_in?
63
+ !authenticated_model.nil?
64
+ end
65
+
66
+
67
+ # Returns the model instance which has been authenticated by password,
68
+ # or nil.
69
+ def authenticated_model
70
+ return @authenticated_model if defined? @authenticated_model
71
+
72
+ # Was not logged in so no need to log out.
73
+ return (@authenticated_model = nil) unless qv.session_id
74
+
75
+ _qv_session = qv.session
76
+
77
+ # If _qv_session is nil: user was logged in (because qv.session_id is not nil)
78
+ # but now isn't (because there is no corresponding record in the database). This
79
+ # means the user has remotely logged out this session from another.
80
+ if _qv_session.nil? || _qv_session.expired?
81
+ qv.logout
82
+ return (@authenticated_model = nil)
83
+ end
84
+
85
+ @authenticated_model = _qv_session.account.model
86
+ end
87
+
88
+
89
+ def request_confirmation(model)
90
+ token = QuoVadis::AccountConfirmationToken.generate model.qv_account
91
+ QuoVadis.deliver :account_confirmation, email: model.email, url: quo_vadis.edit_confirmation_url(token)
92
+
93
+ flash[:notice] = QuoVadis.translate 'flash.confirmation.create'
94
+ end
95
+
96
+
97
+ def qv
98
+ @qv_wrapper ||= QuoVadisWrapper.new self
99
+ end
100
+
101
+
102
+ private
103
+
104
+
105
+ class QuoVadisWrapper
106
+ def initialize(controller)
107
+ @controller = controller
108
+ end
109
+
110
+ # Returns the current QuoVadis session or nil.
111
+ def session
112
+ return nil unless session_id
113
+ QuoVadis::Session.find_by id: session_id
114
+ end
115
+
116
+ def session_id
117
+ cookies.encrypted[QuoVadis.cookie_name]
118
+ end
119
+
120
+ # Store the session id in an encrypted cookie.
121
+ #
122
+ # Given that the cookie is encrypted, it is safe to store the database primary key of the
123
+ # session rather than a random-value candidate key.
124
+ #
125
+ # expires_at - the end of the QuoVadis session's lifetime (regardless of the idle timeout)
126
+ def store_session_id(id, expires_at)
127
+ cookies.encrypted[QuoVadis.cookie_name] = {
128
+ value: id,
129
+ httponly: true,
130
+ secure: Rails.env.production?,
131
+ same_site: :lax,
132
+ expires: expires_at # setting expires_at to nil has the same effect as not setting it
133
+ }
134
+ end
135
+
136
+ def clear_session_id
137
+ cookies.delete QuoVadis.cookie_name
138
+ end
139
+
140
+ def prevent_rails_session_fixation
141
+ old_session = rails_session.to_hash
142
+ reset_session
143
+ old_session.each { |k,v| rails_session[k] = v }
144
+ end
145
+
146
+ # Assumes user is logged in.
147
+ def second_factor_required?
148
+ QuoVadis.two_factor_authentication_mandatory || authenticated_model.qv_account.has_two_factors?
149
+ end
150
+
151
+ def second_factor_authenticated?
152
+ session.second_factor_authenticated?
153
+ end
154
+
155
+ def touch_session_last_seen_at
156
+ session&.touch :last_seen_at
157
+ end
158
+
159
+ def session_authenticated_with_second_factor
160
+ session.authenticated_with_second_factor
161
+ end
162
+
163
+ def replace_session
164
+ prevent_rails_session_fixation
165
+
166
+ sess = session.replace
167
+ store_session_id sess.id, sess.lifetime_expires_at
168
+
169
+ controller.instance_variable_set :@authenticated_model, sess.account.model
170
+ end
171
+
172
+ def lifetime_expires_at(browser_session)
173
+ return nil if browser_session
174
+ return nil if QuoVadis.session_lifetime == :session
175
+
176
+ t = ActiveSupport::Duration.build(QuoVadis.session_lifetime).from_now
177
+ QuoVadis.session_lifetime_extend_to_end_of_day ? t.end_of_day : t
178
+ end
179
+
180
+ def logout
181
+ session&.destroy
182
+ clear_session_id
183
+ reset_session
184
+ controller.instance_variable_set :@authenticated_model, nil
185
+ end
186
+
187
+ def logout_other_sessions
188
+ session.logout_other_sessions
189
+ end
190
+
191
+ def log(account, action, metadata = {})
192
+ Log.create account: account, action: action, ip: request.remote_ip, metadata: metadata
193
+ end
194
+
195
+ def path_after_authentication
196
+ if (bookmark = rails_session[:qv_bookmark])
197
+ rails_session.delete :qv_bookmark
198
+ return bookmark
199
+ end
200
+ return main_app.after_login_path if main_app.respond_to?(:after_login_path)
201
+ return main_app.root_path if main_app.respond_to?(:root_path)
202
+ raise RuntimeError, 'Missing routes: after_login_path, root_path; define at least one of them.'
203
+ end
204
+
205
+ def path_after_password_change
206
+ return main_app.after_password_change_path if main_app.respond_to?(:after_password_change_path)
207
+ return main_app.root_path if main_app.respond_to?(:root_path)
208
+ raise RuntimeError, 'Missing routes: after_password_change_path, root_path; define at least one of them.'
209
+ end
210
+
211
+ private
212
+
213
+ attr_reader :controller
214
+
215
+ delegate :request, :reset_session, :authenticated_model, :main_app, to: :controller
216
+
217
+ def cookies
218
+ controller.send :cookies # private method
219
+ end
220
+
221
+ def rails_session
222
+ controller.session
223
+ end
224
+ end
225
+
226
+ end
227
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class Crypt
5
+
6
+ def self.encrypt(value)
7
+ return nil if value.nil?
8
+ return '' if value == ''
9
+
10
+ salt = SecureRandom.hex KEY_LENGTH
11
+ crypt = encryptor key(salt)
12
+ ciphertext = crypt.encrypt_and_sign value
13
+ [salt, ciphertext].join SEPARATOR
14
+ end
15
+
16
+ def self.decrypt(value)
17
+ return nil if value.nil?
18
+ return '' if value == ''
19
+
20
+ salt, data = value.split SEPARATOR
21
+ crypt = encryptor key(salt)
22
+ crypt.decrypt_and_verify(data)
23
+ end
24
+
25
+ private
26
+
27
+ KEY_LENGTH = ActiveSupport::MessageEncryptor.key_len
28
+ SEPARATOR = '$$'
29
+
30
+ def self.encryptor(key)
31
+ ActiveSupport::MessageEncryptor.new(key)
32
+ end
33
+
34
+ def self.key(salt)
35
+ ActiveSupport::KeyGenerator.new(secret).generate_key(salt, KEY_LENGTH)
36
+ end
37
+
38
+ def self.secret
39
+ Rails.application.secret_key_base
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class CurrentRequestDetails < ActiveSupport::CurrentAttributes
5
+ attribute :ip
6
+
7
+ def request=(request)
8
+ self.ip = request.remote_ip
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ require 'active_support/core_ext'
2
+
3
+ QuoVadis.configure do
4
+ password_minimum_length 12
5
+ mask_ips false
6
+ cookie_name '__Host-qv'
7
+ session_lifetime :session
8
+ session_lifetime_extend_to_end_of_day false
9
+ session_idle_timeout :lifetime
10
+ password_reset_token_lifetime 10.minutes
11
+ accounts_require_confirmation false
12
+ account_confirmation_token_lifetime 10.minutes
13
+ mail_headers ({ from: 'Example App <support@example.com>' })
14
+ enqueue_transactional_emails true
15
+ app_name Rails.app_class.to_s.deconstantize # for the TOTP QR code
16
+ two_factor_authentication_mandatory true
17
+ mount_point '/'
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ class EncryptedType < ActiveRecord::Type::Value
5
+
6
+ def deserialize(value)
7
+ Crypt.decrypt(value)
8
+ end
9
+
10
+ def serialize(value)
11
+ Crypt.encrypt(value)
12
+ end
13
+
14
+ end
15
+ end
16
+
17
+ ActiveRecord::Type.register :qv_encrypted, QuoVadis::EncryptedType
@@ -1,15 +1,13 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module QuoVadis
4
+
5
+ def self.table_name_prefix
6
+ 'qv_'
7
+ end
8
+
9
+
2
10
  class Engine < ::Rails::Engine
3
- initializer 'quo_vadis.model' do |app|
4
- ActiveSupport.on_load(:active_record) do
5
- include ModelMixin
6
- end
7
- end
8
-
9
- initializer 'quo_vadis.controller' do |app|
10
- ActiveSupport.on_load(:action_controller) do
11
- include ControllerMixin
12
- end
13
- end
11
+ isolate_namespace QuoVadis
14
12
  end
15
13
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+
6
+ # Much of this comes from Rodauth.
7
+ module QuoVadis
8
+ module Hmacable
9
+
10
+ def compute_hmac(data)
11
+ OpenSSL::HMAC.hexdigest 'SHA256', hmac_secret, data
12
+ end
13
+
14
+ def timing_safe_eql?(provided, actual)
15
+ provided = provided.to_s
16
+ Rack::Utils.secure_compare(provided.ljust(actual.length), actual) && provided.length == actual.length
17
+ end
18
+
19
+ private
20
+
21
+ def hmac_secret
22
+ Rails.application.secret_key_base
23
+ end
24
+
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+
5
+ module QuoVadis
6
+ module IpMasking
7
+
8
+ def self.included(base)
9
+ base.extend ClassMethods
10
+ base.before_validation :mask_ip, if: -> { QuoVadis.mask_ips }
11
+ end
12
+
13
+ def mask_ip
14
+ self.ip = self.class.mask_ip ip
15
+ end
16
+
17
+ module ClassMethods
18
+ # Based on Google Analytics masking
19
+ # https://support.google.com/analytics/answer/2763052
20
+ def mask_ip(ip)
21
+ addr = IPAddr.new ip
22
+ if addr.ipv4?
23
+ addr.mask(24).to_s # set last octet to 0
24
+ else
25
+ addr.mask(48).to_s # set last 80 bits to 0
26
+ end
27
+ end
28
+ end
29
+
30
+ end
31
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QuoVadis
4
+ module Model
5
+
6
+ def self.included(base)
7
+ base.send :extend, ClassMethods
8
+ end
9
+
10
+
11
+ module ClassMethods
12
+ def authenticates(identifier: :email)
13
+ include InstanceMethodsOnActivation
14
+
15
+ has_one :qv_account, as: :model, class_name: 'QuoVadis::Account', dependent: :destroy, autosave: true
16
+
17
+ before_validation :qv_build_account_and_password, on: :create
18
+ before_validation :qv_copy_identifier_to_account
19
+
20
+ # Enable a new-user form to set a password.
21
+ validate :qv_copy_password_errors, on: :create
22
+
23
+ unless validators_on(identifier).any? { |v| ActiveRecord::Validations::UniquenessValidator === v }
24
+ raise NotImplementedError, <<~END
25
+ Missing uniqueness validation on #{name}##{identifier}.
26
+ Try adding: `validates :#{identifier}, uniqueness: {case_sensitive: false}`
27
+ END
28
+ end
29
+
30
+ define_method :qv_copy_identifier_to_account do
31
+ qv_account.identifier = send identifier
32
+ end
33
+
34
+ after_update :qv_log_email_change, if: :saved_change_to_email?
35
+ after_update :qv_notify_email_change, if: :saved_change_to_email?
36
+
37
+ QuoVadis.register_model self.name, identifier
38
+ end
39
+ end
40
+
41
+
42
+ module InstanceMethodsOnActivation
43
+ attr_reader :password, :password_confirmation
44
+
45
+ def password=(val)
46
+ @password = val
47
+ self.qv_account ||= build_qv_account
48
+ raise PasswordExistsError if qv_account.password&.persisted?
49
+ (qv_account.password || qv_account.build_password).password = val
50
+ end
51
+
52
+ def password_confirmation=(val)
53
+ @password_confirmation = val
54
+ self.qv_account ||= build_qv_account
55
+ (qv_account.password || qv_account.build_password).password_confirmation = val
56
+ end
57
+
58
+ private
59
+
60
+ def qv_build_account_and_password
61
+ self.qv_account ||= build_qv_account
62
+ qv_account.password || qv_account.build_password
63
+ end
64
+
65
+ def qv_copy_password_errors
66
+ qv_account.password.valid? # force qv_account.password to validate
67
+ qv_account.password.errors[:password ].each { |message| errors.add :password, message }
68
+ qv_account.password.errors[:password_confirmation].each { |message| errors.add :password_confirmation, message }
69
+ end
70
+
71
+ def qv_log_email_change
72
+ from, to = saved_change_to_email
73
+ Log.create(
74
+ account: qv_account,
75
+ action: Log::EMAIL_CHANGE,
76
+ ip: (CurrentRequestDetails.ip || ''),
77
+ metadata: {from: from, to: to}
78
+ )
79
+ end
80
+
81
+ def qv_notify_email_change
82
+ QuoVadis.notify :email_change_notification, email: saved_change_to_email[0]
83
+ end
84
+ end
85
+ end
86
+ end