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.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +76 -0
  4. data/Rakefile +25 -0
  5. data/app/controllers/concerns/rsb/auth/ensure_identity_complete.rb +72 -0
  6. data/app/controllers/concerns/rsb/auth/rate_limitable.rb +20 -0
  7. data/app/controllers/rsb/auth/account/login_methods_controller.rb +85 -0
  8. data/app/controllers/rsb/auth/account/sessions_controller.rb +31 -0
  9. data/app/controllers/rsb/auth/account_controller.rb +99 -0
  10. data/app/controllers/rsb/auth/admin/identities_controller.rb +402 -0
  11. data/app/controllers/rsb/auth/admin/sessions_management_controller.rb +27 -0
  12. data/app/controllers/rsb/auth/application_controller.rb +50 -0
  13. data/app/controllers/rsb/auth/invitations_controller.rb +31 -0
  14. data/app/controllers/rsb/auth/password_resets_controller.rb +46 -0
  15. data/app/controllers/rsb/auth/registrations_controller.rb +104 -0
  16. data/app/controllers/rsb/auth/sessions_controller.rb +109 -0
  17. data/app/controllers/rsb/auth/verifications_controller.rb +29 -0
  18. data/app/helpers/rsb/auth/user_agent_helper.rb +22 -0
  19. data/app/mailers/rsb/auth/application_mailer.rb +10 -0
  20. data/app/mailers/rsb/auth/auth_mailer.rb +33 -0
  21. data/app/models/rsb/auth/application_record.rb +10 -0
  22. data/app/models/rsb/auth/credential/email_password.rb +9 -0
  23. data/app/models/rsb/auth/credential/phone_password.rb +16 -0
  24. data/app/models/rsb/auth/credential/username_password.rb +9 -0
  25. data/app/models/rsb/auth/credential.rb +122 -0
  26. data/app/models/rsb/auth/identity.rb +62 -0
  27. data/app/models/rsb/auth/invitation.rb +55 -0
  28. data/app/models/rsb/auth/password_reset_token.rb +36 -0
  29. data/app/models/rsb/auth/session.rb +44 -0
  30. data/app/services/rsb/auth/account_service.rb +140 -0
  31. data/app/services/rsb/auth/authentication_service.rb +86 -0
  32. data/app/services/rsb/auth/invitation_service.rb +53 -0
  33. data/app/services/rsb/auth/password_reset_service.rb +48 -0
  34. data/app/services/rsb/auth/registration_service.rb +108 -0
  35. data/app/services/rsb/auth/session_service.rb +47 -0
  36. data/app/services/rsb/auth/verification_service.rb +30 -0
  37. data/app/views/layouts/rsb/auth/application.html.erb +76 -0
  38. data/app/views/rsb/auth/account/_identity_fields.html.erb +3 -0
  39. data/app/views/rsb/auth/account/confirm_destroy.html.erb +45 -0
  40. data/app/views/rsb/auth/account/login_methods/show.html.erb +92 -0
  41. data/app/views/rsb/auth/account/show.html.erb +110 -0
  42. data/app/views/rsb/auth/admin/credentials/_email_password.html.erb +34 -0
  43. data/app/views/rsb/auth/admin/credentials/_phone_password.html.erb +34 -0
  44. data/app/views/rsb/auth/admin/credentials/_username_password.html.erb +34 -0
  45. data/app/views/rsb/auth/admin/identities/index.html.erb +76 -0
  46. data/app/views/rsb/auth/admin/identities/new.html.erb +46 -0
  47. data/app/views/rsb/auth/admin/identities/new_credential.html.erb +45 -0
  48. data/app/views/rsb/auth/admin/identities/show.html.erb +180 -0
  49. data/app/views/rsb/auth/admin/sessions_management/index.html.erb +69 -0
  50. data/app/views/rsb/auth/auth_mailer/invitation.html.erb +4 -0
  51. data/app/views/rsb/auth/auth_mailer/password_reset.html.erb +4 -0
  52. data/app/views/rsb/auth/auth_mailer/verification.html.erb +4 -0
  53. data/app/views/rsb/auth/credentials/_email_password_login.html.erb +36 -0
  54. data/app/views/rsb/auth/credentials/_email_password_signup.html.erb +45 -0
  55. data/app/views/rsb/auth/credentials/_icon.html.erb +21 -0
  56. data/app/views/rsb/auth/credentials/_phone_password_login.html.erb +33 -0
  57. data/app/views/rsb/auth/credentials/_phone_password_signup.html.erb +45 -0
  58. data/app/views/rsb/auth/credentials/_selector.html.erb +43 -0
  59. data/app/views/rsb/auth/credentials/_username_password_login.html.erb +33 -0
  60. data/app/views/rsb/auth/credentials/_username_password_signup.html.erb +54 -0
  61. data/app/views/rsb/auth/invitations/show.html.erb +40 -0
  62. data/app/views/rsb/auth/password_resets/edit.html.erb +41 -0
  63. data/app/views/rsb/auth/password_resets/new.html.erb +27 -0
  64. data/app/views/rsb/auth/registrations/new.html.erb +55 -0
  65. data/app/views/rsb/auth/sessions/new.html.erb +47 -0
  66. data/config/locales/account.en.yml +65 -0
  67. data/config/locales/admin.en.yml +26 -0
  68. data/config/locales/credentials.en.yml +11 -0
  69. data/config/locales/seo.en.yml +28 -0
  70. data/config/routes.rb +34 -0
  71. data/db/migrate/20260208100001_create_rsb_auth_identities.rb +12 -0
  72. data/db/migrate/20260208100002_create_rsb_auth_credentials.rb +20 -0
  73. data/db/migrate/20260208100003_create_rsb_auth_sessions.rb +18 -0
  74. data/db/migrate/20260208100004_create_rsb_auth_password_reset_tokens.rb +15 -0
  75. data/db/migrate/20260208100005_add_verification_to_rsb_auth_credentials.rb +9 -0
  76. data/db/migrate/20260208100006_create_rsb_auth_invitations.rb +19 -0
  77. data/db/migrate/20260211100001_add_revoked_at_to_rsb_auth_credentials.rb +16 -0
  78. data/db/migrate/20260212100001_add_deleted_at_to_rsb_auth_identities.rb +10 -0
  79. data/db/migrate/20260214172956_add_recovery_email_to_rsb_auth_credentials.rb +8 -0
  80. data/lib/generators/rsb/auth/install/install_generator.rb +31 -0
  81. data/lib/generators/rsb/auth/views/views_generator.rb +197 -0
  82. data/lib/rsb/auth/configuration.rb +59 -0
  83. data/lib/rsb/auth/credential_conflict_error.rb +21 -0
  84. data/lib/rsb/auth/credential_definition.rb +39 -0
  85. data/lib/rsb/auth/credential_deprecation_bridge.rb +81 -0
  86. data/lib/rsb/auth/credential_registry.rb +96 -0
  87. data/lib/rsb/auth/credential_settings_registrar.rb +118 -0
  88. data/lib/rsb/auth/engine.rb +187 -0
  89. data/lib/rsb/auth/lifecycle_handler.rb +71 -0
  90. data/lib/rsb/auth/settings_schema.rb +74 -0
  91. data/lib/rsb/auth/test_helper.rb +139 -0
  92. data/lib/rsb/auth/version.rb +9 -0
  93. data/lib/rsb/auth.rb +49 -0
  94. metadata +192 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c3eb90b542687f335662080f73af84d46e55348664b2e99193452555715b5772
