propel_authentication 0.1.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 (102) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +290 -0
  4. data/Rakefile +12 -0
  5. data/lib/generators/propel_auth/install_generator.rb +486 -0
  6. data/lib/generators/propel_auth/pack_generator.rb +277 -0
  7. data/lib/generators/propel_auth/templates/agency.rb +7 -0
  8. data/lib/generators/propel_auth/templates/agent.rb +7 -0
  9. data/lib/generators/propel_auth/templates/auth/base_passwords_controller.rb.tt +99 -0
  10. data/lib/generators/propel_auth/templates/auth/base_tokens_controller.rb.tt +90 -0
  11. data/lib/generators/propel_auth/templates/auth/passwords_controller.rb.tt +126 -0
  12. data/lib/generators/propel_auth/templates/auth_mailer.rb +180 -0
  13. data/lib/generators/propel_auth/templates/authenticatable.rb +38 -0
  14. data/lib/generators/propel_auth/templates/concerns/confirmable.rb +145 -0
  15. data/lib/generators/propel_auth/templates/concerns/lockable.rb +123 -0
  16. data/lib/generators/propel_auth/templates/concerns/propel_authentication.rb +44 -0
  17. data/lib/generators/propel_auth/templates/concerns/rack_session_disable.rb +19 -0
  18. data/lib/generators/propel_auth/templates/concerns/recoverable.rb +124 -0
  19. data/lib/generators/propel_auth/templates/config/environments/development_email.rb +43 -0
  20. data/lib/generators/propel_auth/templates/db/migrate/create_agencies.rb +20 -0
  21. data/lib/generators/propel_auth/templates/db/migrate/create_agents.rb +11 -0
  22. data/lib/generators/propel_auth/templates/db/migrate/create_invitations.rb +28 -0
  23. data/lib/generators/propel_auth/templates/db/migrate/create_organizations.rb +18 -0
  24. data/lib/generators/propel_auth/templates/db/migrate/create_users.rb +43 -0
  25. data/lib/generators/propel_auth/templates/db/seeds.rb +29 -0
  26. data/lib/generators/propel_auth/templates/invitation.rb +133 -0
  27. data/lib/generators/propel_auth/templates/lib/propel_auth.rb +84 -0
  28. data/lib/generators/propel_auth/templates/organization.rb +7 -0
  29. data/lib/generators/propel_auth/templates/propel_auth.rb +132 -0
  30. data/lib/generators/propel_auth/templates/services/auth_notification_service.rb +89 -0
  31. data/lib/generators/propel_auth/templates/test/concerns/confirmable_test.rb.tt +247 -0
  32. data/lib/generators/propel_auth/templates/test/concerns/lockable_test.rb.tt +282 -0
  33. data/lib/generators/propel_auth/templates/test/concerns/propel_authentication_test.rb.tt +75 -0
  34. data/lib/generators/propel_auth/templates/test/concerns/recoverable_test.rb.tt +327 -0
  35. data/lib/generators/propel_auth/templates/test/controllers/auth/lockable_integration_test.rb.tt +196 -0
  36. data/lib/generators/propel_auth/templates/test/controllers/auth/password_reset_integration_test.rb.tt +471 -0
  37. data/lib/generators/propel_auth/templates/test/controllers/auth/tokens_controller_test.rb.tt +265 -0
  38. data/lib/generators/propel_auth/templates/test/mailers/auth_mailer_test.rb.tt +216 -0
  39. data/lib/generators/propel_auth/templates/test/mailers/previews/auth_mailer_preview.rb +161 -0
  40. data/lib/generators/propel_auth/templates/tokens_controller.rb.tt +96 -0
  41. data/lib/generators/propel_auth/templates/user.rb +21 -0
  42. data/lib/generators/propel_auth/templates/user_test.rb.tt +81 -0
  43. data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.html.erb +213 -0
  44. data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.text.erb +56 -0
  45. data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.html.erb +213 -0
  46. data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.text.erb +32 -0
  47. data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.html.erb +166 -0
  48. data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.text.erb +32 -0
  49. data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.html.erb +194 -0
  50. data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.text.erb +51 -0
  51. data/lib/generators/propel_auth/test/dummy/Dockerfile +72 -0
  52. data/lib/generators/propel_auth/test/dummy/Gemfile +63 -0
  53. data/lib/generators/propel_auth/test/dummy/Gemfile.lock +394 -0
  54. data/lib/generators/propel_auth/test/dummy/README.md +24 -0
  55. data/lib/generators/propel_auth/test/dummy/Rakefile +6 -0
  56. data/lib/generators/propel_auth/test/dummy/app/assets/stylesheets/application.css +10 -0
  57. data/lib/generators/propel_auth/test/dummy/app/controllers/application_controller.rb +4 -0
  58. data/lib/generators/propel_auth/test/dummy/app/helpers/application_helper.rb +2 -0
  59. data/lib/generators/propel_auth/test/dummy/app/jobs/application_job.rb +7 -0
  60. data/lib/generators/propel_auth/test/dummy/app/mailers/application_mailer.rb +4 -0
  61. data/lib/generators/propel_auth/test/dummy/app/models/application_record.rb +3 -0
  62. data/lib/generators/propel_auth/test/dummy/app/views/layouts/application.html.erb +27 -0
  63. data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.html.erb +13 -0
  64. data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.text.erb +1 -0
  65. data/lib/generators/propel_auth/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  66. data/lib/generators/propel_auth/test/dummy/app/views/pwa/service-worker.js +26 -0
  67. data/lib/generators/propel_auth/test/dummy/bin/brakeman +7 -0
  68. data/lib/generators/propel_auth/test/dummy/bin/dev +2 -0
  69. data/lib/generators/propel_auth/test/dummy/bin/docker-entrypoint +14 -0
  70. data/lib/generators/propel_auth/test/dummy/bin/rails +4 -0
  71. data/lib/generators/propel_auth/test/dummy/bin/rake +4 -0
  72. data/lib/generators/propel_auth/test/dummy/bin/rubocop +8 -0
  73. data/lib/generators/propel_auth/test/dummy/bin/setup +34 -0
  74. data/lib/generators/propel_auth/test/dummy/bin/thrust +5 -0
  75. data/lib/generators/propel_auth/test/dummy/config/application.rb +42 -0
  76. data/lib/generators/propel_auth/test/dummy/config/boot.rb +4 -0
  77. data/lib/generators/propel_auth/test/dummy/config/cable.yml +10 -0
  78. data/lib/generators/propel_auth/test/dummy/config/credentials.yml.enc +1 -0
  79. data/lib/generators/propel_auth/test/dummy/config/database.yml +41 -0
  80. data/lib/generators/propel_auth/test/dummy/config/environment.rb +5 -0
  81. data/lib/generators/propel_auth/test/dummy/config/environments/development.rb +72 -0
  82. data/lib/generators/propel_auth/test/dummy/config/environments/production.rb +89 -0
  83. data/lib/generators/propel_auth/test/dummy/config/environments/test.rb +53 -0
  84. data/lib/generators/propel_auth/test/dummy/config/initializers/assets.rb +10 -0
  85. data/lib/generators/propel_auth/test/dummy/config/initializers/content_security_policy.rb +25 -0
  86. data/lib/generators/propel_auth/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
  87. data/lib/generators/propel_auth/test/dummy/config/initializers/inflections.rb +16 -0
  88. data/lib/generators/propel_auth/test/dummy/config/locales/en.yml +31 -0
  89. data/lib/generators/propel_auth/test/dummy/config/master.key +1 -0
  90. data/lib/generators/propel_auth/test/dummy/config/puma.rb +41 -0
  91. data/lib/generators/propel_auth/test/dummy/config/routes.rb +2 -0
  92. data/lib/generators/propel_auth/test/dummy/config/storage.yml +34 -0
  93. data/lib/generators/propel_auth/test/dummy/config.ru +6 -0
  94. data/lib/generators/propel_auth/test/dummy/db/schema.rb +14 -0
  95. data/lib/generators/propel_auth/test/generators/authentication/controllers/tokens_controller_test.rb +230 -0
  96. data/lib/generators/propel_auth/test/generators/authentication/install_generator_test.rb +490 -0
  97. data/lib/generators/propel_auth/test/generators/authentication/uninstall_generator_test.rb +408 -0
  98. data/lib/generators/propel_auth/test/integration/generator_integration_test.rb +158 -0
  99. data/lib/generators/propel_auth/test/integration/multi_version_generator_test.rb +125 -0
  100. data/lib/generators/propel_auth/unpack_generator.rb +345 -0
  101. data/lib/propel_auth.rb +3 -0
  102. metadata +195 -0
