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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +290 -0
- data/Rakefile +12 -0
- data/lib/generators/propel_auth/install_generator.rb +486 -0
- data/lib/generators/propel_auth/pack_generator.rb +277 -0
- data/lib/generators/propel_auth/templates/agency.rb +7 -0
- data/lib/generators/propel_auth/templates/agent.rb +7 -0
- data/lib/generators/propel_auth/templates/auth/base_passwords_controller.rb.tt +99 -0
- data/lib/generators/propel_auth/templates/auth/base_tokens_controller.rb.tt +90 -0
- data/lib/generators/propel_auth/templates/auth/passwords_controller.rb.tt +126 -0
- data/lib/generators/propel_auth/templates/auth_mailer.rb +180 -0
- data/lib/generators/propel_auth/templates/authenticatable.rb +38 -0
- data/lib/generators/propel_auth/templates/concerns/confirmable.rb +145 -0
- data/lib/generators/propel_auth/templates/concerns/lockable.rb +123 -0
- data/lib/generators/propel_auth/templates/concerns/propel_authentication.rb +44 -0
- data/lib/generators/propel_auth/templates/concerns/rack_session_disable.rb +19 -0
- data/lib/generators/propel_auth/templates/concerns/recoverable.rb +124 -0
- data/lib/generators/propel_auth/templates/config/environments/development_email.rb +43 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_agencies.rb +20 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_agents.rb +11 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_invitations.rb +28 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_organizations.rb +18 -0
- data/lib/generators/propel_auth/templates/db/migrate/create_users.rb +43 -0
- data/lib/generators/propel_auth/templates/db/seeds.rb +29 -0
- data/lib/generators/propel_auth/templates/invitation.rb +133 -0
- data/lib/generators/propel_auth/templates/lib/propel_auth.rb +84 -0
- data/lib/generators/propel_auth/templates/organization.rb +7 -0
- data/lib/generators/propel_auth/templates/propel_auth.rb +132 -0
- data/lib/generators/propel_auth/templates/services/auth_notification_service.rb +89 -0
- data/lib/generators/propel_auth/templates/test/concerns/confirmable_test.rb.tt +247 -0
- data/lib/generators/propel_auth/templates/test/concerns/lockable_test.rb.tt +282 -0
- data/lib/generators/propel_auth/templates/test/concerns/propel_authentication_test.rb.tt +75 -0
- data/lib/generators/propel_auth/templates/test/concerns/recoverable_test.rb.tt +327 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/lockable_integration_test.rb.tt +196 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/password_reset_integration_test.rb.tt +471 -0
- data/lib/generators/propel_auth/templates/test/controllers/auth/tokens_controller_test.rb.tt +265 -0
- data/lib/generators/propel_auth/templates/test/mailers/auth_mailer_test.rb.tt +216 -0
- data/lib/generators/propel_auth/templates/test/mailers/previews/auth_mailer_preview.rb +161 -0
- data/lib/generators/propel_auth/templates/tokens_controller.rb.tt +96 -0
- data/lib/generators/propel_auth/templates/user.rb +21 -0
- data/lib/generators/propel_auth/templates/user_test.rb.tt +81 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.html.erb +213 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/account_unlock.text.erb +56 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.html.erb +213 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/email_confirmation.text.erb +32 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.html.erb +166 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/password_reset.text.erb +32 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.html.erb +194 -0
- data/lib/generators/propel_auth/templates/views/auth_mailer/user_invitation.text.erb +51 -0
- data/lib/generators/propel_auth/test/dummy/Dockerfile +72 -0
- data/lib/generators/propel_auth/test/dummy/Gemfile +63 -0
- data/lib/generators/propel_auth/test/dummy/Gemfile.lock +394 -0
- data/lib/generators/propel_auth/test/dummy/README.md +24 -0
- data/lib/generators/propel_auth/test/dummy/Rakefile +6 -0
- data/lib/generators/propel_auth/test/dummy/app/assets/stylesheets/application.css +10 -0
- data/lib/generators/propel_auth/test/dummy/app/controllers/application_controller.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/app/helpers/application_helper.rb +2 -0
- data/lib/generators/propel_auth/test/dummy/app/jobs/application_job.rb +7 -0
- data/lib/generators/propel_auth/test/dummy/app/mailers/application_mailer.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/app/models/application_record.rb +3 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/application.html.erb +27 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.html.erb +13 -0
- data/lib/generators/propel_auth/test/dummy/app/views/layouts/mailer.text.erb +1 -0
- data/lib/generators/propel_auth/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/lib/generators/propel_auth/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/lib/generators/propel_auth/test/dummy/bin/brakeman +7 -0
- data/lib/generators/propel_auth/test/dummy/bin/dev +2 -0
- data/lib/generators/propel_auth/test/dummy/bin/docker-entrypoint +14 -0
- data/lib/generators/propel_auth/test/dummy/bin/rails +4 -0
- data/lib/generators/propel_auth/test/dummy/bin/rake +4 -0
- data/lib/generators/propel_auth/test/dummy/bin/rubocop +8 -0
- data/lib/generators/propel_auth/test/dummy/bin/setup +34 -0
- data/lib/generators/propel_auth/test/dummy/bin/thrust +5 -0
- data/lib/generators/propel_auth/test/dummy/config/application.rb +42 -0
- data/lib/generators/propel_auth/test/dummy/config/boot.rb +4 -0
- data/lib/generators/propel_auth/test/dummy/config/cable.yml +10 -0
- data/lib/generators/propel_auth/test/dummy/config/credentials.yml.enc +1 -0
- data/lib/generators/propel_auth/test/dummy/config/database.yml +41 -0
- data/lib/generators/propel_auth/test/dummy/config/environment.rb +5 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/development.rb +72 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/production.rb +89 -0
- data/lib/generators/propel_auth/test/dummy/config/environments/test.rb +53 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/assets.rb +10 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/content_security_policy.rb +25 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/filter_parameter_logging.rb +8 -0
- data/lib/generators/propel_auth/test/dummy/config/initializers/inflections.rb +16 -0
- data/lib/generators/propel_auth/test/dummy/config/locales/en.yml +31 -0
- data/lib/generators/propel_auth/test/dummy/config/master.key +1 -0
- data/lib/generators/propel_auth/test/dummy/config/puma.rb +41 -0
- data/lib/generators/propel_auth/test/dummy/config/routes.rb +2 -0
- data/lib/generators/propel_auth/test/dummy/config/storage.yml +34 -0
- data/lib/generators/propel_auth/test/dummy/config.ru +6 -0
- data/lib/generators/propel_auth/test/dummy/db/schema.rb +14 -0
- data/lib/generators/propel_auth/test/generators/authentication/controllers/tokens_controller_test.rb +230 -0
- data/lib/generators/propel_auth/test/generators/authentication/install_generator_test.rb +490 -0
- data/lib/generators/propel_auth/test/generators/authentication/uninstall_generator_test.rb +408 -0
- data/lib/generators/propel_auth/test/integration/generator_integration_test.rb +158 -0
- data/lib/generators/propel_auth/test/integration/multi_version_generator_test.rb +125 -0
- data/lib/generators/propel_auth/unpack_generator.rb +345 -0
- data/lib/propel_auth.rb +3 -0
- 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
|