searls-auth 0.2.0 → 1.0.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -1
  3. data/README.md +162 -0
  4. data/app/controllers/searls/auth/base_controller.rb +42 -21
  5. data/app/controllers/searls/auth/email_verifications_controller.rb +57 -0
  6. data/app/controllers/searls/auth/logins_controller.rb +60 -39
  7. data/app/controllers/searls/auth/registrations_controller.rb +84 -32
  8. data/app/controllers/searls/auth/requests_password_resets_controller.rb +55 -0
  9. data/app/controllers/searls/auth/resets_passwords_controller.rb +73 -0
  10. data/app/controllers/searls/auth/settings_controller.rb +83 -0
  11. data/app/controllers/searls/auth/verifications_controller.rb +31 -61
  12. data/app/helpers/searls/auth/application_helper.rb +9 -5
  13. data/app/mailers/searls/auth/base_mailer.rb +1 -1
  14. data/app/mailers/searls/auth/email_verification_mailer.rb +29 -0
  15. data/app/mailers/searls/auth/login_link_mailer.rb +3 -3
  16. data/app/mailers/searls/auth/password_reset_mailer.rb +29 -0
  17. data/app/views/searls/auth/email_verification_mailer/verification_email.html.erb +23 -0
  18. data/app/views/searls/auth/email_verification_mailer/verification_email.text.erb +6 -0
  19. data/app/views/searls/auth/login_link_mailer/login_link.html.erb +5 -5
  20. data/app/views/searls/auth/login_link_mailer/login_link.text.erb +4 -5
  21. data/app/views/searls/auth/logins/show.html.erb +12 -4
  22. data/app/views/searls/auth/password_reset_mailer/password_reset.html.erb +23 -0
  23. data/app/views/searls/auth/password_reset_mailer/password_reset.text.erb +6 -0
  24. data/app/views/searls/auth/registrations/pending_email_verification.html.erb +12 -0
  25. data/app/views/searls/auth/registrations/show.html.erb +1 -2
  26. data/app/views/searls/auth/requests_password_resets/show.html.erb +17 -0
  27. data/app/views/searls/auth/resets_passwords/show.html.erb +26 -0
  28. data/app/views/searls/auth/settings/edit.html.erb +31 -0
  29. data/app/views/searls/auth/shared/_login_fields.html.erb +11 -0
  30. data/app/views/searls/auth/shared/_register_fields.html.erb +15 -0
  31. data/config/routes.rb +11 -0
  32. data/lib/searls/auth/authenticates_user.rb +54 -10
  33. data/lib/searls/auth/builds_target_redirect_url.rb +72 -0
  34. data/lib/searls/auth/config.rb +243 -12
  35. data/lib/searls/auth/creates_user.rb +12 -4
  36. data/lib/searls/auth/delivers_password_reset.rb +18 -0
  37. data/lib/searls/auth/emails_link.rb +2 -2
  38. data/lib/searls/auth/emails_verification.rb +33 -0
  39. data/lib/searls/auth/parses_time_safely.rb +34 -0
  40. data/lib/searls/auth/railtie.rb +0 -1
  41. data/lib/searls/auth/resets_password.rb +41 -0
  42. data/lib/searls/auth/updates_settings.rb +149 -0
  43. data/lib/searls/auth/version.rb +1 -1
  44. data/lib/searls/auth.rb +62 -13
  45. metadata +23 -1