@@ -0,0 +1,180 @@
1
+ class AuthMailer < ApplicationMailer
2
+ # Rails 8 compatible authentication mailer with JWT integration
3
+ # Provides password reset, account unlock, invitation, and email confirmation functionality
4
+
5
+ default from: -> { "noreply@#{Rails.application.class.module_parent.name.downcase}.com" }
6
+
7
+ # Password reset email with JWT token integration
8
+ def password_reset
9
+ @user = params[:user]
10
+ @token = params[:token]
11
+ @reset_url = params[:reset_url]
12
+ @expiration_minutes = 15
13
+
14
+ # Set template variables as instance variables
15
+ @organization_name = organization_name(@user)
16
+ @display_name = display_name(@user)
17
+ @support_email = support_email
18
+
19
+ # Validate required parameters
20
+ raise ArgumentError, "User is required" unless @user
21
+ raise ArgumentError, "Token is required" unless @token
22
+ raise ArgumentError, "Reset URL is required" unless @reset_url
23
+
24
+ mail(
25
+ to: @user.email_address,
26
+ subject: "Reset your password for #{@organization_name}"
27
+ )
28
+ end
29
+
30
+ # Account unlock email with JWT token integration
31
+ def account_unlock
32
+ @user = params[:user]
33
+ @token = params[:token]
34
+ @unlock_url = params[:unlock_url]
35
+ @expiration_hours = 1
36
+
37
+ # Set template variables as instance variables
38
+ @organization_name = organization_name(@user)
39
+ @display_name = display_name(@user)
40
+ @support_email = support_email
41
+
42
+ # Validate required parameters
43
+ raise ArgumentError, "User is required" unless @user
44
+ raise ArgumentError, "Token is required" unless @token
45
+ raise ArgumentError, "Unlock URL is required" unless @unlock_url
46
+
47
+ mail(
48
+ to: @user.email_address,
49
+ subject: "Account locked - Security notification for #{@organization_name}"
50
+ )
51
+ end
52
+
53
+ # User invitation email with organization context
54
+ def user_invitation
55
+ @invitation = params[:invitation]
56
+ @inviter = params[:inviter]
57
+ @token = params[:token]
58
+ @invitation_url = params[:invitation_url]
59
+ @expiration_days = 7
60
+
61
+ # Set template variables as instance variables
62
+ @organization_name = organization_name(@inviter)
63
+ @inviter_display_name = display_name(@inviter)
64
+ @support_email = support_email
65
+
66
+ # Validate required parameters
67
+ raise ArgumentError, "Invitation is required" unless @invitation
68
+ raise ArgumentError, "Inviter is required" unless @inviter
69
+ raise ArgumentError, "Token is required" unless @token
70
+ raise ArgumentError, "Invitation URL is required" unless @invitation_url
71
+
72
+ mail(
73
+ to: @invitation.email_address,
74
+ subject: "You're invited to join #{@invitation.organization.name}"
75
+ )
76
+ end
77
+
78
+ # Email confirmation with secure token
79
+ def email_confirmation(user, email_address = nil)
80
+ @user = user
81
+ @email_address = email_address || user.email_address
82
+ @token = user.confirmation_token
83
+
84
+ # Set template variables as instance variables
85
+ @organization_name = organization_name(@user)
86
+ @display_name = display_name(@user)
87
+ @support_email = support_email
88
+
89
+ # Validate required parameters
90
+ raise ArgumentError, "User is required" unless @user
91
+ raise ArgumentError, "Confirmation token is required" unless @token
92
+
93
+ mail(
94
+ to: @email_address,
95
+ subject: "Please confirm your email address for #{@organization_name}"
96
+ )
97
+ end
98
+
99
+ # Welcome email for newly registered users
100
+ def welcome
101
+ @user = params[:user]
102
+ @organization = @user.organization
103
+
104
+ # Validate required parameters
105
+ raise ArgumentError, "User is required" unless @user
106
+
107
+ mail(
108
+ to: @user.email_address,
109
+ subject: "Welcome to #{@organization_name}!"
110
+ )
111
+ end
112
+
113
+ # Email change confirmation
114
+ def email_change_confirmation
115
+ @user = params[:user]
116
+ @new_email_address = params[:new_email_address]
117
+ @token = params[:token]
118
+ @confirmation_url = params[:confirmation_url]
119
+
120
+ # Validate required parameters
121
+ raise ArgumentError, "User is required" unless @user
122
+ raise ArgumentError, "New email address is required" unless @new_email_address
123
+ raise ArgumentError, "Token is required" unless @token
124
+ raise ArgumentError, "Confirmation URL is required" unless @confirmation_url
125
+
126
+ mail(
127
+ to: @new_email_address,
128
+ subject: "Confirm your new email address for #{@organization_name}"
129
+ )
130
+ end
131
+
132
+ # Helper methods - made public so they can be used in email templates
133
+
134
+ # Get organization name for email subjects and content
135
+ def organization_name(user = nil)
136
+ user = user || @user
137
+ if user&.organization&.name.present?
138
+ user.organization.name
139
+ elsif @invitation&.organization&.name.present?
140
+ @invitation.organization.name
141
+ else
142
+ Rails.application.class.module_parent.name
143
+ end
144
+ end
145
+
146
+ # Get user's display name with fallback
147
+ def display_name(user)
148
+ if user.first_name.present?
149
+ user.first_name
150
+ elsif user.username.present?
151
+ user.username
152
+ else
153
+ user.email_address.split('@').first.humanize
154
+ end
155
+ end
156
+
157
+ # Get support email for the organization
158
+ def support_email
159
+ "support@#{Rails.application.class.module_parent.name.downcase}.com"
160
+ end
161
+
162
+ # Get application name
163
+ def application_name
164
+ Rails.application.class.module_parent.name
165
+ end
166
+
167
+ # Generate confirmation URL with token
168
+ def confirmation_url(user)
169
+ token = user.confirmation_token
170
+ base_url = PropelAuth.configuration.frontend_url || "#{request.protocol}#{request.host_with_port}"
171
+ "#{base_url}/auth/confirm?token=#{token}"
172
+ end
173
+
174
+ private
175
+
176
+ # Renamed to avoid confusion with public helper
177
+ def user_display_name(user)
178
+ display_name(user)
179
+ end
180
+ end
@@ -0,0 +1,38 @@
1
+ module Authenticatable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ has_secure_password
6
+ validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
7
+ validates :password, presence: true, length: { minimum: 8 }, if: :password_digest_changed?
8
+ end
9
+
10
+ # Generate JWT token for user authentication
11
+ def generate_jwt_token
12
+ payload = {
13
+ user_id: id,
14
+ email_address: email_address,
15
+ organization_id: organization_id,
16
+ iat: Time.now.to_i,
17
+ exp: PropelAuth.configuration.jwt_expiration.from_now.to_i
18
+ }
19
+
20
+ JWT.encode(payload, PropelAuth.configuration.jwt_secret, 'HS256')
21
+ end
22
+
23
+ class_methods do
24
+ # Find user by JWT token
25
+ def find_by_jwt_token(token)
26
+ return nil unless token
27
+
28
+ decoded_token = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
29
+ payload = decoded_token.first
30
+
31
+ # Check if token is expired (JWT gem handles this, but double-check)
32
+ return nil if payload['exp'] && Time.at(payload['exp']) < Time.current
33
+
34
+ # Find user by ID from token
35
+ find_by(id: payload['user_id'])
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,145 @@
1
+ module Confirmable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ before_create :generate_confirmation_token, unless: :confirmed?
6
+ before_update :generate_confirmation_token_if_email_changed
7
+ after_create :send_confirmation_instructions, unless: :confirmed?
8
+
9
+ validates :confirmation_token, uniqueness: true, allow_nil: true
10
+ validates :email_address, presence: true
11
+ end
12
+
13
+ # CONFIRMATION STATUS METHODS
14
+
15
+ def confirmed?
16
+ confirmed_at.present?
17
+ end
18
+
19
+ def confirmation_pending?
20
+ !confirmed? && confirmation_token.present?
21
+ end
22
+
23
+ def confirmation_expired?
24
+ return false if confirmation_sent_at.blank?
25
+ confirmation_sent_at < confirmation_period.ago
26
+ end
27
+
28
+ def can_login?
29
+ confirmed? && !locked?
30
+ end
31
+
32
+ # CONFIRMATION TOKEN METHODS
33
+
34
+ def confirm_by_token(token)
35
+ return false if token.blank? || confirmation_token.blank?
36
+ return false unless secure_compare(token, confirmation_token)
37
+ return false if confirmation_expired?
38
+
39
+ self.confirmed_at = Time.current
40
+ self.confirmation_token = nil
41
+ self.confirmation_sent_at = nil
42
+
43
+ # If there's a pending email change, apply it
44
+ if unconfirmed_email_address.present?
45
+ self.email_address = unconfirmed_email_address
46
+ self.unconfirmed_email_address = nil
47
+ end
48
+
49
+ save(validate: false)
50
+ end
51
+
52
+ def send_confirmation_instructions
53
+ return false if confirmed?
54
+ return false if confirmation_rate_limited?
55
+
56
+ generate_confirmation_token
57
+ self.confirmation_sent_at = Time.current
58
+ save(validate: false)
59
+
60
+ AuthMailer.email_confirmation(self).deliver_now
61
+ true
62
+ end
63
+
64
+ def resend_confirmation_instructions
65
+ return false if confirmed?
66
+
67
+ send_confirmation_instructions
68
+ end
69
+
70
+ # PRIVATE METHODS
71
+
72
+ private
73
+
74
+ def generate_confirmation_token
75
+ self.confirmation_token = generate_secure_token
76
+ self.confirmation_sent_at = Time.current
77
+ end
78
+
79
+ def generate_confirmation_token_if_email_changed
80
+ if email_address_changed? && confirmed?
81
+ # Store the new email for confirmation
82
+ self.unconfirmed_email_address = email_address
83
+ # Revert email to original for now
84
+ self.email_address = email_address_was
85
+ # Mark as unconfirmed and generate new token
86
+ self.confirmed_at = nil
87
+ generate_confirmation_token
88
+ # Send confirmation to the new email
89
+ AuthMailer.email_confirmation(self, unconfirmed_email_address).deliver_now
90
+ elsif email_address_changed? && !confirmed?
91
+ # If user isn't confirmed yet, just regenerate token
92
+ generate_confirmation_token
93
+ end
94
+ end
95
+
96
+ def confirmation_rate_limited?
97
+ return false if confirmation_sent_at.blank?
98
+ confirmation_sent_at > confirmation_resend_interval.ago
99
+ end
100
+
101
+ def generate_secure_token
102
+ SecureRandom.hex(32)
103
+ end
104
+
105
+ def secure_compare(a, b)
106
+ return false if a.nil? || b.nil?
107
+ ActiveSupport::SecurityUtils.secure_compare(a, b)
108
+ end
109
+
110
+ def confirmation_period
111
+ PropelAuth.configuration.confirmation_period || 24.hours
112
+ end
113
+
114
+ def confirmation_resend_interval
115
+ PropelAuth.configuration.confirmation_resend_interval || 1.minute
116
+ end
117
+
118
+ module ClassMethods
119
+ def confirm_by_token(token)
120
+ user = find_by(confirmation_token: token)
121
+ return nil unless user
122
+
123
+ if user.confirm_by_token(token)
124
+ user
125
+ else
126
+ nil
127
+ end
128
+ end
129
+
130
+ def send_confirmation_instructions(attributes = {})
131
+ user = find_by(email_address: attributes[:email_address])
132
+ return nil unless user
133
+
134
+ if user.send_confirmation_instructions
135
+ user
136
+ else
137
+ nil
138
+ end
139
+ end
140
+
141
+ def find_for_confirmation(token)
142
+ find_by(confirmation_token: token)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,123 @@
1
+ module Lockable
2
+ extend ActiveSupport::Concern
3
+
4
+ # Check if account is currently locked
5
+ def locked?
6
+ return false unless locked_at.present?
7
+
8
+ # Check if lockout duration has passed (automatic unlock)
9
+ if locked_at + PropelAuth.configuration.lockout_duration > Time.current
10
+ true # Still within lockout period
11
+ else
12
+ # Lockout period expired, automatically unlock
13
+ unlock_account!
14
+ false
15
+ end
16
+ end
17
+
18
+ # Check if user can authenticate (not locked and within limits)
19
+ def can_authenticate?
20
+ !locked?
21
+ end
22
+
23
+ # Increment failed login attempts and lock if limit reached
24
+ def increment_failed_attempts
25
+ return if locked? # Don't increment if already locked
26
+
27
+ self.failed_login_attempts = (failed_login_attempts || 0) + 1
28
+
29
+ if failed_login_attempts >= PropelAuth.configuration.max_failed_attempts
30
+ lock_account!
31
+ else
32
+ save!
33
+ end
34
+ end
35
+
36
+ # Reset failed login attempts (called on successful login)
37
+ def reset_failed_attempts
38
+ update!(failed_login_attempts: 0)
39
+ end
40
+
41
+ # Manually lock the account
42
+ def lock_account!
43
+ update!(
44
+ locked_at: Time.current,
45
+ failed_login_attempts: PropelAuth.configuration.max_failed_attempts
46
+ )
47
+
48
+ # Send account locked email notification if enabled
49
+ if PropelAuth.configuration.enable_email_notifications
50
+ email_result = AuthNotificationService.send_account_unlock_email(self)
51
+
52
+ if email_result[:success]
53
+ Rails.logger.info "Account unlock email sent successfully to #{email_address}"
54
+ else
55
+ Rails.logger.error "Failed to send account unlock email to #{email_address}: #{email_result[:error]}"
56
+ end
57
+ end
58
+ end
59
+
60
+ # Manually unlock the account
61
+ def unlock_account!
62
+ update!(
63
+ locked_at: nil,
64
+ failed_login_attempts: 0
65
+ )
66
+ end
67
+
68
+ # Generate JWT token for account unlock (for email/SMS unlock links)
69
+ def generate_unlock_token
70
+ payload = {
71
+ user_id: id,
72
+ email_address: email_address,
73
+ type: 'unlock',
74
+ iat: Time.now.to_i,
75
+ exp: 1.hour.from_now.to_i # Unlock tokens expire in 1 hour
76
+ }
77
+
78
+ JWT.encode(payload, PropelAuth.configuration.jwt_secret, 'HS256')
79
+ end
80
+
81
+ # Unlock account using a valid unlock token
82
+ def unlock_with_token!(token)
83
+ return false unless token.present?
84
+
85
+ begin
86
+ decoded_token = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
87
+ payload = decoded_token.first
88
+
89
+ # Verify token is for this user and is an unlock token
90
+ if payload['user_id'] == id && payload['type'] == 'unlock'
91
+ unlock_account!
92
+ true
93
+ else
94
+ false
95
+ end
96
+ rescue JWT::ExpiredSignature, JWT::DecodeError, JWT::InvalidSignature
97
+ false
98
+ end
99
+ end
100
+
101
+ module ClassMethods
102
+ # Find user by unlock token
103
+ def find_by_unlock_token(token)
104
+ return nil unless token.present?
105
+
106
+ begin
107
+ decoded_token = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
108
+ payload = decoded_token.first
109
+
110
+ # Check if token is expired (JWT gem handles this, but double-check)
111
+ return nil if payload['exp'] && Time.at(payload['exp']) < Time.current
112
+
113
+ # Verify this is an unlock token
114
+ return nil unless payload['type'] == 'unlock'
115
+
116
+ # Find user by ID from token
117
+ find_by(id: payload['user_id'])
118
+ rescue JWT::ExpiredSignature, JWT::DecodeError, JWT::InvalidSignature
119
+ nil
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PropelAuthentication
4
+ extend ActiveSupport::Concern
5
+
6
+ private
7
+
8
+ def authenticate_user
9
+ token = extract_jwt_token
10
+
11
+ unless token
12
+ render json: { error: 'No token provided' }, status: :unauthorized
13
+ return false
14
+ end
15
+
16
+ begin
17
+ @current_user = User.find_by_jwt_token(token)
18
+ unless @current_user
19
+ render json: { error: 'Invalid token' }, status: :unauthorized
20
+ return false
21
+ end
22
+ rescue JWT::ExpiredSignature
23
+ render json: { error: 'Token expired' }, status: :unauthorized
24
+ return false
25
+ rescue JWT::DecodeError, JWT::InvalidSignature
26
+ render json: { error: 'Invalid token' }, status: :unauthorized
27
+ return false
28
+ end
29
+
30
+ true
31
+ end
32
+
33
+ def current_user
34
+ @current_user
35
+ end
36
+
37
+ def extract_jwt_token
38
+ auth_header = request.headers['Authorization']
39
+ return nil unless auth_header
40
+
41
+ # Extract token from "Bearer <token>" format
42
+ auth_header.split(' ').last if auth_header.start_with?('Bearer ')
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Disables rack session for API controllers to prevent session cookie overhead
4
+ # and ensure a stateless authentication mechanism, which is critical for JWT.
5
+ module RackSessionDisable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :disable_session
10
+
11
+ private
12
+
13
+ # By setting :skip to true, we tell Rack not to load or save session data,
14
+ # making the request handling more efficient and purely token-based.
15
+ def disable_session
16
+ request.session_options[:skip] = true
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,124 @@
1
+ module Recoverable
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ # No additional database fields needed - using JWT tokens
6
+ end
7
+
8
+ # Generate password reset token with JWT
9
+ def generate_password_reset_token
10
+ # Use high precision timestamp and random nonce for uniqueness
11
+ now = Time.now
12
+
13
+ payload = {
14
+ user_id: self.id,
15
+ email_address: self.email_address,
16
+ type: 'password_reset',
17
+ iat: now.to_f, # Use float for higher precision
18
+ exp: PropelAuth.configuration.password_reset_expiration.from_now.to_i,
19
+ password_hash: self.password_digest[0..10], # Bind token to current password
20
+ nonce: SecureRandom.hex(8) # Add random nonce for uniqueness
21
+ }
22
+
23
+ JWT.encode(payload, PropelAuth.configuration.jwt_secret, 'HS256')
24
+ end
25
+
26
+ # Validate password reset token
27
+ def valid_password_reset_token?(token)
28
+ return false if token.blank?
29
+
30
+ begin
31
+ decoded = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
32
+ payload = decoded.first
33
+
34
+ # Validate token structure and content
35
+ return false unless payload['type'] == 'password_reset'
36
+ return false unless payload['user_id'] == self.id
37
+ return false unless payload['email_address'] == self.email_address
38
+
39
+ # Validate token is not expired
40
+ return false if token_expired?(payload)
41
+
42
+ # Validate password hash binding (prevents reuse after password change)
43
+ return false unless valid_password_hash_binding?(payload)
44
+
45
+ true
46
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidSignature
47
+ false
48
+ end
49
+ end
50
+
51
+ # Reset password with token validation
52
+ def reset_password_with_token!(token, new_password, new_password_confirmation = new_password)
53
+ return false unless valid_password_reset_token?(token)
54
+ return false if new_password.blank?
55
+ return false if new_password != new_password_confirmation
56
+
57
+ # Validate password length (configuration is a Range)
58
+ password_range = PropelAuth.configuration.password_length
59
+ return false unless password_range.include?(new_password.length)
60
+
61
+ begin
62
+ # Update password and clear failed login attempts
63
+ self.password = new_password
64
+ self.failed_login_attempts = 0 # Clear lockable state
65
+
66
+ # Save changes
67
+ self.save!
68
+ true
69
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved
70
+ false
71
+ end
72
+ end
73
+
74
+ class_methods do
75
+ # Find user by JWT password reset token
76
+ def find_user_by_password_reset_token(token)
77
+ return nil if token.blank?
78
+
79
+ begin
80
+ decoded = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
81
+ payload = decoded.first
82
+
83
+ # Validate token structure
84
+ return nil unless payload['type'] == 'password_reset'
85
+ return nil unless payload['user_id'].present?
86
+
87
+ # Find user and validate token
88
+ user = find_by(id: payload['user_id'])
89
+ return nil unless user&.valid_password_reset_token?(token)
90
+
91
+ user
92
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidSignature
93
+ nil
94
+ end
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # Extract user ID from token
101
+ def extract_user_id_from_token(token)
102
+ return nil if token.blank?
103
+
104
+ begin
105
+ decoded = JWT.decode(token, PropelAuth.configuration.jwt_secret, true, { algorithm: 'HS256' })
106
+ decoded.first['user_id']
107
+ rescue JWT::DecodeError
108
+ nil
109
+ end
110
+ end
111
+
112
+ # Validate token expiration
113
+ def token_expired?(payload)
114
+ return true unless payload['exp'].present?
115
+ Time.current.to_i >= payload['exp']
116
+ end
117
+
118
+ # Validate password hash binding to prevent reuse after password change
119
+ def valid_password_hash_binding?(payload)
120
+ return false unless payload['password_hash'].present?
121
+ expected_hash = self.password_digest[0..10]
122
+ payload['password_hash'] == expected_hash
123
+ end
124
+ end