quo_vadis 1.4.2 → 2.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (191) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +11 -7
  3. data/CHANGELOG.md +43 -0
  4. data/Gemfile +11 -1
  5. data/LICENSE.txt +21 -0
  6. data/README.md +460 -127
  7. data/Rakefile +15 -9
  8. data/app/controllers/quo_vadis/confirmations_controller.rb +102 -0
  9. data/app/controllers/quo_vadis/logs_controller.rb +20 -0
  10. data/app/controllers/quo_vadis/password_resets_controller.rb +68 -0
  11. data/app/controllers/quo_vadis/passwords_controller.rb +28 -0
  12. data/app/controllers/quo_vadis/recovery_codes_controller.rb +54 -0
  13. data/app/controllers/quo_vadis/sessions_controller.rb +50 -132
  14. data/app/controllers/quo_vadis/totps_controller.rb +72 -0
  15. data/app/controllers/quo_vadis/twofas_controller.rb +26 -0
  16. data/app/mailers/quo_vadis/mailer.rb +73 -0
  17. data/app/models/quo_vadis/account.rb +72 -0
  18. data/app/models/quo_vadis/account_confirmation_token.rb +17 -0
  19. data/app/models/quo_vadis/log.rb +59 -0
  20. data/app/models/quo_vadis/password.rb +68 -0
  21. data/app/models/quo_vadis/password_reset_token.rb +17 -0
  22. data/app/models/quo_vadis/recovery_code.rb +26 -0
  23. data/app/models/quo_vadis/session.rb +55 -0
  24. data/app/models/quo_vadis/token.rb +42 -0
  25. data/app/models/quo_vadis/totp.rb +56 -0
  26. data/app/views/quo_vadis/confirmations/edit.html.erb +10 -0
  27. data/app/views/quo_vadis/confirmations/edit_email.html.erb +14 -0
  28. data/app/views/quo_vadis/confirmations/index.html.erb +14 -0
  29. data/app/views/quo_vadis/confirmations/new.html.erb +16 -0
  30. data/app/views/quo_vadis/logs/index.html.erb +30 -0
  31. data/app/views/quo_vadis/mailer/account_confirmation.text.erb +4 -0
  32. data/app/views/quo_vadis/mailer/email_change_notification.text.erb +8 -0
  33. data/app/views/quo_vadis/mailer/identifier_change_notification.text.erb +8 -0
  34. data/app/views/quo_vadis/mailer/password_change_notification.text.erb +8 -0
  35. data/app/views/quo_vadis/mailer/password_reset_notification.text.erb +8 -0
  36. data/app/views/quo_vadis/mailer/recovery_codes_generation_notification.text.erb +8 -0
  37. data/app/views/quo_vadis/mailer/reset_password.text.erb +4 -0
  38. data/app/views/quo_vadis/mailer/totp_reuse_notification.text.erb +6 -0
  39. data/app/views/quo_vadis/mailer/totp_setup_notification.text.erb +8 -0
  40. data/app/views/quo_vadis/mailer/twofa_deactivated_notification.text.erb +8 -0
  41. data/app/views/quo_vadis/password_resets/edit.html.erb +17 -0
  42. data/app/views/quo_vadis/password_resets/index.html.erb +5 -0
  43. data/app/views/quo_vadis/password_resets/new.html.erb +12 -0
  44. data/app/views/quo_vadis/passwords/edit.html.erb +22 -0
  45. data/app/views/quo_vadis/recovery_codes/challenge.html.erb +11 -0
  46. data/app/views/quo_vadis/recovery_codes/index.html.erb +25 -0
  47. data/app/views/quo_vadis/sessions/index.html.erb +26 -0
  48. data/app/views/quo_vadis/sessions/new.html.erb +24 -0
  49. data/app/views/quo_vadis/totps/challenge.html.erb +11 -0
  50. data/app/views/quo_vadis/totps/new.html.erb +17 -0
  51. data/app/views/quo_vadis/twofas/show.html.erb +20 -0
  52. data/bin/console +15 -0
  53. data/bin/rails +21 -0
  54. data/bin/setup +8 -0
  55. data/config/locales/quo_vadis.en.yml +80 -24
  56. data/config/routes.rb +46 -12
  57. data/db/migrate/202102150904_setup.rb +48 -0
  58. data/lib/generators/quo_vadis/install_generator.rb +4 -23
  59. data/lib/quo_vadis.rb +103 -97
  60. data/lib/quo_vadis/controller.rb +227 -0
  61. data/lib/quo_vadis/crypt.rb +43 -0
  62. data/lib/quo_vadis/current_request_details.rb +11 -0
  63. data/lib/quo_vadis/defaults.rb +18 -0
  64. data/lib/quo_vadis/encrypted_type.rb +17 -0
  65. data/lib/quo_vadis/engine.rb +9 -11
  66. data/lib/quo_vadis/hmacable.rb +26 -0
  67. data/lib/quo_vadis/ip_masking.rb +31 -0
  68. data/lib/quo_vadis/model.rb +83 -0
  69. data/lib/quo_vadis/version.rb +3 -1
  70. data/quo_vadis.gemspec +20 -25
  71. data/test/dummy/README.markdown +1 -0
  72. data/test/dummy/Rakefile +2 -6
  73. data/test/dummy/app/controllers/application_controller.rb +0 -1
  74. data/test/dummy/app/controllers/articles_controller.rb +8 -11
  75. data/test/dummy/app/controllers/sign_ups_controller.rb +42 -0
  76. data/test/dummy/app/controllers/users_controller.rb +13 -5
  77. data/test/dummy/app/models/application_record.rb +3 -0
  78. data/test/dummy/app/models/article.rb +2 -1
  79. data/test/dummy/app/models/person.rb +5 -2
  80. data/test/dummy/app/models/user.rb +4 -1
  81. data/test/dummy/app/views/articles/also_secret.html.erb +1 -0
  82. data/test/dummy/app/views/articles/index.html.erb +1 -1
  83. data/test/dummy/app/views/articles/secret.html.erb +1 -0
  84. data/test/dummy/app/views/articles/very_secret.html.erb +2 -0
  85. data/test/dummy/app/views/layouts/application.html.erb +41 -25
  86. data/test/dummy/app/views/sign_ups/new.html.erb +37 -0
  87. data/test/dummy/app/views/sign_ups/show.html.erb +5 -0
  88. data/test/dummy/app/views/users/new.html.erb +32 -9
  89. data/test/dummy/config.ru +6 -3
  90. data/test/dummy/config/application.rb +18 -9
  91. data/test/dummy/config/boot.rb +3 -9
  92. data/test/dummy/config/database.yml +2 -14
  93. data/test/dummy/config/environment.rb +2 -3
  94. data/test/dummy/config/initializers/quo_vadis.rb +2 -76
  95. data/test/dummy/config/routes.rb +11 -3
  96. data/test/dummy/db/migrate/202102121932_create_users.rb +10 -0
  97. data/test/dummy/db/migrate/202102121935_create_people.rb +10 -0
  98. data/test/dummy/db/schema.rb +80 -21
  99. data/test/fixtures/quo_vadis/mailer/account_confirmation.text +4 -0
  100. data/test/fixtures/quo_vadis/mailer/email_change_notification.text +8 -0
  101. data/test/fixtures/quo_vadis/mailer/identifier_change_notification.text +8 -0
  102. data/test/fixtures/quo_vadis/mailer/password_change_notification.text +8 -0
  103. data/test/fixtures/quo_vadis/mailer/password_reset_notification.text +8 -0
  104. data/test/fixtures/quo_vadis/mailer/recovery_codes_generation_notification.text +8 -0
  105. data/test/fixtures/quo_vadis/mailer/reset_password.text +4 -0
  106. data/test/fixtures/quo_vadis/mailer/totp_reuse_notification.text +6 -0
  107. data/test/fixtures/quo_vadis/mailer/totp_setup_notification.text +8 -0
  108. data/test/fixtures/quo_vadis/mailer/twofa_deactivated_notification.text +8 -0
  109. data/test/integration/account_confirmation_test.rb +145 -0
  110. data/test/integration/controller_test.rb +280 -0
  111. data/test/integration/logging_test.rb +244 -0
  112. data/test/integration/password_change_test.rb +99 -0
  113. data/test/integration/password_login_test.rb +137 -0
  114. data/test/integration/password_reset_test.rb +136 -0
  115. data/test/integration/recovery_codes_test.rb +48 -0
  116. data/test/integration/sessions_test.rb +86 -0
  117. data/test/integration/sign_up_test.rb +26 -12
  118. data/test/integration/totps_test.rb +96 -0
  119. data/test/integration/twofa_test.rb +82 -0
  120. data/test/mailers/mailer_test.rb +200 -0
  121. data/test/models/account_test.rb +50 -0
  122. data/test/models/crypt_test.rb +22 -0
  123. data/test/models/log_test.rb +16 -0
  124. data/test/models/mask_ip_test.rb +27 -0
  125. data/test/models/model_test.rb +66 -0
  126. data/test/models/password_test.rb +179 -0
  127. data/test/models/recovery_code_test.rb +54 -0
  128. data/test/models/session_test.rb +67 -0
  129. data/test/models/token_test.rb +70 -0
  130. data/test/models/totp_test.rb +68 -0
  131. data/test/quo_vadis_test.rb +39 -3
  132. data/test/test_helper.rb +42 -70
  133. metadata +120 -204
  134. data/app/controllers/controller_mixin.rb +0 -109
  135. data/app/mailers/quo_vadis/notifier.rb +0 -30
  136. data/app/models/model_mixin.rb +0 -128
  137. data/lib/generators/quo_vadis/templates/migration.rb.erb +0 -18
  138. data/lib/generators/quo_vadis/templates/quo_vadis.rb.erb +0 -96
  139. data/test/dummy/.gitignore +0 -2
  140. data/test/dummy/app/helpers/application_helper.rb +0 -2
  141. data/test/dummy/app/helpers/articles_helper.rb +0 -2
  142. data/test/dummy/app/views/articles/new.html.erb +0 -11
  143. data/test/dummy/app/views/layouts/sessions.html.erb +0 -3
  144. data/test/dummy/app/views/quo_vadis/notifier/change_password.text.erb +0 -9
  145. data/test/dummy/app/views/quo_vadis/notifier/invite.text.erb +0 -8
  146. data/test/dummy/app/views/sessions/edit.html.erb +0 -11
  147. data/test/dummy/app/views/sessions/forgotten.html.erb +0 -13
  148. data/test/dummy/app/views/sessions/invite.html.erb +0 -31
  149. data/test/dummy/app/views/sessions/new.html.erb +0 -15
  150. data/test/dummy/config/environments/development.rb +0 -26
  151. data/test/dummy/config/environments/production.rb +0 -49
  152. data/test/dummy/config/environments/test.rb +0 -37
  153. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  154. data/test/dummy/config/initializers/inflections.rb +0 -10
  155. data/test/dummy/config/initializers/mime_types.rb +0 -5
  156. data/test/dummy/config/initializers/rack_patch.rb +0 -16
  157. data/test/dummy/config/initializers/secret_token.rb +0 -7
  158. data/test/dummy/config/initializers/session_store.rb +0 -8
  159. data/test/dummy/config/locales/en.yml +0 -5
  160. data/test/dummy/config/locales/quo_vadis.en.yml +0 -21
  161. data/test/dummy/db/migrate/20110124125037_create_users.rb +0 -13
  162. data/test/dummy/db/migrate/20110124131535_create_articles.rb +0 -14
  163. data/test/dummy/db/migrate/20110127094709_add_authentication_to_users.rb +0 -18
  164. data/test/dummy/db/migrate/20111004112209_create_people.rb +0 -13
  165. data/test/dummy/db/migrate/20111004132342_add_authentication_to_people.rb +0 -18
  166. data/test/dummy/public/404.html +0 -26
  167. data/test/dummy/public/422.html +0 -26
  168. data/test/dummy/public/500.html +0 -26
  169. data/test/dummy/public/javascripts/application.js +0 -2
  170. data/test/dummy/public/javascripts/controls.js +0 -965
  171. data/test/dummy/public/javascripts/dragdrop.js +0 -974
  172. data/test/dummy/public/javascripts/effects.js +0 -1123
  173. data/test/dummy/public/javascripts/prototype.js +0 -6001
  174. data/test/dummy/public/javascripts/rails.js +0 -175
  175. data/test/dummy/public/stylesheets/.gitkeep +0 -0
  176. data/test/dummy/script/rails +0 -6
  177. data/test/integration/activation_test.rb +0 -108
  178. data/test/integration/authenticate_test.rb +0 -39
  179. data/test/integration/blocked_test.rb +0 -23
  180. data/test/integration/config_test.rb +0 -118
  181. data/test/integration/cookie_test.rb +0 -67
  182. data/test/integration/csrf_test.rb +0 -41
  183. data/test/integration/forgotten_test.rb +0 -93
  184. data/test/integration/helper_test.rb +0 -18
  185. data/test/integration/locale_test.rb +0 -197
  186. data/test/integration/navigation_test.rb +0 -7
  187. data/test/integration/sign_in_person_test.rb +0 -26
  188. data/test/integration/sign_in_test.rb +0 -24
  189. data/test/integration/sign_out_test.rb +0 -20
  190. data/test/support/integration_case.rb +0 -11
  191. 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