@@ -0,0 +1,11 @@
1
+ <div>
2
+ <%= f.label :email %>
3
+ <%= f.email_field :email, required: true, value: params[:email], data: searls_auth_helper.email_field_stimulus_data %>
4
+ </div>
5
+ <% if Searls::Auth.config.auth_methods.include?(:password) %>
6
+ <div>
7
+ <%= f.label :password %>
8
+ <%= f.password_field :password, autocomplete: "current-password" %>
9
+ </div>
10
+ <% end %>
11
+
@@ -0,0 +1,15 @@
1
+ <div>
2
+ <%= f.label :email %>
3
+ <%= f.email_field :email, value: params[:email], required: true, data: searls_auth_helper.email_field_stimulus_data %>
4
+ </div>
5
+ <% if Searls::Auth.config.auth_methods.include?(:password) %>
6
+ <div>
7
+ <%= f.label :password %>
8
+ <%= f.password_field :password, autocomplete: "new-password" %>
9
+ </div>
10
+ <div>
11
+ <%= f.label :password_confirmation %>
12
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
13
+ </div>
14
+ <% end %>
15
+
data/config/routes.rb CHANGED
@@ -9,4 +9,15 @@ Searls::Auth::Engine.routes.draw do
9
9
  get "login/verify", to: "verifications#show", as: :verify
10
10
  post "login/verify", to: "verifications#create"
11
11
  get "login/verify_token", to: "verifications#create", as: :verify_token
12
+ match "email/resend_verification", via: [:get, :patch], to: "email_verifications#resend", as: :resend_email_verification
13
+ get "email/pending_verification", to: "registrations#pending_email_verification", as: :pending_email_verification
14
+
15
+ get "email/verify", to: "email_verifications#show", as: :verify_email
16
+
17
+ resource :settings, only: [:edit, :update]
18
+
19
+ get "password/reset", to: "requests_password_resets#show", as: :password_reset_request
20
+ post "password/reset", to: "requests_password_resets#create"
21
+ get "password/reset/edit", to: "resets_passwords#show", as: :password_reset_edit
22
+ patch "password/reset", to: "resets_passwords#update", as: :password_reset_update
12
23
  end
@@ -1,18 +1,23 @@
1
1
  module Searls
2
2
  module Auth
3
3
  class AuthenticatesUser
4
- Result = Struct.new(:success?, :user, :exceeded_short_code_attempt_limit?, keyword_init: true)
4
+ def initialize
5
+ @parses_time_safely = ParsesTimeSafely.new
6
+ end
7
+ Result = Struct.new(:success?, :user, :exceeded_email_otp_attempt_limit?, :email_unverified?, keyword_init: true)
5
8
 
6
- def authenticate_by_short_code(short_code, session)
7
- if session[:searls_auth_short_code_verification_attempts] > Searls::Auth.config.max_allowed_short_code_attempts
8
- return Result.new(success?: false, exceeded_short_code_attempt_limit?: true)
9
+ def authenticate_by_email_otp(email_otp, session)
10
+ if session[:searls_auth_email_otp_verification_attempts] > Searls::Auth.config.max_allowed_email_otp_attempts
11
+ return Result.new(success?: false, exceeded_email_otp_attempt_limit?: true)
9
12
  end
10
13
 
11
- if session[:searls_auth_short_code_generated_at].present? &&
12
- Time.zone.parse(session[:searls_auth_short_code_generated_at]) > Searls::Auth.config.token_expiry_minutes.minutes.ago &&
13
- short_code == session[:searls_auth_short_code] &&
14
- (user = Searls::Auth.config.user_finder_by_id.call(session[:searls_auth_short_code_user_id])).present?
15
- Searls::Auth.config.after_login_success&.call(user)
14
+ generated_at_value = session[:searls_auth_email_otp_generated_at]
15
+ if generated_at_value.present? &&
16
+ (generated_at = parse_otp_timestamp(generated_at_value)) &&
17
+ generated_at > email_otp_expiry_cutoff &&
18
+ email_otp == session[:searls_auth_email_otp] &&
19
+ (user = Searls::Auth.config.user_finder_by_id.call(session[:searls_auth_email_otp_user_id])).present?
20
+ Searls::Auth.config.after_login_success.call(user)
16
21
  Result.new(success?: true, user: user)
17
22
  else
18
23
  Result.new(success?: false)
