searls-auth 0.2.0 → 1.0.1

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 +16 -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 +259 -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 +32 -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,26 @@
1
+ <h1>Reset your password</h1>
2
+
3
+ <%= form_with url: searls_auth.password_reset_update_path, method: :patch do |f| %>
4
+ <%= f.hidden_field :token, value: @token %>
5
+ <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
6
+ <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
7
+ <div>
8
+ <%= label_tag :email, "Email address" %>
9
+ <%= email_field_tag :email, @user_email, disabled: true %>
10
+ </div>
11
+ <div>
12
+ <%= f.label :password, "New password" %>
13
+ <%= f.password_field :password, autocomplete: "new-password", required: true %>
14
+ </div>
15
+ <div>
16
+ <%= f.label :password_confirmation, "Confirm new password" %>
17
+ <%= f.password_field :password_confirmation, autocomplete: "new-password", required: true %>
18
+ </div>
19
+ <div>
20
+ <%= f.submit "Update password" %>
21
+ </div>
22
+ <% end %>
23
+
24
+ <p>
25
+ Remembered it? <%= link_to "Log in", searls_auth_helper.login_path %> instead.
26
+ </p>
@@ -0,0 +1,31 @@
1
+ <h1>Account Settings</h1>
2
+
3
+ <%= form_with scope: :settings, url: searls_auth.settings_path, method: :patch do |f| %>
4
+ <%= hidden_field_tag :redirect_path, params[:redirect_path] %>
5
+ <%= hidden_field_tag :redirect_subdomain, params[:redirect_subdomain] %>
6
+
7
+ <fieldset>
8
+ <legend>Password</legend>
9
+ <% if password_on_file? %>
10
+ <div>
11
+ <%= f.label :current_password, "Current password" %>
12
+ <%= f.password_field :current_password, autocomplete: "current-password", required: password_on_file? %>
13
+ </div>
14
+ <p class="hint">Enter your current password to make changes.</p>
15
+ <% end %>
16
+
17
+ <div>
18
+ <%= f.label :password, password_on_file? ? "New password" : "Password" %>
19
+ <%= f.password_field :password, autocomplete: "new-password", required: !password_on_file? %>
20
+ </div>
21
+
22
+ <div>
23
+ <%= f.label :password_confirmation, "Confirm password" %>
24
+ <%= f.password_field :password_confirmation, autocomplete: "new-password", required: !password_on_file? %>
25
+ </div>
26
+ </fieldset>
27
+
28
+ <div>
29
+ <%= f.submit "Save changes" %>
30
+ </div>
31
+ <% end %>
@@ -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,236 @@ 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
+ unless schema_checks_blocked?
169
+ has_password_digest_method = ::User.method_defined?(:password_digest)
170
+ has_password_digest_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("password_digest")
171
+ missing << "users.password_digest" unless has_password_digest_method || has_password_digest_column
172
+ end
173
+
174
+ if missing.any?
175
+ raise Searls::Auth::Error, "Password login requires #{missing.join(" and ")}. Add bcrypt/has_secure_password or override password hooks."
176
+ end
177
+ end
178
+
179
+ ensure_callable!(:password_reset_token_generator)
180
+ ensure_callable!(:password_reset_token_finder)
181
+ ensure_callable!(:before_password_reset)
182
+ ensure_callable!(:password_present_predicate)
183
+ self.auto_login_after_password_reset = !!auto_login_after_password_reset
184
+ self.password_reset_enabled = true if password_reset_enabled.nil?
185
+ self.password_reset_enabled = !!password_reset_enabled
186
+ end
187
+
188
+ def validate_numeric_options!
189
+ NUMERIC_FIELDS.each do |key|
190
+ raw = public_send(key)
191
+ coerced = begin
192
+ Integer(raw)
193
+ rescue ArgumentError, TypeError
194
+ raise Searls::Auth::Error, "#{key} must be an integer"
195
+ end
196
+ if coerced < 1
197
+ raise Searls::Auth::Error, "#{key} must be >= 1"
198
+ end
199
+ public_send("#{key}=", coerced)
200
+ end
201
+ end
202
+
203
+ def validate_core_hooks!
204
+ HOOK_FIELDS.each { |key| ensure_callable!(key) }
205
+ end
206
+
207
+ def ensure_callable!(key)
208
+ value = public_send(key)
209
+ return if value.respond_to?(:call)
210
+
211
+ raise Searls::Auth::Error, "#{key} must be callable when password authentication is enabled"
212
+ end
213
+
214
+ def ensure_callable_optional!(key)
215
+ value = public_send(key)
216
+ return if value.nil? || value.respond_to?(:call)
217
+
218
+ raise Searls::Auth::Error, "#{key} must be callable when provided"
219
+ end
220
+
221
+ # If any hooks still reference the default `User` implementation, make
222
+ # sure a compatible `User` exists and exposes the fields/methods our
223
+ # defaults assume (id, email, token helpers, etc.).
224
+ def validate_default_user_hooks!
225
+ hooks_pointing_at_user = [
226
+ :user_finder_by_email,
227
+ :user_finder_by_id,
228
+ :user_finder_by_token,
229
+ :user_initializer,
230
+ :token_generator
231
+ ].select { |k| public_send(k).equal?(Searls::Auth::DEFAULT_CONFIG[k]) }
232
+
233
+ return if hooks_pointing_at_user.empty?
234
+
235
+ # Enforce these checks only when ActiveModel is present (e.g., Rails).
236
+ return unless defined?(::ActiveModel)
237
+
238
+ unless defined?(::User)
239
+ raise Searls::Auth::Error,
240
+ "Default hooks assume a `User` model. Define `User` (Active Record/Active Model) or override: #{hooks_pointing_at_user.inspect}"
241
+ end
242
+
243
+ # Proceed with concrete, per-hook capability checks.
244
+
245
+ # One-off validations for each default hook
246
+ if public_send(:user_finder_by_id).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_id])
247
+ unless ::User.respond_to?(:find_by)
248
+ raise Searls::Auth::Error, "Default :user_finder_by_id expects User.find_by(id: ...) to exist."
249
+ end
250
+ unless ::User.method_defined?(:id)
251
+ raise Searls::Auth::Error, "Default :user_finder_by_id expects a `User#id` attribute."
252
+ end
253
+ end
254
+
255
+ if public_send(:user_finder_by_email).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_email])
256
+ unless ::User.respond_to?(:find_by)
257
+ raise Searls::Auth::Error, "Default :user_finder_by_email expects User.find_by(email: ...) to exist."
258
+ end
259
+ has_email_method = ::User.method_defined?(:email)
260
+ has_email_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("email")
261
+ unless schema_checks_blocked? || has_email_method || has_email_column
262
+ raise Searls::Auth::Error, "Default :user_finder_by_email expects a `users.email` attribute."
263
+ end
264
+ end
265
+
266
+ if public_send(:user_finder_by_token).equal?(Searls::Auth::DEFAULT_CONFIG[:user_finder_by_token])
267
+ unless ::User.respond_to?(:find_by_token_for)
268
+ raise Searls::Auth::Error, "Default :user_finder_by_token expects User.find_by_token_for(:email_auth, token) (Rails signed_id API)."
269
+ end
270
+ end
271
+
272
+ if public_send(:user_initializer).equal?(Searls::Auth::DEFAULT_CONFIG[:user_initializer])
273
+ unless ::User.respond_to?(:new)
274
+ raise Searls::Auth::Error, "Default :user_initializer expects `User.new(email: ...)` to work."
275
+ end
276
+ begin
277
+ probe = ::User.new
278
+ has_email_setter = probe.respond_to?(:email=)
279
+ has_email_column = ::User.respond_to?(:column_names) && ::User.column_names.include?("email")
280
+ unless schema_checks_blocked? || has_email_setter || has_email_column
281
+ raise Searls::Auth::Error, "Default :user_initializer expects a writable email attribute on User."
282
+ end
283
+ rescue ArgumentError
284
+ # e.g., custom initialize signature
285
+ raise Searls::Auth::Error, "Default :user_initializer expects `User.new` with keyword args to be permissible."
286
+ end
287
+ end
288
+
289
+ if public_send(:token_generator).equal?(Searls::Auth::DEFAULT_CONFIG[:token_generator])
290
+ unless ::User.method_defined?(:generate_token_for)
291
+ raise Searls::Auth::Error, "Default :token_generator expects `user.generate_token_for(:email_auth)` (Rails signed_id API)."
292
+ end
55
293
  end
56
294
  end
57
295
 
58
- def auth_methods
59
- Array(self[:auth_methods]).map(&:to_sym)
296
+ def schema_checks_blocked?
297
+ return true unless defined?(::ActiveRecord)
298
+ begin
299
+ if !::ActiveRecord::Base.connected? ||
300
+ !::ActiveRecord::Base.connection&.migration_context.present? ||
301
+ ::ActiveRecord::Base.connection.migration_context.needs_migration?
302
+ true
303
+ end
304
+ rescue
305
+ true
306
+ end
60
307
  end
61
308
  end
62
309
  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