searls-auth 0.1.1 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -1
  3. data/LICENSE.txt +1 -1
  4. data/README.md +185 -1
  5. data/app/controllers/searls/auth/base_controller.rb +42 -21
  6. data/app/controllers/searls/auth/email_verifications_controller.rb +57 -0
  7. data/app/controllers/searls/auth/logins_controller.rb +61 -35
  8. data/app/controllers/searls/auth/registrations_controller.rb +84 -32
  9. data/app/controllers/searls/auth/requests_password_resets_controller.rb +55 -0
  10. data/app/controllers/searls/auth/resets_passwords_controller.rb +73 -0
  11. data/app/controllers/searls/auth/settings_controller.rb +83 -0
  12. data/app/controllers/searls/auth/verifications_controller.rb +37 -53
  13. data/app/helpers/searls/auth/application_helper.rb +9 -5
  14. data/app/mailers/searls/auth/base_mailer.rb +1 -1
  15. data/app/mailers/searls/auth/email_verification_mailer.rb +29 -0
  16. data/app/mailers/searls/auth/login_link_mailer.rb +13 -2
  17. data/app/mailers/searls/auth/password_reset_mailer.rb +29 -0
  18. data/app/views/searls/auth/email_verification_mailer/verification_email.html.erb +23 -0
  19. data/app/views/searls/auth/email_verification_mailer/verification_email.text.erb +6 -0
  20. data/app/views/searls/auth/login_link_mailer/login_link.html.erb +29 -25
  21. data/app/views/searls/auth/login_link_mailer/login_link.text.erb +9 -5
  22. data/app/views/searls/auth/logins/show.html.erb +12 -4
  23. data/app/views/searls/auth/password_reset_mailer/password_reset.html.erb +23 -0
  24. data/app/views/searls/auth/password_reset_mailer/password_reset.text.erb +6 -0
  25. data/app/views/searls/auth/registrations/pending_email_verification.html.erb +12 -0
  26. data/app/views/searls/auth/registrations/show.html.erb +1 -2
  27. data/app/views/searls/auth/requests_password_resets/show.html.erb +17 -0
  28. data/app/views/searls/auth/resets_passwords/show.html.erb +26 -0
  29. data/app/views/searls/auth/settings/edit.html.erb +31 -0
  30. data/app/views/searls/auth/shared/_login_fields.html.erb +11 -0
  31. data/app/views/searls/auth/shared/_register_fields.html.erb +15 -0
  32. data/app/views/searls/auth/verifications/show.html.erb +20 -20
  33. data/config/routes.rb +11 -0
  34. data/lib/searls/auth/authenticates_user.rb +54 -10
  35. data/lib/searls/auth/builds_target_redirect_url.rb +72 -0
  36. data/lib/searls/auth/config.rb +246 -10
  37. data/lib/searls/auth/creates_user.rb +12 -4
  38. data/lib/searls/auth/delivers_password_reset.rb +18 -0
  39. data/lib/searls/auth/emails_link.rb +3 -3
  40. data/lib/searls/auth/emails_verification.rb +33 -0
  41. data/lib/searls/auth/parses_time_safely.rb +34 -0
  42. data/lib/searls/auth/railtie.rb +0 -1
  43. data/lib/searls/auth/resets_password.rb +41 -0
  44. data/lib/searls/auth/updates_settings.rb +149 -0
  45. data/lib/searls/auth/version.rb +1 -1
  46. data/lib/searls/auth.rb +63 -13
  47. data/script/setup +1 -6
  48. data/script/test +1 -1
  49. metadata +24 -2
@@ -3,8 +3,7 @@
3
3
  <%= form_with url: searls_auth.register_path, data: {controller: searls_auth_helper.login_stimulus_controller} do |f| %>
4
4
  <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
5
5
  <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
6
- <%= f.label :email %>
7
- <%= f.email_field :email, value: params[:email], required: true, data: searls_auth_helper.email_field_stimulus_data %>
6
+ <%= render "searls/auth/shared/register_fields", f: f %>
8
7
  <%= f.submit "Register" %>
9
8
  <% end %>
10
9
 
@@ -0,0 +1,17 @@
1
+ <h1>Forgot your password?</h1>
2
+
3
+ <%= form_with url: searls_auth.password_reset_request_path, method: :post, data: {controller: searls_auth_helper.login_stimulus_controller} do |f| %>
4
+ <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
5
+ <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
6
+ <div>
7
+ <%= f.label :email, "Email address" %>
8
+ <%= f.email_field :email, required: true, value: params[:email], autocomplete: "email" %>
9
+ </div>
10
+ <div>
11
+ <%= f.submit "Send reset instructions" %>
12
+ </div>
13
+ <% end %>
14
+
15
+ <p>
16
+ Remembered your password? <%= link_to "Log in", searls_auth_helper.login_path %> instead.
17
+ </p>
@@ -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
+
@@ -1,24 +1,24 @@
1
1
  <h1>Check your email!</h1>