@@ -23,12 +28,51 @@ module Searls
23
28
  user = Searls::Auth.config.user_finder_by_token.call(token)
24
29
 
25
30
  if user.present?
26
- Searls::Auth.config.after_login_success&.call(user)
31
+ Searls::Auth.config.after_login_success.call(user)
32
+ Result.new(success?: true, user: user)
33
+ else
34
+ Result.new(success?: false)
35
+ end
36
+ end
37
+
38
+ def authenticate_by_password(email, password, session)
39
+ user = Searls::Auth.config.user_finder_by_email.call(email)
40
+ return Result.new(success?: false) if user.blank?
41
+
42
+ configuration = Searls::Auth.config
43
+
44
+ if requires_verification?(configuration) && !configuration.email_verified_predicate.call(user)
45
+ return Result.new(success?: false, email_unverified?: true)
46
+ end
47
+
48
+ begin
49
+ ok = configuration.password_verifier.call(user, password)
50
+ rescue NameError
51
+ return Result.new(success?: false) # controller will map to misconfiguration message
52
+ end
53
+
54
+ if ok
55
+ configuration.after_login_success.call(user)
27
56
  Result.new(success?: true, user: user)
28
57
  else
29
58
  Result.new(success?: false)
30
59
  end
31
60
  end
61
+
62
+ private
63
+
64
+ def requires_verification?(configuration)
65
+ configuration.email_verification_mode == :required
66
+ end
67
+
68
+ def email_otp_expiry_cutoff
69
+ minutes = Searls::Auth.config.email_otp_expiry_minutes
70
+ Time.zone.now - (minutes * 60)
71
+ end
72
+
73
+ def parse_otp_timestamp(value)
74
+ @parses_time_safely.parse(value)
75
+ end
32
76
  end
33
77
  end
34
78
  end
