rsb-auth 0.9.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 +7 -0
- data/LICENSE +15 -0
- data/README.md +76 -0
- data/Rakefile +25 -0
- data/app/controllers/concerns/rsb/auth/ensure_identity_complete.rb +72 -0
- data/app/controllers/concerns/rsb/auth/rate_limitable.rb +20 -0
- data/app/controllers/rsb/auth/account/login_methods_controller.rb +85 -0
- data/app/controllers/rsb/auth/account/sessions_controller.rb +31 -0
- data/app/controllers/rsb/auth/account_controller.rb +99 -0
- data/app/controllers/rsb/auth/admin/identities_controller.rb +402 -0
- data/app/controllers/rsb/auth/admin/sessions_management_controller.rb +27 -0
- data/app/controllers/rsb/auth/application_controller.rb +50 -0
- data/app/controllers/rsb/auth/invitations_controller.rb +31 -0
- data/app/controllers/rsb/auth/password_resets_controller.rb +46 -0
- data/app/controllers/rsb/auth/registrations_controller.rb +104 -0
- data/app/controllers/rsb/auth/sessions_controller.rb +109 -0
- data/app/controllers/rsb/auth/verifications_controller.rb +29 -0
- data/app/helpers/rsb/auth/user_agent_helper.rb +22 -0
- data/app/mailers/rsb/auth/application_mailer.rb +10 -0
- data/app/mailers/rsb/auth/auth_mailer.rb +33 -0
- data/app/models/rsb/auth/application_record.rb +10 -0
- data/app/models/rsb/auth/credential/email_password.rb +9 -0
- data/app/models/rsb/auth/credential/phone_password.rb +16 -0
- data/app/models/rsb/auth/credential/username_password.rb +9 -0
- data/app/models/rsb/auth/credential.rb +122 -0
- data/app/models/rsb/auth/identity.rb +62 -0
- data/app/models/rsb/auth/invitation.rb +55 -0
- data/app/models/rsb/auth/password_reset_token.rb +36 -0
- data/app/models/rsb/auth/session.rb +44 -0
- data/app/services/rsb/auth/account_service.rb +140 -0
- data/app/services/rsb/auth/authentication_service.rb +86 -0
- data/app/services/rsb/auth/invitation_service.rb +53 -0
- data/app/services/rsb/auth/password_reset_service.rb +48 -0
- data/app/services/rsb/auth/registration_service.rb +108 -0
- data/app/services/rsb/auth/session_service.rb +47 -0
- data/app/services/rsb/auth/verification_service.rb +30 -0
- data/app/views/layouts/rsb/auth/application.html.erb +76 -0
- data/app/views/rsb/auth/account/_identity_fields.html.erb +3 -0
- data/app/views/rsb/auth/account/confirm_destroy.html.erb +45 -0
- data/app/views/rsb/auth/account/login_methods/show.html.erb +92 -0
- data/app/views/rsb/auth/account/show.html.erb +110 -0
- data/app/views/rsb/auth/admin/credentials/_email_password.html.erb +34 -0
- data/app/views/rsb/auth/admin/credentials/_phone_password.html.erb +34 -0
- data/app/views/rsb/auth/admin/credentials/_username_password.html.erb +34 -0
- data/app/views/rsb/auth/admin/identities/index.html.erb +76 -0
- data/app/views/rsb/auth/admin/identities/new.html.erb +46 -0
- data/app/views/rsb/auth/admin/identities/new_credential.html.erb +45 -0
- data/app/views/rsb/auth/admin/identities/show.html.erb +180 -0
- data/app/views/rsb/auth/admin/sessions_management/index.html.erb +69 -0
- data/app/views/rsb/auth/auth_mailer/invitation.html.erb +4 -0
- data/app/views/rsb/auth/auth_mailer/password_reset.html.erb +4 -0
- data/app/views/rsb/auth/auth_mailer/verification.html.erb +4 -0
- data/app/views/rsb/auth/credentials/_email_password_login.html.erb +36 -0
- data/app/views/rsb/auth/credentials/_email_password_signup.html.erb +45 -0
- data/app/views/rsb/auth/credentials/_icon.html.erb +21 -0
- data/app/views/rsb/auth/credentials/_phone_password_login.html.erb +33 -0
- data/app/views/rsb/auth/credentials/_phone_password_signup.html.erb +45 -0
- data/app/views/rsb/auth/credentials/_selector.html.erb +43 -0
- data/app/views/rsb/auth/credentials/_username_password_login.html.erb +33 -0
- data/app/views/rsb/auth/credentials/_username_password_signup.html.erb +54 -0
- data/app/views/rsb/auth/invitations/show.html.erb +40 -0
- data/app/views/rsb/auth/password_resets/edit.html.erb +41 -0
- data/app/views/rsb/auth/password_resets/new.html.erb +27 -0
- data/app/views/rsb/auth/registrations/new.html.erb +55 -0
- data/app/views/rsb/auth/sessions/new.html.erb +47 -0
- data/config/locales/account.en.yml +65 -0
- data/config/locales/admin.en.yml +26 -0
- data/config/locales/credentials.en.yml +11 -0
- data/config/locales/seo.en.yml +28 -0
- data/config/routes.rb +34 -0
- data/db/migrate/20260208100001_create_rsb_auth_identities.rb +12 -0
- data/db/migrate/20260208100002_create_rsb_auth_credentials.rb +20 -0
- data/db/migrate/20260208100003_create_rsb_auth_sessions.rb +18 -0
- data/db/migrate/20260208100004_create_rsb_auth_password_reset_tokens.rb +15 -0
- data/db/migrate/20260208100005_add_verification_to_rsb_auth_credentials.rb +9 -0
- data/db/migrate/20260208100006_create_rsb_auth_invitations.rb +19 -0
- data/db/migrate/20260211100001_add_revoked_at_to_rsb_auth_credentials.rb +16 -0
- data/db/migrate/20260212100001_add_deleted_at_to_rsb_auth_identities.rb +10 -0
- data/db/migrate/20260214172956_add_recovery_email_to_rsb_auth_credentials.rb +8 -0
- data/lib/generators/rsb/auth/install/install_generator.rb +31 -0
- data/lib/generators/rsb/auth/views/views_generator.rb +197 -0
- data/lib/rsb/auth/configuration.rb +59 -0
- data/lib/rsb/auth/credential_conflict_error.rb +21 -0
- data/lib/rsb/auth/credential_definition.rb +39 -0
- data/lib/rsb/auth/credential_deprecation_bridge.rb +81 -0
- data/lib/rsb/auth/credential_registry.rb +96 -0
- data/lib/rsb/auth/credential_settings_registrar.rb +118 -0
- data/lib/rsb/auth/engine.rb +187 -0
- data/lib/rsb/auth/lifecycle_handler.rb +71 -0
- data/lib/rsb/auth/settings_schema.rb +74 -0
- data/lib/rsb/auth/test_helper.rb +139 -0
- data/lib/rsb/auth/version.rb +9 -0
- data/lib/rsb/auth.rb +49 -0
- metadata +192 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
# Generator to export RSB Auth views to the host application for customization.
|
|
6
|
+
#
|
|
7
|
+
# Copies user-facing view files from the RSB Auth engine to the host app's
|
|
8
|
+
# `app/views/rsb/auth/` directory. Rails engine view lookup gives host app
|
|
9
|
+
# views priority over engine defaults, so copied views automatically override
|
|
10
|
+
# engine defaults without additional configuration.
|
|
11
|
+
#
|
|
12
|
+
# Admin views (identity management, sessions management) are NOT included —
|
|
13
|
+
# those are managed by the rsb-admin view generator.
|
|
14
|
+
#
|
|
15
|
+
# @example Export all auth views
|
|
16
|
+
# rails generate rsb:auth:views
|
|
17
|
+
#
|
|
18
|
+
# @example Export only login and registration views
|
|
19
|
+
# rails generate rsb:auth:views --only sessions,registrations
|
|
20
|
+
#
|
|
21
|
+
# @example Export mailer templates
|
|
22
|
+
# rails generate rsb:auth:views --only mailers
|
|
23
|
+
#
|
|
24
|
+
# @example Force overwrite existing files
|
|
25
|
+
# rails generate rsb:auth:views --force
|
|
26
|
+
#
|
|
27
|
+
class ViewsGenerator < Rails::Generators::Base
|
|
28
|
+
namespace 'rsb:auth:views'
|
|
29
|
+
desc 'Export RSB Auth views to your application for customization.'
|
|
30
|
+
|
|
31
|
+
class_option :only, type: :string, default: nil,
|
|
32
|
+
desc: 'Comma-separated list of view groups to export (sessions,registrations,account,verifications,password_resets,invitations,mailers,layout)'
|
|
33
|
+
class_option :force, type: :boolean, default: false,
|
|
34
|
+
desc: 'Overwrite existing files'
|
|
35
|
+
|
|
36
|
+
# Mapping of view groups to their file paths (relative to the engine views root).
|
|
37
|
+
# Each group corresponds to a controller's view directory.
|
|
38
|
+
# Admin views are intentionally excluded — use rsb:admin:views for those.
|
|
39
|
+
VIEW_GROUPS = {
|
|
40
|
+
'sessions' => [
|
|
41
|
+
'sessions/new.html.erb'
|
|
42
|
+
],
|
|
43
|
+
'registrations' => [
|
|
44
|
+
'registrations/new.html.erb'
|
|
45
|
+
],
|
|
46
|
+
'account' => [
|
|
47
|
+
'account/show.html.erb',
|
|
48
|
+
'account/confirm_destroy.html.erb',
|
|
49
|
+
'account/_identity_fields.html.erb'
|
|
50
|
+
],
|
|
51
|
+
'verifications' => [
|
|
52
|
+
'verifications/show.html.erb'
|
|
53
|
+
],
|
|
54
|
+
'password_resets' => [
|
|
55
|
+
'password_resets/new.html.erb',
|
|
56
|
+
'password_resets/edit.html.erb'
|
|
57
|
+
],
|
|
58
|
+
'invitations' => [
|
|
59
|
+
'invitations/show.html.erb'
|
|
60
|
+
],
|
|
61
|
+
'mailers' => [
|
|
62
|
+
'auth_mailer/verification.html.erb',
|
|
63
|
+
'auth_mailer/password_reset.html.erb',
|
|
64
|
+
'auth_mailer/invitation.html.erb'
|
|
65
|
+
],
|
|
66
|
+
'layout' => [
|
|
67
|
+
'layouts/rsb/auth/application.html.erb'
|
|
68
|
+
]
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
# Copies the selected view files from the engine to the host application.
|
|
72
|
+
#
|
|
73
|
+
# Iterates through the selected view groups and copies each file.
|
|
74
|
+
# Existing files are skipped unless --force is used.
|
|
75
|
+
#
|
|
76
|
+
# @return [void]
|
|
77
|
+
def copy_views
|
|
78
|
+
groups = selected_groups
|
|
79
|
+
|
|
80
|
+
files_copied = 0
|
|
81
|
+
files_skipped = 0
|
|
82
|
+
|
|
83
|
+
groups.each_value do |paths|
|
|
84
|
+
paths.each do |relative_path|
|
|
85
|
+
source = source_path_for(relative_path)
|
|
86
|
+
destination = destination_path_for(relative_path)
|
|
87
|
+
|
|
88
|
+
unless File.exist?(source)
|
|
89
|
+
say_status :skip, "#{relative_path} (not found in engine)", :yellow
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if File.exist?(destination) && !options[:force]
|
|
94
|
+
say_status :skip, "#{relative_path} (already exists, use --force to overwrite)", :yellow
|
|
95
|
+
files_skipped += 1
|
|
96
|
+
else
|
|
97
|
+
copy_file_with_status(source, destination)
|
|
98
|
+
files_copied += 1
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
say ''
|
|
104
|
+
say "Exported #{files_copied} view(s) to app/views/rsb/auth/", :green
|
|
105
|
+
say "Skipped #{files_skipped} existing file(s)." if files_skipped.positive?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Prints instructions about the view override chain.
|
|
109
|
+
#
|
|
110
|
+
# @return [void]
|
|
111
|
+
def print_instructions
|
|
112
|
+
say ''
|
|
113
|
+
say 'View override chain (highest priority first):', :cyan
|
|
114
|
+
say ' 1. app/views/rsb/auth/ (your customizations)'
|
|
115
|
+
say ' 2. RSB Auth engine defaults (fallback)'
|
|
116
|
+
say ''
|
|
117
|
+
say 'Edit the exported files to customize your auth pages.'
|
|
118
|
+
say "Files you didn't export will continue using engine defaults."
|
|
119
|
+
say ''
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Returns the filtered set of view groups based on the --only option.
|
|
125
|
+
#
|
|
126
|
+
# When --only is provided, parses the comma-separated list and validates
|
|
127
|
+
# that all group names are recognized. Returns only the matching groups.
|
|
128
|
+
# When --only is not provided, returns all groups.
|
|
129
|
+
#
|
|
130
|
+
# @return [Hash{String => Array<String>}] filtered view groups
|
|
131
|
+
# @raise [SystemExit] if invalid group names are provided
|
|
132
|
+
def selected_groups
|
|
133
|
+
if options[:only]
|
|
134
|
+
keys = options[:only].split(',').map(&:strip)
|
|
135
|
+
invalid = keys - VIEW_GROUPS.keys
|
|
136
|
+
if invalid.any?
|
|
137
|
+
say_status :error,
|
|
138
|
+
"Unknown view group(s): #{invalid.join(', ')}. Valid groups: #{VIEW_GROUPS.keys.join(', ')}", :red
|
|
139
|
+
exit 1
|
|
140
|
+
end
|
|
141
|
+
VIEW_GROUPS.slice(*keys)
|
|
142
|
+
else
|
|
143
|
+
VIEW_GROUPS
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Returns the full source path for a given relative view path.
|
|
148
|
+
#
|
|
149
|
+
# Layout files live under app/views/layouts/ (not namespaced),
|
|
150
|
+
# while all other views live under app/views/rsb/auth/.
|
|
151
|
+
#
|
|
152
|
+
# @param relative_path [String] the relative path from VIEW_GROUPS
|
|
153
|
+
# @return [String] absolute path to the source file in the engine
|
|
154
|
+
def source_path_for(relative_path)
|
|
155
|
+
if relative_path.start_with?('layouts/')
|
|
156
|
+
RSB::Auth::Engine.root.join('app', 'views', relative_path).to_s
|
|
157
|
+
else
|
|
158
|
+
File.join(engine_views_root, relative_path)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Returns the full destination path for a given relative view path.
|
|
163
|
+
#
|
|
164
|
+
# Layout files go to app/views/layouts/ (not namespaced),
|
|
165
|
+
# while all other views go to app/views/rsb/auth/.
|
|
166
|
+
#
|
|
167
|
+
# @param relative_path [String] the relative path from VIEW_GROUPS
|
|
168
|
+
# @return [String] absolute path to the destination file in the host app
|
|
169
|
+
def destination_path_for(relative_path)
|
|
170
|
+
if relative_path.start_with?('layouts/')
|
|
171
|
+
File.join(destination_root, 'app', 'views', relative_path)
|
|
172
|
+
else
|
|
173
|
+
File.join(destination_root, 'app', 'views', 'rsb', 'auth', relative_path)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns the engine's namespaced views root directory.
|
|
178
|
+
#
|
|
179
|
+
# @return [String] absolute path to rsb/auth views in the engine
|
|
180
|
+
def engine_views_root
|
|
181
|
+
RSB::Auth::Engine.root.join('app', 'views', 'rsb', 'auth').to_s
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Copies a file and prints a create status message.
|
|
185
|
+
#
|
|
186
|
+
# @param source [String] absolute path to source file
|
|
187
|
+
# @param destination [String] absolute path to destination file
|
|
188
|
+
# @return [void]
|
|
189
|
+
def copy_file_with_status(source, destination)
|
|
190
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
191
|
+
FileUtils.cp(source, destination)
|
|
192
|
+
relative = destination.sub("#{destination_root}/", '')
|
|
193
|
+
say_status :create, relative, :green
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
# Configuration for rsb-auth lifecycle handler and model concerns.
|
|
6
|
+
#
|
|
7
|
+
# @example Configure a custom handler and identity concerns
|
|
8
|
+
# RSB::Auth.configure do |config|
|
|
9
|
+
# config.lifecycle_handler = "MyApp::AuthLifecycleHandler"
|
|
10
|
+
# config.identity_concerns << MyApp::HasProfile
|
|
11
|
+
# config.credential_concerns << MyApp::HasTenant
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
class Configuration
|
|
15
|
+
# @return [String, nil] fully-qualified class name of the lifecycle handler.
|
|
16
|
+
# When nil, the base {LifecycleHandler} null object is used.
|
|
17
|
+
attr_accessor :lifecycle_handler
|
|
18
|
+
|
|
19
|
+
# @return [Array<Module>] concern modules to include into Identity model.
|
|
20
|
+
# Applied in order during Rails +to_prepare+ block.
|
|
21
|
+
attr_reader :identity_concerns
|
|
22
|
+
|
|
23
|
+
# @return [Array<Module>] concern modules to include into Credential base model.
|
|
24
|
+
# Applied in order during Rails +to_prepare+ block. Inherited by all STI subtypes.
|
|
25
|
+
attr_reader :credential_concerns
|
|
26
|
+
|
|
27
|
+
# @return [Array<Symbol, Hash>] parameters permitted in account update form.
|
|
28
|
+
# Default: +[:metadata]+. Host app extends this for concern-added nested attributes.
|
|
29
|
+
#
|
|
30
|
+
# @example Permit nested profile attributes
|
|
31
|
+
# config.permitted_account_params = [:metadata, profile_attributes: [:first_name, :last_name]]
|
|
32
|
+
attr_accessor :permitted_account_params
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@lifecycle_handler = nil
|
|
36
|
+
@identity_concerns = []
|
|
37
|
+
@credential_concerns = []
|
|
38
|
+
@permitted_account_params = [:metadata]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolves and instantiates the lifecycle handler.
|
|
42
|
+
#
|
|
43
|
+
# If {#lifecycle_handler} is set, constantizes the string and returns a new
|
|
44
|
+
# instance. If nil, returns a base {LifecycleHandler} (null object).
|
|
45
|
+
#
|
|
46
|
+
# The class name is resolved via +constantize+ on every call (not cached),
|
|
47
|
+
# which supports Rails code reloading in development.
|
|
48
|
+
#
|
|
49
|
+
# @return [RSB::Auth::LifecycleHandler] handler instance
|
|
50
|
+
def resolve_lifecycle_handler
|
|
51
|
+
if lifecycle_handler
|
|
52
|
+
lifecycle_handler.constantize.new
|
|
53
|
+
else
|
|
54
|
+
LifecycleHandler.new
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
# Raised when restoring a revoked credential would violate the active
|
|
6
|
+
# uniqueness constraint (another active credential has the same type + identifier).
|
|
7
|
+
#
|
|
8
|
+
# @example Handling the error
|
|
9
|
+
# begin
|
|
10
|
+
# credential.restore!
|
|
11
|
+
# rescue RSB::Auth::CredentialConflictError => e
|
|
12
|
+
# e.message # => "Cannot restore — another active credential with the same identifier exists."
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
class CredentialConflictError < StandardError
|
|
16
|
+
def initialize(msg = 'Cannot restore — another active credential with the same identifier exists.')
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
class CredentialDefinition
|
|
6
|
+
attr_reader :key, :class_name, :authenticatable, :registerable, :label,
|
|
7
|
+
:icon, :form_partial, :redirect_url, :admin_form_partial
|
|
8
|
+
|
|
9
|
+
# @param key [Symbol, String] unique identifier for the credential type
|
|
10
|
+
# @param class_name [String] fully-qualified class name of the credential model
|
|
11
|
+
# @param authenticatable [Boolean] whether this credential can be used to sign in (default: true)
|
|
12
|
+
# @param registerable [Boolean] whether new users can register with this credential (default: true)
|
|
13
|
+
# @param label [String, nil] human-readable label (defaults to titleized key)
|
|
14
|
+
# @param icon [String, nil] icon name for UI display (e.g. "mail", "phone", "user")
|
|
15
|
+
# @param form_partial [String, nil] Rails partial path for the credential's login/register form
|
|
16
|
+
# @param redirect_url [String, nil] redirect URL for OAuth/redirect-based credential flows
|
|
17
|
+
def initialize(key:, class_name:, authenticatable: true, registerable: true, label: nil,
|
|
18
|
+
icon: nil, form_partial: nil, redirect_url: nil, admin_form_partial: nil)
|
|
19
|
+
@key = key.to_sym
|
|
20
|
+
@class_name = class_name.to_s
|
|
21
|
+
@authenticatable = authenticatable
|
|
22
|
+
@registerable = registerable
|
|
23
|
+
@label = label || key.to_s.titleize
|
|
24
|
+
@icon = icon
|
|
25
|
+
@form_partial = form_partial
|
|
26
|
+
@redirect_url = redirect_url
|
|
27
|
+
@admin_form_partial = admin_form_partial
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def credential_class
|
|
31
|
+
@class_name.constantize
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def valid?
|
|
35
|
+
key.present? && class_name.present?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
# Provides backward compatibility between the old `auth.login_identifier`
|
|
6
|
+
# setting and the new per-credential `auth.credentials.<key>.enabled` settings.
|
|
7
|
+
#
|
|
8
|
+
# When per-credential settings are NOT explicitly set in the database,
|
|
9
|
+
# this bridge reads `auth.login_identifier` and infers which types should
|
|
10
|
+
# be enabled. When per-credential settings ARE explicitly set, this bridge
|
|
11
|
+
# is bypassed entirely — the explicit settings win.
|
|
12
|
+
#
|
|
13
|
+
# @see SRS-004 US-006 (Deprecate login_identifier)
|
|
14
|
+
class CredentialDeprecationBridge
|
|
15
|
+
IDENTIFIER_MAP = {
|
|
16
|
+
'email' => { email_password: true, phone_password: false, username_password: false },
|
|
17
|
+
'phone' => { email_password: false, phone_password: true, username_password: false },
|
|
18
|
+
'username' => { email_password: false, phone_password: false, username_password: true }
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Returns the enabled/disabled map for a given login_identifier value.
|
|
23
|
+
#
|
|
24
|
+
# @param identifier [String] "email", "phone", or "username"
|
|
25
|
+
# @return [Hash<Symbol, Boolean>]
|
|
26
|
+
def enabled_map_for(identifier)
|
|
27
|
+
IDENTIFIER_MAP.fetch(identifier.to_s, IDENTIFIER_MAP['email'])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Checks whether any per-credential enabled setting has been explicitly
|
|
31
|
+
# set in the database (not just relying on the default).
|
|
32
|
+
#
|
|
33
|
+
# @return [Boolean]
|
|
34
|
+
def per_credential_settings_explicit?
|
|
35
|
+
RSB::Auth.credentials.all.any? do |defn|
|
|
36
|
+
RSB::Settings::Setting.exists?(
|
|
37
|
+
category: 'auth',
|
|
38
|
+
key: "credentials.#{defn.key}.enabled"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
rescue StandardError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resolves the deprecated login_identifier into per-credential settings.
|
|
46
|
+
# Only called when per-credential settings are NOT explicitly set.
|
|
47
|
+
# Logs a deprecation warning.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
def resolve_from_login_identifier
|
|
51
|
+
identifier = RSB::Settings.get('auth.login_identifier')
|
|
52
|
+
fire_deprecation(
|
|
53
|
+
'auth.login_identifier is deprecated. Use auth.credentials.<key>.enabled settings instead. ' \
|
|
54
|
+
"Current value '#{identifier}' maps to: #{enabled_map_for(identifier).inspect}"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Register a deprecation handler (for testing).
|
|
59
|
+
# @param block [Proc]
|
|
60
|
+
def on_deprecation(&block)
|
|
61
|
+
@deprecation_handler = block
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Clear the deprecation handler (for test teardown).
|
|
65
|
+
def clear_deprecation_handler
|
|
66
|
+
@deprecation_handler = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def fire_deprecation(message)
|
|
72
|
+
if @deprecation_handler
|
|
73
|
+
@deprecation_handler.call(message)
|
|
74
|
+
elsif defined?(Rails) && Rails.logger
|
|
75
|
+
Rails.logger.warn("[RSB::Auth DEPRECATION] #{message}")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
class CredentialRegistry
|
|
6
|
+
attr_reader :definitions
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@definitions = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(definition)
|
|
13
|
+
unless definition.is_a?(CredentialDefinition)
|
|
14
|
+
raise ArgumentError, "Expected CredentialDefinition, got #{definition.class}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@definitions[definition.key] = definition
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find(key)
|
|
21
|
+
@definitions[key.to_sym]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def all
|
|
25
|
+
@definitions.values
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authenticatable
|
|
29
|
+
@definitions.values.select(&:authenticatable)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registerable
|
|
33
|
+
@definitions.values.select(&:registerable)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def for_identifier(identifier_type)
|
|
37
|
+
# Find the default credential type for a given login identifier
|
|
38
|
+
key = :"#{identifier_type}_password"
|
|
39
|
+
@definitions[key]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def keys
|
|
43
|
+
@definitions.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns only credential definitions that are currently enabled via settings.
|
|
47
|
+
# A credential type is enabled if `auth.credentials.<key>.enabled` resolves to `true`.
|
|
48
|
+
# If no setting exists for the credential type, it defaults to enabled (true).
|
|
49
|
+
#
|
|
50
|
+
# @return [Array<CredentialDefinition>]
|
|
51
|
+
def enabled
|
|
52
|
+
@definitions.values.select { |defn| credential_enabled?(defn.key) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns keys of enabled credential types.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Symbol>]
|
|
58
|
+
def enabled_keys
|
|
59
|
+
enabled.map(&:key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Checks if a specific credential type is enabled.
|
|
63
|
+
# Returns false if the credential type is not registered.
|
|
64
|
+
#
|
|
65
|
+
# @param key [Symbol, String] credential type key
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def enabled?(key)
|
|
68
|
+
defn = @definitions[key.to_sym]
|
|
69
|
+
return false unless defn
|
|
70
|
+
|
|
71
|
+
credential_enabled?(defn.key)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Reads the enabled setting for a credential type.
|
|
77
|
+
# Falls back to true if no setting is registered (backward compat / custom types
|
|
78
|
+
# that haven't registered their enabled setting yet).
|
|
79
|
+
#
|
|
80
|
+
# @param key [Symbol] credential type key
|
|
81
|
+
# @return [Boolean]
|
|
82
|
+
def credential_enabled?(key)
|
|
83
|
+
setting_key = "auth.credentials.#{key}.enabled"
|
|
84
|
+
value = begin
|
|
85
|
+
RSB::Settings.get(setting_key)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
# If no setting registered, default to true (backward compat)
|
|
90
|
+
return true if value.nil?
|
|
91
|
+
|
|
92
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Auth
|
|
5
|
+
# Handles registration of per-credential-type enabled settings
|
|
6
|
+
# and validation callbacks (e.g., preventing disabling the last type).
|
|
7
|
+
#
|
|
8
|
+
# Called from the engine initializer after credential types are registered.
|
|
9
|
+
class CredentialSettingsRegistrar
|
|
10
|
+
# Auto-registers `auth.credentials.<key>.enabled` settings for all
|
|
11
|
+
# currently registered credential types.
|
|
12
|
+
#
|
|
13
|
+
# @return [void]
|
|
14
|
+
def self.register_enabled_settings
|
|
15
|
+
definitions = RSB::Auth.credentials.all
|
|
16
|
+
return if definitions.empty?
|
|
17
|
+
|
|
18
|
+
schema = RSB::Settings::Schema.new('auth') do
|
|
19
|
+
definitions.each do |defn|
|
|
20
|
+
setting :"credentials.#{defn.key}.enabled",
|
|
21
|
+
type: :boolean,
|
|
22
|
+
default: true,
|
|
23
|
+
group: 'Credential Types',
|
|
24
|
+
label: defn.label,
|
|
25
|
+
description: "Enable or disable #{defn.label} as a sign-in method"
|
|
26
|
+
|
|
27
|
+
setting :"credentials.#{defn.key}.verification_required",
|
|
28
|
+
type: :boolean,
|
|
29
|
+
default: true,
|
|
30
|
+
group: 'Credential Types',
|
|
31
|
+
label: "#{defn.label} — Verification Required",
|
|
32
|
+
depends_on: "auth.credentials.#{defn.key}.enabled",
|
|
33
|
+
description: "Require email verification before login for #{defn.label}"
|
|
34
|
+
|
|
35
|
+
setting :"credentials.#{defn.key}.auto_verify_on_signup",
|
|
36
|
+
type: :boolean,
|
|
37
|
+
default: false,
|
|
38
|
+
group: 'Credential Types',
|
|
39
|
+
label: "#{defn.label} — Auto-verify on Signup",
|
|
40
|
+
depends_on: "auth.credentials.#{defn.key}.enabled",
|
|
41
|
+
description: "Auto-verify credentials at registration time for #{defn.label}"
|
|
42
|
+
|
|
43
|
+
setting :"credentials.#{defn.key}.allow_login_unverified",
|
|
44
|
+
type: :boolean,
|
|
45
|
+
default: false,
|
|
46
|
+
group: 'Credential Types',
|
|
47
|
+
label: "#{defn.label} — Allow Login Unverified",
|
|
48
|
+
depends_on: "auth.credentials.#{defn.key}.enabled",
|
|
49
|
+
description: "Allow login without verification for #{defn.label}"
|
|
50
|
+
|
|
51
|
+
setting :"credentials.#{defn.key}.registerable",
|
|
52
|
+
type: :boolean,
|
|
53
|
+
default: true,
|
|
54
|
+
group: 'Credential Types',
|
|
55
|
+
label: "#{defn.label} — Self-registration",
|
|
56
|
+
depends_on: "auth.credentials.#{defn.key}.enabled",
|
|
57
|
+
description: "Allow self-registration for #{defn.label}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
RSB::Settings.registry.register(schema)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Registers on_change callbacks that prevent disabling the last
|
|
65
|
+
# enabled credential type. For each credential type, when its
|
|
66
|
+
# enabled setting changes to false, check that at least one other
|
|
67
|
+
# type remains enabled.
|
|
68
|
+
#
|
|
69
|
+
# @return [void]
|
|
70
|
+
def self.register_last_type_validation
|
|
71
|
+
RSB::Auth.credentials.all.each do |defn|
|
|
72
|
+
setting_key = "auth.credentials.#{defn.key}.enabled"
|
|
73
|
+
RSB::Settings.registry.on_change(setting_key) do |_old_value, new_value|
|
|
74
|
+
# Only check when disabling (new_value is falsy)
|
|
75
|
+
is_disabling = !ActiveModel::Type::Boolean.new.cast(new_value)
|
|
76
|
+
if is_disabling
|
|
77
|
+
# Count how many OTHER types are still enabled
|
|
78
|
+
other_enabled = RSB::Auth.credentials.all.count do |other|
|
|
79
|
+
next false if other.key == defn.key
|
|
80
|
+
|
|
81
|
+
RSB::Auth.credentials.enabled?(other.key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if other_enabled.zero?
|
|
85
|
+
raise RSB::Settings::ValidationError,
|
|
86
|
+
"Cannot disable #{defn.label}: at least one credential type must remain enabled."
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Mutual exclusion: auto_verify_on_signup and verification_required cannot both be true
|
|
92
|
+
auto_verify_key = "auth.credentials.#{defn.key}.auto_verify_on_signup"
|
|
93
|
+
verification_key = "auth.credentials.#{defn.key}.verification_required"
|
|
94
|
+
|
|
95
|
+
RSB::Settings.registry.on_change(auto_verify_key) do |_old_value, new_value|
|
|
96
|
+
if ActiveModel::Type::Boolean.new.cast(new_value)
|
|
97
|
+
verif = RSB::Settings.get(verification_key)
|
|
98
|
+
if ActiveModel::Type::Boolean.new.cast(verif)
|
|
99
|
+
raise RSB::Settings::ValidationError,
|
|
100
|
+
"Cannot enable auto-verify when verification is required for #{defn.label}. Disable verification_required first."
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
RSB::Settings.registry.on_change(verification_key) do |_old_value, new_value|
|
|
106
|
+
if ActiveModel::Type::Boolean.new.cast(new_value)
|
|
107
|
+
auto = RSB::Settings.get(auto_verify_key)
|
|
108
|
+
if ActiveModel::Type::Boolean.new.cast(auto)
|
|
109
|
+
raise RSB::Settings::ValidationError,
|
|
110
|
+
"Cannot enable verification_required when auto-verify is enabled for #{defn.label}. Disable auto_verify_on_signup first."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|