+ session[:qv_bookmark] = request.original_fullpath
21
+ redirect_to quo_vadis.login_path, notice: QuoVadis.translate('flash.require_authentication')
22
+ end
23
+ alias_method :require_authentication, :require_password_authentication
24
+
25
+
26
+ # implies require_password_authentication
27
+ def require_two_factor_authentication
28
+ return require_authentication unless logged_in?
29
+ return unless qv.second_factor_required?
30
+ return if qv.second_factor_authenticated?
31
+ redirect_to quo_vadis.challenge_totps_path and return
32
+ end
33
+
34
+
35
+ # To be called with a model which has authenticated with a password.
36
+ #
37
+ # browser_session - true: login only for duration of browser session
38
+ # false: login for QuoVadis.session_lifetime (which may be browser session anyway)
39
+ def login(model, browser_session = true)
40
+ qv.log model.qv_account, Log::LOGIN_SUCCESS
41
+
42
+ qv.prevent_rails_session_fixation
43
+
44
+ lifetime_expires_at = qv.lifetime_expires_at browser_session
45
+
46
+ qv_session = model.qv_account.sessions.create!(
47
+ ip: request.remote_ip,
48
+ user_agent: (request.user_agent || ''),
49
+ lifetime_expires_at: lifetime_expires_at
50
+ )
51
+
52
+ qv.store_session_id qv_session.id, lifetime_expires_at
53
+
54
+ # It is not necessary to set the instance variable here -- the
55
+ # `authenticated_model` method will figure it out from the qv.session --
56
+ # but doing so saves that method a couple of database calls.
57
+ @authenticated_model = model
58
+ end
59
+
60
+
61
+ def logged_in?
62
+ !authenticated_model.nil?
63
+ end
64
+
65
+
66
+ # Returns the model instance which has been authenticated by password,
67
+ # or nil.
68
+ def authenticated_model
69
+ return @authenticated_model if defined? @authenticated_model
70
+
71
+ # Was not logged in so no need to log out.
72
+ return (@authenticated_model = nil) unless qv.session_id
73
+
74
+ _qv_session = qv.session
75
+
76
+ # If _qv_session is nil: user was logged in (because qv.session_id is not nil)
77
+ # but now isn't (because there is no corresponding record in the database). This
78
+ # means the user has remotely logged out this session from another.
79
+ if _qv_session.nil? || _qv_session.expired?
80
+ qv.logout
81
+ return (@authenticated_model = nil)
82
+ end
83
+
84
+ @authenticated_model = _qv_session.account.model
85
+ end
86
+
87
+
88
+ def request_confirmation(model)
89
+ token = QuoVadis::AccountConfirmationToken.generate model.qv_account
90
+ QuoVadis.deliver :account_confirmation, email: model.email, url: quo_vadis.confirmation_url(token)
91
+ session[:account_pending_confirmation] = model.qv_account.id
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 (Rails.env.production? ? '__Host-qv' : '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,83 @@
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_copy_identifier_to_account, if: Proc.new { |m| m.qv_account }
18
+
19
+ validate :qv_copy_password_errors, if: Proc.new { |m| m.qv_account&.password }
20
+
21
+ unless validators_on(identifier).any? { |v| ActiveRecord::Validations::UniquenessValidator === v }
22
+ raise NotImplementedError, <<~END
23
+ Missing uniqueness validation on #{name}##{identifier}.
24
+ Try adding: `validates :#{identifier}, uniqueness: {case_sensitive: false}`
25
+ END
26
+ end
27
+
28
+ define_method :qv_copy_identifier_to_account do
29
+ qv_account.identifier = send identifier
30
+ end
31
+
32
+ after_update :qv_log_email_change, if: :saved_change_to_email?
33
+ after_update :qv_notify_email_change, if: :saved_change_to_email?
34
+
35
+ QuoVadis.register_model self.name, identifier
36
+ end
37
+ end
38
+
39
+
40
+ module InstanceMethodsOnActivation
41
+ attr_reader :password, :password_confirmation
42
+
43
+ def password=(val)
44
+ @password = val
45
+ build_qv_account unless qv_account
46
+ raise PasswordExistsError if qv_account.password&.persisted?
47
+ (qv_account.password || qv_account.build_password).password = val
48
+ end
49
+
50
+ def password_confirmation=(val)
51
+ @password_confirmation = val
52
+ build_qv_account unless qv_account
53
+ (qv_account.password || qv_account.build_password).password_confirmation = val
54
+ end
55
+
56
+ def revoke_authentication_credentials
57
+ qv_account.revoke
58
+ end
59
+
60
+ private
61
+
62
+ def qv_copy_password_errors
63
+ qv_account.password.valid? # force qv_account.password to validate
64
+ qv_account.password.errors[:password ].each { |message| errors.add :password, message }
65
+ qv_account.password.errors[:password_confirmation].each { |message| errors.add :password_confirmation, message }
66
+ end
67
+
68
+ def qv_log_email_change
69
+ from, to = saved_change_to_email
70
+ Log.create(
71
+ account: qv_account,
72
+ action: Log::EMAIL_CHANGE,
73
+ ip: (CurrentRequestDetails.ip || ''),
74
+ metadata: {from: from, to: to}
75
+ )
76
+ end
77
+
78
+ def qv_notify_email_change
79
+ QuoVadis.notify :email_change_notification, email: saved_change_to_email[0]
80
+ end
81
+ end
82
+ end
83
+ end