@@ -0,0 +1,72 @@
1
+ require "uri"
2
+
3
+ module Searls
4
+ module Auth
5
+ class BuildsTargetRedirectUrl
6
+ def build(request, params)
7
+ path = normalize_path(params[:redirect_path])
8
+ host = resolve_host(request, params[:redirect_subdomain])
9
+
10
+ if host == request.host && path.present?
11
+ path
12
+ elsif host != request.host
13
+ absolute_url(request, host, path)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def normalize_path(raw)
20
+ if !raw.nil? && !(v = raw.to_s.strip).empty?
21
+ v = v.sub(%r{\Ahttps?://[^/?#]+}i, "")
22
+ "/#{v}".sub(%r{\A/+/}, "/")
23
+ end
24
+ end
25
+
26
+ def resolve_host(request, subdomain)
27
+ s = normalize_subdomain(subdomain)
28
+ cur = request.subdomain.presence
29
+ return request.host if s.nil? || s == cur || (s == "" && cur.nil?)
30
+ return root_host(request) || request.host if s == "" && cur
31
+ base = root_host(request) || request.host
32
+ "#{s}.#{base}"
33
+ end
34
+
35
+ def normalize_subdomain(raw)
36
+ return "" if raw.is_a?(String) && raw.strip.empty?
37
+ if (v = raw.to_s.downcase).present?
38
+ v if /\A[a-z0-9-]+\z/.match?(v)
39
+ end
40
+ end
41
+
42
+ def root_host(request)
43
+ request.domain.presence || begin
44
+ host = URI.parse(request.base_url).host
45
+ sub = request.subdomain.to_s
46
+ if sub.empty?
47
+ host
48
+ else
49
+ pref = "#{sub}."
50
+ host.start_with?(pref) ? host.delete_prefix(pref) : host
51
+ end
52
+ end
53
+ end
54
+
55
+ def absolute_url(request, host, path)
56
+ uri = URI.parse(request.base_url)
57
+ uri.host = host
58
+ if path && !path.empty?
59
+ m = path.match(/\A([^?#]*)(?:\?([^#]*))?(?:#(.*))?\z/)
60
+ uri.path = m[1]
61
+ uri.query = m[2]
62
+ uri.fragment = m[3]
63
+ else
64
+ uri.path = ""
65
+ uri.query = nil
66
+ uri.fragment = nil
67
+ end
68
+ uri.to_s
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,7 +1,26 @@
1
1
  module Searls
2
2
  module Auth
3
+ # Numeric config keys coerced to Integer and required to be > 0
4
+ NUMERIC_FIELDS = [
5
+ :email_otp_expiry_minutes,
6
+ :max_allowed_email_otp_attempts
7
+ ].freeze
8
+
9
+ # Core hooks that must always be callable
10
+ HOOK_FIELDS = [
11
+ :user_finder_by_email,
12
+ :user_finder_by_id,
13
+ :user_finder_by_token,
14
+ :user_initializer,
15
+ :token_generator,
16
+ :email_verified_predicate,
17
+ :email_verified_setter,
18
+ :validate_registration,
19
+ :after_login_success
20
+ ].freeze
3
21
  Config = Struct.new(
4
22
  :auth_methods, # array of symbols, e.g., [:email_link, :email_otp]
23
+ :email_verification_mode, # :none, :optional, :required
5
24
  # Data setup
6
25
  :user_finder_by_email, # proc(email)
7
26
  :user_finder_by_id, # proc(id)
@@ -9,28 +28,44 @@ module Searls
9
28
  :user_initializer, # proc(params)
10
29
  :user_name_method, # string
11
30
  :token_generator, # proc()
12
- :token_expiry_minutes, # integer
31
+ :password_verifier, # proc(user, password)
32
+ :password_setter, # proc(user, password)
33
+ :password_reset_token_generator, # proc(user)
34
+ :password_reset_token_finder, # proc(token)
35
+ :before_password_reset, # proc(user, params, controller)
36
+ :password_reset_enabled, # boolean
37
+ :email_verified_predicate, # proc(user)
38
+ :email_verified_setter, # proc(user, time = Time.current)
39
+ :password_present_predicate, # proc(user)
13
40
  # Controller setup
14
41
  :preserve_session_keys_after_logout, # array of symbols
15
- :max_allowed_short_code_attempts, # integer
42
+ :max_allowed_email_otp_attempts, # integer (email OTP attempts)
43
+ :email_otp_expiry_minutes, # integer
16
44
  # View setup
17
45
  :layout, # string
18
46
  :login_view, # string
19
47
  :register_view, # string
20
48
  :verify_view, # string
49
+ :pending_email_verification_view, # string
50
+ :password_reset_request_view, # string
51
+ :password_reset_edit_view, # string
21
52
  :mail_layout, # string
22
53
  :mail_login_template_path, # string
23
54
  :mail_login_template_name, # string
55
+ :mail_password_reset_template_path, # string
56
+ :mail_password_reset_template_name, # string
57
+ :mail_email_verification_template_path, # string
58
+ :mail_email_verification_template_name, # string
24
59
  # Routing setup
25
60
  :redirect_path_after_register, # string or proc(user, params, request, routes), all new registrations redirect here
26
- :default_redirect_path_after_login, # string or proc(user, params, request, routes), only redirected here if redirect_path param not set
61
+ :redirect_path_after_login, # string or proc(user, params, request, routes), only redirected here if redirect_path param not set
62
+ :redirect_path_after_settings_change, # string or proc(user, params, request, routes), post-settings updates redirect here
27
63
  # Hook setup
28
64
  :validate_registration, # proc(user, params, errors = []), must return an array of error messages where empty means valid
29
65
  :after_login_success, # proc(user)
30
66
  # Branding setup
31
67
  :app_name, # string
32
68
  :app_url, # string
33
- :support_email_address, # string
34
69
  :email_banner_image_path, # string
35
70
  :email_background_color, # string
36
71
  :email_button_color, # string
@@ -39,24 +74,220 @@ module Searls
39
74
  :flash_error_after_register_attempt, # string or proc(error_messages, login_path, params)
40
75
  :flash_notice_after_login_attempt, # string or proc(user, params)
41
76
  :flash_error_after_login_attempt_unknown_email, # string or proc(register_path, params)
77
+ :flash_error_after_login_attempt_invalid_password, # string or proc(params)
78
+ :flash_error_after_login_attempt_unverified_email, # string or proc(resend_email_verification_path, params)
79
+ :flash_notice_after_login_with_unverified_email, # string or proc(resend_email_verification_path, params)
80
+ :flash_error_after_password_misconfigured, # string or proc(params)
81
+ :flash_error_after_password_reset_token_invalid, # string or proc(params)
82
+ :flash_error_after_password_reset_password_mismatch, # string or proc(params)
83
+ :flash_error_after_password_reset_password_blank, # string or proc(params)
84
+ :flash_error_after_password_reset_not_enabled, # string or proc(params)
42
85
  :flash_notice_after_logout, # string or proc(params)
43
- :flash_notice_after_verification, # string or proc(user, params)
86
+ :flash_notice_after_login, # string or proc(user, params)
87
+ :flash_notice_after_verification_email_resent, # string or proc(params)
88
+ :flash_notice_after_email_verified, # string or proc(user, params)
89
+ :flash_notice_after_password_reset_email, # string or proc(params)
90
+ :flash_notice_after_password_reset, # string or proc(user, params)
44
91
  :flash_error_after_verify_attempt_exceeds_limit, # string or proc(params)
45
- :flash_error_after_verify_attempt_incorrect_short_code, # string or proc(params)
92
+ :flash_error_after_verify_attempt_incorrect_email_otp, # string or proc(params)
46
93
  :flash_error_after_verify_attempt_invalid_link, # string or proc(params)
94
+ :flash_notice_after_settings_update, # string or proc(user, params)
95
+ :flash_error_after_settings_current_password_missing, # string or proc(params)
96
+ :flash_error_after_settings_current_password_invalid, # string or proc(params)
97
+ :auto_login_after_password_reset, # boolean
47
98
  keyword_init: true
48
99
  ) do
49
100
  # Get values from values that might be procs
50
101
  def resolve(option, *args)
51
- if self[option].respond_to?(:call)
52
- self[option].call(*args)
53
- else
54
- self[option]
102
+ key = option.to_sym
103
+ value = public_send(key)
104
+ value.respond_to?(:call) ? value.call(*args) : value
105
+ end
106
+
107
+ def password_reset_enabled?
108
+ auth_methods.include?(:password) && password_reset_enabled
109
+ end
110
+
111
+ def password_present?(user)
112
+ predicate = password_present_predicate
113
+ return false if predicate.nil?
114
+
115
+ predicate.call(user)
116
+ end
117
+
118
+ def validate!
119
+ validate_auth_methods!
120
+ validate_email_verification_mode!
121
+ validate_numeric_options!
122
+ validate_core_hooks!
123
+ validate_password_settings!
124
+ validate_default_user_hooks!
125
+ rescue => e
126
+ unless [
127
+ "PG::UndefinedTable",
128
+ "ActiveRecord::NoDatabaseError",
129
+ "ActiveRecord::StatementInvalid"
130
+ ].include?(e.class.inspect)
131
+ # don't validate when connection isn't esstablished
132
+ raise
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def validate_auth_methods!
139
+ normalized = Array(auth_methods).map(&:to_sym)
140
+ self.auth_methods = normalized
141
+ allowed = [:password, :email_link, :email_otp]
142
+ raise Searls::Auth::Error, "auth_methods cannot be empty; enable at least one of :password, :email_link, or :email_otp" if normalized.empty?
143
+ unknown = normalized - allowed
144
+ if unknown.any?
145
+ raise Searls::Auth::Error, "Unknown auth_methods: #{unknown.inspect}. Allowed: #{allowed.inspect}"
146
+ end
147
+ end
148
+
149
+ def validate_email_verification_mode!
150
+ mode_value = email_verification_mode
151
+ mode = mode_value.respond_to?(:to_sym) ? mode_value.to_sym : :none
152
+ self.email_verification_mode = mode
153
+ auth_methods
154
+ # Allow email verification regardless of email auth methods; verification emails are separate
155
+ end
156
+
157
+ def validate_password_settings!
158
+ methods = auth_methods
159
+ return unless methods.include?(:password)
160
+
161
+ using_default_hooks =
162
+ password_verifier.equal?(Searls::Auth::DEFAULT_CONFIG[:password_verifier]) &&
163
+ password_setter.equal?(Searls::Auth::DEFAULT_CONFIG[:password_setter])
164
+
165
+ if using_default_hooks && defined?(::User)
166
+ missing = []
167
+ missing << "User#authenticate" unless ::User.method_defined?(:authenticate)
168
+ has_password_digest_method = ::User.method_defined?(:password_digest)
169
+ has_password_digest_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("password_digest")
170
+ missing << "users.password_digest" unless has_password_digest_method || has_password_digest_column
171
+ if missing.any?
172
+ raise Searls::Auth::Error, "Password login requires #{missing.join(" and ")}. Add bcrypt/has_secure_password or override password hooks."
173
+ end
174
+ end
175
+
176
+ ensure_callable!(:password_reset_token_generator)
177
+ ensure_callable!(:password_reset_token_finder)
178
+ ensure_callable!(:before_password_reset)
179
+ ensure_callable!(:password_present_predicate)
180
+ self.auto_login_after_password_reset = !!auto_login_after_password_reset
181
+ self.password_reset_enabled = true if password_reset_enabled.nil?
182
+ self.password_reset_enabled = !!password_reset_enabled
183
+ end
184
+
185
+ def validate_numeric_options!
186
+ NUMERIC_FIELDS.each do |key|
187
+ raw = public_send(key)
188
+ coerced = begin
189
+ Integer(raw)
190
+ rescue ArgumentError, TypeError
191
+ raise Searls::Auth::Error, "#{key} must be an integer"
192
+ end
193
+ if coerced < 1
194
+ raise Searls::Auth::Error, "#{key} must be >= 1"
195
+ end
196
+ public_send("#{key}=", coerced)
55
197
  end
56
198
  end
57
199
 
58
- def auth_methods
59
- Array(self[:auth_methods]).map(&:to_sym)
200
+ def validate_core_hooks!
201
+ HOOK_FIELDS.each { |key| ensure_callable!(key) }
202
+ end
203
+
204
+ def ensure_callable!(key)
205
+ value = public_send(key)
206
+ return if value.respond_to?(:call)
207
+
208
+ raise Searls::Auth::Error, "#{key} must be callable when password authentication is enabled"
209
+ end
210
+
211
+ def ensure_callable_optional!(key)
212
+ value = public_send(key)
213
+ return if value.nil? || value.respond_to?(:call)
214
+
215
+ raise Searls::Auth::Error, "#{key} must be callable when provided"
216
+ end
217
+
218
+ # If any hooks still reference the default `User` implementation, make
219
+ # sure a compatible `User` exists and exposes the fields/methods our
220
+ # defaults assume (id, email, token helpers, etc.).
221
+ def validate_default_user_hooks!
222
+ hooks_pointing_at_user = [
223
+ :user_finder_by_email,
224
+ :user_finder_by_id,
225
+ :user_finder_by_token,
226
+ :user_initializer,
227
+ :token_generator
228
+ ].select { |k| public_send(k).equal?(Searls::Auth::DEFAULT_CONFIG[k]) }
229
+
230
+ return if hooks_pointing_at_user.empty?
231
+
232
+ # Enforce these checks only when ActiveModel is present (e.g., Rails).
233
+ return unless defined?(::ActiveModel)
234
+
235
+ unless defined?(::User)
236
+ raise Searls::Auth::Error,
237
+ "Default hooks assume a `User` model. Define `User` (Active Record/Active Model) or override: #{hooks_pointing_at_user.inspect}"
238
+ end
239
+
240
+ # Proceed with concrete, per-hook capability checks.
241
+
242
+ # One-off validations for each default hook
243
+ if public_send(:user_finder_by_id).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_id])
244
+ unless ::User.respond_to?(:find_by)
245
+ raise Searls::Auth::Error, "Default :user_finder_by_id expects User.find_by(id: ...) to exist."
246
+ end
247
+ unless ::User.method_defined?(:id)
248
+ raise Searls::Auth::Error, "Default :user_finder_by_id expects a `User#id` attribute."
249
+ end
250
+ end
251
+
252
+ if public_send(:user_finder_by_email).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_email])
253
+ unless ::User.respond_to?(:find_by)
254
+ raise Searls::Auth::Error, "Default :user_finder_by_email expects User.find_by(email: ...) to exist."
255
+ end
256
+ has_email_method = ::User.method_defined?(:email)
257
+ has_email_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("email")
258
+ unless has_email_method || has_email_column
259
+ raise Searls::Auth::Error, "Default :user_finder_by_email expects a `users.email` attribute."
260
+ end
261
+ end
262
+
263
+ if public_send(:user_finder_by_token).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_token])
264
+ unless ::User.respond_to?(:find_by_token_for)
265
+ raise Searls::Auth::Error, "Default :user_finder_by_token expects User.find_by_token_for(:email_auth, token) (Rails signed_id API)."
266
+ end
267
+ end
268
+
269
+ if public_send(:user_initializer).equal?(Searls::Auth::DEFAULT_CONFIG[:user_initializer])
270
+ unless ::User.respond_to?(:new)
271
+ raise Searls::Auth::Error, "Default :user_initializer expects `User.new(email: ...)` to work."
272
+ end
273
+ begin
274
+ probe = ::User.new
275
+ has_email_setter = probe.respond_to?(:email=)
276
+ has_email_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("email")
277
+ unless has_email_setter || has_email_column
278
+ raise Searls::Auth::Error, "Default :user_initializer expects a writable email attribute on User."
279
+ end
280
+ rescue ArgumentError
281
+ # e.g., custom initialize signature
282
+ raise Searls::Auth::Error, "Default :user_initializer expects `User.new` with keyword args to be permissible."
283
+ end
284
+ end
285
+
286
+ if public_send(:token_generator).equal?(Searls::Auth::DEFAULT_CONFIG[:token_generator])
287
+ unless ::User.method_defined?(:generate_token_for)
288
+ raise Searls::Auth::Error, "Default :token_generator expects `user.generate_token_for(:email_auth)` (Rails signed_id API)."
289
+ end
290
+ end
60
291
  end