4
+ data.tar.gz: 5f980b6c9c0042304eb15c8f7f114461bece133f02bea55ba088006e4c915b34
5
+ SHA512:
6
+ metadata.gz: f333c9fa678dba4bb82b544178ef99654ffbacf1108b2b2bcb7b89b48196357f2a407710a35ab975113a52521f3d00c8680e0f068ed61f76c5f8a5b1fdae9f0b
7
+ data.tar.gz: 8b26d96dd17ef6892d025faa04704ce502e01e632be6ae37e06c40cbe756d72c3bfaebdc634ddd9ec026aeca79a89a680b427e16bf6ae92f9cab98648d633358
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Rails SaaS Builder (RSB)
2
+ Copyright (C) 2026 Aleksandr Marchenko
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Lesser General Public License as published by
6
+ the Free Software Foundation, either version 3 of the License, or
7
+ (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Lesser General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Lesser General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # rsb-auth
2
+
3
+ Identity and authentication engine for Rails SaaS Builder. Provides a flexible identity system with pluggable credential types (email+password, username+password out of the box), session management, password reset, email verification, and user invitations. Extensible via a credential registry for custom auth methods.
4
+
5
+ ## Installation
6
+
7
+ ### As part of Rails SaaS Builder
8
+
9
+ ```ruby
10
+ gem "rails-saas-builder"
11
+ ```
12
+
13
+ ### Standalone
14
+
15
+ ```ruby
16
+ gem "rsb-auth"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```bash
22
+ bundle install
23
+ rails generate rsb_auth:install
24
+ rails db:migrate
25
+ ```
26
+
27
+ ## Key Features
28
+
29
+ - Pluggable credential types with auto-detection
30
+ - Session management with configurable TTL and max concurrent sessions
31
+ - Email verification with secure token flow
32
+ - Password reset with 2-hour expiry tokens
33
+ - User invitations with 7-day expiry
34
+ - Account management (profile, password change, deletion)
35
+ - Rate limiting and lockout protection
36
+ - Configurable registration modes (open, invite-only, disabled)
37
+ - Extensible identity model via `identity_concerns`
38
+ - Pre-built auth views (login, registration, password reset, account)
39
+
40
+ ## Basic Usage
41
+
42
+ ```ruby
43
+ # Register a credential type
44
+ RSB::Auth.credentials.register(
45
+ RSB::Auth::CredentialDefinition.new(
46
+ key: :email_password,
47
+ class_name: "RSB::Auth::Credential::EmailPassword",
48
+ authenticatable: true,
49
+ registerable: true,
50
+ label: "Email & Password"
51
+ )
52
+ )
53
+
54
+ # Extend the Identity model
55
+ RSB::Auth.configure do |config|
56
+ config.identity_concerns = [MyApp::HasProfile]
57
+ end
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ ```ruby
63
+ RSB::Auth.configure do |config|
64
+ config.lifecycle_handler = "MyApp::AuthHandler"
65
+ config.identity_concerns = [MyApp::HasProfile]
66
+ config.permitted_account_params = [:name, :avatar_url]
67
+ end
68
+ ```
69
+
70
+ ## Documentation
71
+
72
+ Part of [Rails SaaS Builder](../README.md). See the main README for the full picture.
73
+
74
+ ## License
75
+
76
+ [LGPL-3.0](../LICENSE)
data/Rakefile ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'fileutils'
5
+ require 'rake/testtask'
6
+
7
+ task :prepare_test_db do
8
+ ENV['RAILS_ENV'] = 'test'
9
+ db = File.expand_path('test/dummy/db/test.sqlite3', __dir__)
10
+ FileUtils.rm_f(db)
11
+ require_relative 'test/dummy/config/environment'
12
+ ActiveRecord::Migration.verbose = false
13
+ ActiveRecord::MigrationContext.new(Rails.application.paths['db/migrate'].to_a).migrate
14
+ schema = File.expand_path('test/dummy/db/schema.rb', __dir__)
15
+ File.open(schema, 'w') { |f| ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, f) }
16
+ end
17
+
18
+ Rake::TestTask.new(:test) do |t|
19
+ t.libs << 'test'
20
+ t.pattern = 'test/**/*_test.rb'
21
+ t.verbose = false
22
+ end
23
+
24
+ task test: :prepare_test_db
25
+ task default: :test
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ # Opt-in concern for host app controllers that redirects users with
6
+ # incomplete identities to the account edit page.
7
+ #
8
+ # Include in your ApplicationController to enforce profile completion:
9
+ #
10
+ # @example
11
+ # class ApplicationController < ActionController::Base
12
+ # include RSB::Auth::EnsureIdentityComplete
13
+ # end
14
+ #
15
+ # The concern adds a +before_action+ that checks +current_identity.complete?+
16
+ # (from RFC-010). If the identity is incomplete, the user is redirected to
17
+ # +/auth/account+ with a flash message.
18
+ #
19
+ # Auth engine routes are skipped to prevent redirect loops (e.g., the account
20
+ # page itself, login, registration).
21
+ #
22
+ module EnsureIdentityComplete
23
+ extend ActiveSupport::Concern
24
+
25
+ included do
26
+ before_action :ensure_identity_complete
27
+ end
28
+
29
+ private
30
+
31
+ # Redirects to account edit if the current identity exists but is incomplete.
32
+ # Skips check for auth engine routes to prevent redirect loops.
33
+ #
34
+ # @return [void]
35
+ def ensure_identity_complete
36
+ identity = current_identity_for_completion_check
37
+ return unless identity
38
+ return if identity.complete?
39
+ return if rsb_auth_engine_route?
40
+
41
+ redirect_to RSB::Auth::Engine.routes.url_helpers.account_path,
42
+ alert: t('rsb.auth.account.complete_profile')
43
+ end
44
+
45
+ # Resolves the current identity from the session cookie so the concern
46
+ # works in the host app without requiring the host to define +current_identity+.
47
+ #
48
+ # @return [RSB::Auth::Identity, nil]
49
+ def current_identity_for_completion_check
50
+ session = RSB::Auth::SessionService.new.find_by_token(
51
+ cookies.signed[:rsb_session_token]
52
+ )
53
+ session&.identity
54
+ end
55
+
56
+ # Checks whether the current request is handled by the rsb-auth engine.
57
+ # Used to skip the completion check on auth routes (login, registration,
58
+ # account edit) to prevent redirect loops.
59
+ #
60
+ # @return [Boolean]
61
+ def rsb_auth_engine_route?
62
+ script_name = RSB::Auth::Engine.routes.find_script_name({})
63
+ return self.class.module_parents.include?(RSB::Auth) if script_name.nil? || script_name.empty?
64
+
65
+ request.path.start_with?(script_name)
66
+ rescue StandardError
67
+ # Fallback: check if controller is under RSB::Auth namespace
68
+ self.class.module_parents.include?(RSB::Auth)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ module RateLimitable
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def throttle!(key:, limit: 10, period: 60)
11
+ cache_key = "rsb_throttle:#{key}:#{request.remote_ip}"
12
+ count = Rails.cache.increment(cache_key, 1, expires_in: period.seconds, initial: 0)
13
+
14
+ return unless count > limit
15
+
16
+ render plain: 'Too many requests. Try again later.', status: :too_many_requests
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ module Account
6
+ class LoginMethodsController < RSB::Auth::ApplicationController
7
+ layout 'rsb/auth/application'
8
+
9
+ include RSB::Auth::RateLimitable
10
+
11
+ before_action :require_authentication
12
+ before_action :set_credential
13
+ before_action -> { throttle!(key: 'change_password', limit: 10, period: 60) }, only: :change_password
14
+
15
+ # Renders the login method detail page for a specific credential.
16
+ # Shows identifier, type, verification status, change password form,
17
+ # and optional remove button.
18
+ #
19
+ # @route GET /auth/account/login_methods/:id
20
+ def show
21
+ @can_remove = current_identity.active_credentials.count > 1
22
+ end
23
+
24
+ # Changes the password on a specific credential.
25
+ # Delegates to AccountService#change_password which verifies the current
26
+ # password and revokes all other sessions on success.
27
+ #
28
+ # @route PATCH /auth/account/login_methods/:id/password
29
+ def change_password
30
+ result = RSB::Auth::AccountService.new.change_password(
31
+ credential: @credential,
32
+ current_password: params[:current_password],
33
+ new_password: params[:new_password],
34
+ new_password_confirmation: params[:new_password_confirmation],
35
+ current_session: current_session
36
+ )
37
+
38
+ if result.success?
39
+ redirect_to account_login_method_path(@credential), notice: t('rsb.auth.account.password_changed')
40
+ else
41
+ @password_errors = result.errors
42
+ @can_remove = current_identity.active_credentials.count > 1
43
+ render :show, status: :unprocessable_entity
44
+ end
45
+ end
46
+
47
+ # Revokes (removes) a login method.
48
+ # Guards against removing the last active credential.
49
+ #
50
+ # @route DELETE /auth/account/login_methods/:id
51
+ def destroy
52
+ if current_identity.active_credentials.count <= 1
53
+ redirect_to account_path, alert: t('rsb.auth.account.cannot_remove_last')
54
+ return
55
+ end
56
+
57
+ @credential.revoke!
58
+ redirect_to account_path, notice: t('rsb.auth.account.login_method_removed')
59
+ end
60
+
61
+ # Resends verification email for an unverified credential.
62
+ #
63
+ # @route POST /auth/account/login_methods/:id/resend_verification
64
+ def resend_verification
65
+ if @credential.verified?
66
+ redirect_to account_login_method_path(@credential), alert: t('rsb.auth.account.already_verified')
67
+ return
68
+ end
69
+
70
+ RSB::Auth::VerificationService.new.send_verification(@credential)
71
+ redirect_to account_login_method_path(@credential), notice: t('rsb.auth.account.verification_sent')
72
+ end
73
+
74
+ private
75
+
76
+ # Scoped to current_identity.active_credentials to prevent:
77
+ # - Accessing another identity's credentials (RecordNotFound)
78
+ # - Accessing revoked credentials (not in active scope)
79
+ def set_credential
80
+ @credential = current_identity.active_credentials.find(params[:id])
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ module Account
6
+ class SessionsController < RSB::Auth::ApplicationController
7
+ before_action :require_authentication
8
+
9
+ # Revokes a specific session belonging to the current identity.
10
+ # Scoped to current_identity.sessions to prevent accessing
11
+ # another identity's sessions (raises RecordNotFound).
12
+ #
13
+ # @route DELETE /auth/account/sessions/:id
14
+ def destroy
15
+ target = current_identity.sessions.find(params[:id])
16
+ RSB::Auth::SessionService.new.revoke(target)
17
+ redirect_to account_path, notice: t('rsb.auth.account.session_revoked')
18
+ end
19
+
20
+ # Revokes all active sessions for the current identity except
21
+ # the current session. The user remains logged in.
22
+ #
23
+ # @route DELETE /auth/account/sessions
24
+ def destroy_all
25
+ RSB::Auth::SessionService.new.revoke_all(current_identity, except: current_session)
26
+ redirect_to account_path, notice: t('rsb.auth.account.all_sessions_revoked')
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Auth
5
+ class AccountController < ApplicationController
6
+ layout 'rsb/auth/application'
7
+
8
+ include RSB::Auth::RateLimitable
9
+ include RSB::Auth::UserAgentHelper
10
+
11
+ before_action :require_authentication
12
+ before_action :check_account_enabled
13
+ before_action :check_deletion_enabled, only: %i[confirm_destroy destroy]
14
+
15
+ # Renders the account hub page with four sections:
16
+ # login methods, identity fields, active sessions, delete account.
17
+ #
18
+ # @route GET /auth/account
19
+ def show
20
+ load_account_data
21
+ @rsb_page_title = t('rsb.auth.account.show.page_title', default: 'Account')
22
+ end
23
+
24
+ # Updates identity attributes (metadata or concern-provided nested attributes).
25
+ #
26
+ # @route PATCH /auth/account
27
+ def update
28
+ result = RSB::Auth::AccountService.new.update(
29
+ identity: current_identity,
30
+ params: account_params
31
+ )
32
+
33
+ if result.success?
34
+ redirect_to account_path, notice: t('rsb.auth.account.updated')
35
+ else
36
+ @errors = result.errors
37
+ load_account_data
38
+ render :show, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ # Renders the password confirmation page before account deletion.
43
+ #
44
+ # @route GET /auth/account/confirm_destroy
45
+ def confirm_destroy; end
46
+
47
+ # Soft-deletes the account after password verification.
48
+ # Clears the session cookie and redirects to login.
49
+ #
50
+ # @route DELETE /auth/account
51
+ def destroy
52
+ result = RSB::Auth::AccountService.new.delete_account(
53
+ identity: current_identity,
54
+ password: params[:password],
55
+ current_session: current_session
56
+ )
57
+
58
+ if result.success?
59
+ reset_session
60
+ cookies.delete(:rsb_session_token)
61
+ redirect_to new_session_path, notice: t('rsb.auth.account.deleted')
62
+ else
63
+ @errors = result.errors
64
+ render :confirm_destroy, status: :unprocessable_entity
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def account_params
71
+ permitted = RSB::Auth.configuration.permitted_account_params
72
+ permitted = permitted.flat_map { |p| p == :metadata ? [{ metadata: {} }] : [p] }
73
+ params.require(:identity).permit(*permitted)
74
+ rescue ActionController::ParameterMissing
75
+ {}
76
+ end
77
+
78
+ def load_account_data
79
+ @identity = current_identity
80
+ @login_methods = current_identity.active_credentials.order(:created_at)
81
+ @sessions = current_identity.sessions.active.order(last_active_at: :desc)
82
+ @current_session = current_session
83
+ @deletion_enabled = RSB::Settings.get('auth.account_deletion_enabled')
84
+ end
85
+
86
+ def check_account_enabled
87
+ return if RSB::Settings.get('auth.account_enabled')
88
+
89
+ redirect_to main_app.root_path, alert: t('rsb.auth.account.disabled')
90
+ end
91
+
92
+ def check_deletion_enabled
93
+ return if RSB::Settings.get('auth.account_deletion_enabled')
94
+
95
+ redirect_to account_path, alert: t('rsb.auth.account.deletion_disabled')
96
+ end
97
+ end
98
+ end
99
+ end