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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +162 -0
- data/app/controllers/searls/auth/base_controller.rb +42 -21
- data/app/controllers/searls/auth/email_verifications_controller.rb +57 -0
- data/app/controllers/searls/auth/logins_controller.rb +60 -39
- data/app/controllers/searls/auth/registrations_controller.rb +84 -32
- data/app/controllers/searls/auth/requests_password_resets_controller.rb +55 -0
- data/app/controllers/searls/auth/resets_passwords_controller.rb +73 -0
- data/app/controllers/searls/auth/settings_controller.rb +83 -0
- data/app/controllers/searls/auth/verifications_controller.rb +31 -61
- data/app/helpers/searls/auth/application_helper.rb +9 -5
- data/app/mailers/searls/auth/base_mailer.rb +1 -1
- data/app/mailers/searls/auth/email_verification_mailer.rb +29 -0
- data/app/mailers/searls/auth/login_link_mailer.rb +3 -3
- data/app/mailers/searls/auth/password_reset_mailer.rb +29 -0
- data/app/views/searls/auth/email_verification_mailer/verification_email.html.erb +23 -0
- data/app/views/searls/auth/email_verification_mailer/verification_email.text.erb +6 -0
- data/app/views/searls/auth/login_link_mailer/login_link.html.erb +5 -5
- data/app/views/searls/auth/login_link_mailer/login_link.text.erb +4 -5
- data/app/views/searls/auth/logins/show.html.erb +12 -4
- data/app/views/searls/auth/password_reset_mailer/password_reset.html.erb +23 -0
- data/app/views/searls/auth/password_reset_mailer/password_reset.text.erb +6 -0
- data/app/views/searls/auth/registrations/pending_email_verification.html.erb +12 -0
- data/app/views/searls/auth/registrations/show.html.erb +1 -2
- data/app/views/searls/auth/requests_password_resets/show.html.erb +17 -0
- data/app/views/searls/auth/resets_passwords/show.html.erb +26 -0
- data/app/views/searls/auth/settings/edit.html.erb +31 -0
- data/app/views/searls/auth/shared/_login_fields.html.erb +11 -0
- data/app/views/searls/auth/shared/_register_fields.html.erb +15 -0
- data/config/routes.rb +11 -0
- data/lib/searls/auth/authenticates_user.rb +54 -10
- data/lib/searls/auth/builds_target_redirect_url.rb +72 -0
- data/lib/searls/auth/config.rb +259 -12
- data/lib/searls/auth/creates_user.rb +12 -4
- data/lib/searls/auth/delivers_password_reset.rb +18 -0
- data/lib/searls/auth/emails_link.rb +2 -2
- data/lib/searls/auth/emails_verification.rb +33 -0
- data/lib/searls/auth/parses_time_safely.rb +32 -0
- data/lib/searls/auth/railtie.rb +0 -1
- data/lib/searls/auth/resets_password.rb +41 -0
- data/lib/searls/auth/updates_settings.rb +149 -0
- data/lib/searls/auth/version.rb +1 -1
- data/lib/searls/auth.rb +62 -13
- 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
|
-
|
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
|
7
|
-
if session[:
|
8
|
-
return Result.new(success?: false,
|
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
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
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
|
data/lib/searls/auth/config.rb
CHANGED
@@ -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
|
-
:
|
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
|
-
:
|
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
|
-
:
|
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
|
-
:
|
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
|
-
:
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
59
|
-
|
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?"
|
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
|
-
|
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:,
|
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
|
-
|
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
|