61
292
  end
62
293
  end
@@ -11,13 +11,21 @@ module Searls
11
11
  Searls::Auth.config.user_initializer.call(params)
12
12
 
13
13
  if user.persisted?
14
- Result.new(nil, false, ["An account already exists for that email address. <a href=\"#{login_path(**forwardable_params(params))}\">Log in</a> instead?".html_safe])
14
+ Result.new(nil, false, ["An account already exists for that email address. <a href=\"#{login_path(**forwardable_params(params))}\">Log in</a> instead?"])
15
15
  elsif (errors = Searls::Auth.config.validate_registration.call(user, params, [])).any?
16
16
  Result.new(nil, false, errors)
17
- elsif user.save
18
- Result.new(user, true)
19
17
  else
20
- Result.new(user, false, simplified_error_messages(user))
18
+ if params[:password].present?
19
+ Searls::Auth.config.password_setter.call(user, params[:password])
20
+ if params[:password_confirmation].present? && user.respond_to?(:password_confirmation=)
21
+ user.password_confirmation = params[:password_confirmation]
22
+ end
23
+ end
24
+ if user.save
25
+ Result.new(user, true)
26
+ else
27
+ Result.new(user, false, simplified_error_messages(user))
28
+ end
21
29
  end
22
30
  end
23
31
 