2
+ <% parts = { email_link: "a link", email_otp: "a six-digit code" }.slice(*Searls::Auth.config.auth_methods).values %>
2
3
  <p>
3
- In the next few moments, you should receive an email that will provide you
4
- two ways to log in: a link and a six-digit code that you can enter below.
4
+ In the next few moments, you should receive an email with <%= parts.to_sentence %> to log in.
5
5
  </p>
6
- <%= form_with(url: searls_auth.verify_path, method: :post, data: {
7
- # Don't use turbo on cross-domain redirects
8
- turbo: searls_auth_helper.enable_turbo?
9
- }) do |f| %>
10
- <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
11
- <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
12
- <div data-controller="<%= searls_auth_helper.otp_stimulus_controller %>">
13
- <%= f.label :short_code, "Code" %>
14
- <%= f.text_field :short_code,
15
- maxlength: 6,
16
- inputmode: "numeric",
17
- pattern: "\\d{6}",
18
- autocomplete: "one-time-code",
19
- title: "six-digit code that was emailed to you",
20
- data: searls_auth_helper.otp_field_stimulus_data
21
- %>
22
- </div>
23
- <%= f.submit "Log in" %>
6
+
7
+ <% if Searls::Auth.config.auth_methods.include?(:email_otp) %>
8
+ <%= form_with(url: searls_auth.verify_path, method: :post, data: { turbo: searls_auth_helper.enable_turbo? }) do |f| %>
9
+ <%= f.hidden_field :redirect_path, value: params[:redirect_path] %>
10
+ <%= f.hidden_field :redirect_subdomain, value: params[:redirect_subdomain] %>
11
+ <div data-controller="<%= searls_auth_helper.otp_stimulus_controller %>">
12
+ <%= f.label :short_code, "Code" %>
13
+ <%= f.text_field :short_code,
14
+ maxlength: 6,
15
+ inputmode: "numeric",
16
+ pattern: "\\d{6}",
17
+ autocomplete: "one-time-code",
18
+ title: "six-digit code that was emailed to you",
19
+ data: searls_auth_helper.otp_field_stimulus_data
20
+ %>
21
+ </div>
22
+ <%= f.submit "Log in" %>
23
+ <% end %>
24
24
  <% end %>
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,6 +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(
22
+ :auth_methods, # array of symbols, e.g., [:email_link, :email_otp]
23
+ :email_verification_mode, # :none, :optional, :required
4
24
  # Data setup
5
25
  :user_finder_by_email, # proc(email)
6
26
  :user_finder_by_id, # proc(id)
@@ -8,28 +28,44 @@ module Searls
8
28
  :user_initializer, # proc(params)
9
29
  :user_name_method, # string
10
30
  :token_generator, # proc()
11
- :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)
12
40
  # Controller setup
13
41
  :preserve_session_keys_after_logout, # array of symbols
14
- :max_allowed_short_code_attempts, # integer
42
+ :max_allowed_email_otp_attempts, # integer (email OTP attempts)
43
+ :email_otp_expiry_minutes, # integer
15
44
  # View setup
16
45
  :layout, # string
17
46
  :login_view, # string
18
47
  :register_view, # string
19
48
  :verify_view, # string
49
+ :pending_email_verification_view, # string
50
+ :password_reset_request_view, # string
51
+ :password_reset_edit_view, # string
20
52
  :mail_layout, # string
21
53
  :mail_login_template_path, # string
22
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
23
59
  # Routing setup
24
60
  :redirect_path_after_register, # string or proc(user, params, request, routes), all new registrations redirect here
25
- :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
26
63
  # Hook setup
27
64
  :validate_registration, # proc(user, params, errors = []), must return an array of error messages where empty means valid
28
65
  :after_login_success, # proc(user)
29
66
  # Branding setup
30
67
  :app_name, # string
31
68
  :app_url, # string
32
- :support_email_address, # string
33
69
  :email_banner_image_path, # string
34
70
  :email_background_color, # string
35
71
  :email_button_color, # string
@@ -38,19 +74,219 @@ module Searls
38
74
  :flash_error_after_register_attempt, # string or proc(error_messages, login_path, params)
39
75
  :flash_notice_after_login_attempt, # string or proc(user, params)
40
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)
41
85
  :flash_notice_after_logout, # string or proc(params)
42
- :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)
43
91
  :flash_error_after_verify_attempt_exceeds_limit, # string or proc(params)
44
- :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)
45
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
46
98
  keyword_init: true
47
99
  ) do
48
100
  # Get values from values that might be procs
49
101
  def resolve(option, *args)
50
- if self[option].respond_to?(:call)
51
- self[option].call(*args)
52
- else
53
- 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)
197
+ end
198
+ end
199
+
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
54
290
  end
55
291
  end
56
292
  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