@@ -0,0 +1,18 @@
1
+ module Searls
2
+ module Auth
3
+ class DeliversPasswordReset
4
+ Result = Struct.new(:success?, keyword_init: true)
5
+
6
+ def deliver(user:, redirect_path: nil, redirect_subdomain: nil)
7
+ token = Searls::Auth.config.password_reset_token_generator.call(user)
8
+ PasswordResetMailer.with(
9
+ user:,
10
+ token:,
11
+ redirect_path:,
12
+ redirect_subdomain:
13
+ ).password_reset.deliver_later
14
+ Result.new(success?: true)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -1,11 +1,11 @@
1
1
  module Searls
2
2
  module Auth
3
3
  class EmailsLink
4
- def email(user:, short_code:, redirect_path: nil, redirect_subdomain: nil)
4
+ def email(user:, email_otp:, redirect_path: nil, redirect_subdomain: nil)
5
5
  LoginLinkMailer.with(
6
6
  user:,
7
7
  token: (Searls::Auth.config.auth_methods.include?(:email_link) ? generate_token!(user) : nil),
8
- short_code: (Searls::Auth.config.auth_methods.include?(:email_otp) ? short_code : nil),
8
+ email_otp: (Searls::Auth.config.auth_methods.include?(:email_otp) ? email_otp : nil),
9
9
  redirect_path:,
10
10
  redirect_subdomain:
11
11
  ).login_link.deliver_later
@@ -0,0 +1,33 @@
1
+ module Searls
2
+ module Auth
3
+ class EmailsVerification
4
+ def email(user:, redirect_path: nil, redirect_subdomain: nil)
5
+ EmailVerificationMailer.with(
6
+ user:,
7
+ token: generate_token!(user),
8
+ redirect_path:,
9
+ redirect_subdomain:
10
+ ).verification_email.deliver_later
11
+ end
12
+
13
+ private
14
+
15
+ def generate_token!(user)
16
+ Searls::Auth.config.token_generator.call(user)
17
+ rescue KeyError => e
18
+ raise Error, <<~MSG
19
+ Secure token generation for user failed!
20
+
21
+ Message: #{e.message}
22
+ User: #{user.inspect}
23
+
24
+ This can probably be fixed by adding a line like this to your #{user.class.name} class:
25
+
26
+ generates_token_for :email_auth, expires_in: 30.minutes
27
+
28
+ Otherwise, you may want to override searls-auth's "token_generator" setting with a proc of your own.
29
+ MSG
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ require "active_support/time"
2
+
3
+ module Searls
4
+ module Auth
5
+ class ParsesTimeSafely
6
+ def parse(input)
7
+ return nil if input.nil?
8
+
9
+ case input
10
+ when String
11
+ parse_string(input)
12
+ when Integer, Float
13
+ Time.at(input).in_time_zone
14
+ else
15
+ if input.respond_to?(:in_time_zone)
16
+ input.in_time_zone
17
+ else
18
+ parse_string(input.to_s)
19
+ end
20
+ end
21
+ rescue ArgumentError, TypeError, NoMethodError
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ def parse_string(s)
28
+ if !(stripped = s.strip).empty?
29
+ Time.zone.parse(stripped)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -13,7 +13,6 @@ module Searls
13
13
 
14
14
  # Initialize configuration defaults
15
15
  initializer "searls.auth.configure" do |app|
16
- # Set up any configuration defaults here
17
16
  end
18
17
  end
19
18
  end