shieldify 0.1.2.pre.alpha

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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +375 -0
  4. data/Rakefile +3 -0
  5. data/app/controllers/users/access_controller.rb +16 -0
  6. data/app/controllers/users/emails/reset_passwords_controller.rb +30 -0
  7. data/app/controllers/users/emails_controller.rb +17 -0
  8. data/app/models/jwt_session.rb +5 -0
  9. data/lib/generators/shieldify/USAGE +8 -0
  10. data/lib/generators/shieldify/install_generator.rb +52 -0
  11. data/lib/generators/shieldify/templates/initializer.rb.tt +52 -0
  12. data/lib/generators/shieldify/templates/locales/en.shieldify.yml.tt +58 -0
  13. data/lib/generators/shieldify/templates/locales/es.shieldify.yml.tt +48 -0
  14. data/lib/generators/shieldify/templates/mailer_layouts/mailer.html.erb +10 -0
  15. data/lib/generators/shieldify/templates/mailer_layouts/mailer.text.erb +3 -0
  16. data/lib/generators/shieldify/templates/mailer_views/email_changed.html.erb +3 -0
  17. data/lib/generators/shieldify/templates/mailer_views/email_changed.text.erb +5 -0
  18. data/lib/generators/shieldify/templates/mailer_views/email_confirmation_instructions.html.erb +7 -0
  19. data/lib/generators/shieldify/templates/mailer_views/email_confirmation_instructions.text.erb +7 -0
  20. data/lib/generators/shieldify/templates/mailer_views/password_changed.html.erb +3 -0
  21. data/lib/generators/shieldify/templates/mailer_views/password_changed.text.erb +5 -0
  22. data/lib/generators/shieldify/templates/mailer_views/reset_email_password_instructions.html.erb +5 -0
  23. data/lib/generators/shieldify/templates/mailer_views/reset_email_password_instructions.text.erb +9 -0
  24. data/lib/generators/shieldify/templates/mailer_views/unlock_access_instructions.html.erb +4 -0
  25. data/lib/generators/shieldify/templates/mailer_views/unlock_access_instructions.text.erb +7 -0
  26. data/lib/generators/shieldify/templates/migration.rb.tt +28 -0
  27. data/lib/generators/shieldify/templates/model.rb.tt +2 -0
  28. data/lib/shieldify/controllers/helpers.rb +29 -0
  29. data/lib/shieldify/failure_app.rb +8 -0
  30. data/lib/shieldify/jwt_service.rb +158 -0
  31. data/lib/shieldify/mailer.rb +44 -0
  32. data/lib/shieldify/middleware/authentication.rb +27 -0
  33. data/lib/shieldify/middleware.rb +36 -0
  34. data/lib/shieldify/model_extensions.rb +73 -0
  35. data/lib/shieldify/models/email_authenticatable/confirmable.rb +159 -0
  36. data/lib/shieldify/models/email_authenticatable/registerable.rb +117 -0
  37. data/lib/shieldify/models/email_authenticatable.rb +41 -0
  38. data/lib/shieldify/railtie.rb +52 -0
  39. data/lib/shieldify/strategies/email.rb +48 -0
  40. data/lib/shieldify/strategies/jwt.rb +78 -0
  41. data/lib/shieldify/version.rb +3 -0
  42. data/lib/shieldify.rb +74 -0
  43. data/lib/tasks/shieldify_tasks.rake +4 -0
  44. metadata +163 -0
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shieldify
4
+ module Models
5
+ module EmailAuthenticatable
6
+ # This module implements email confirmation using tokens. It generates confirmation tokens
7
+ # when registering or changing a user's email address, enabling confirmation through a secure process.
8
+ # Includes functionality to automatically send confirmation instructions and manage email confirmations.
9
+ # When changing an email, the new one is automatically marked as pending confirmation,
10
+ # requiring the user to verify their access to the new email to complete confirmation.
11
+ # Additionally, it provides methods to check the expiration of confirmation tokens.
12
+ #
13
+ # @example Including the module in a User model
14
+ # class User < ApplicationRecord
15
+ # include Shieldify::Models::EmailAuthenticatable::Confirmable
16
+ # end
17
+ #
18
+ # @see #confirm_email
19
+ # @see #regenerate_and_save_email_confirmation_token
20
+ # @see #send_email_confirmation_instructions
21
+ # @see #email_confirmation_token_expired?
22
+ # @see #pending_email_confirmation?
23
+ # @see #confirmed?
24
+ # @see .confirm_email_by_token
25
+ # @see .resend_email_confirmation_instructions_to
26
+ # @see .add_error_to_empty_user
27
+ module Confirmable
28
+ extend ActiveSupport::Concern
29
+
30
+ included do
31
+ # @!attribute [rw] skip_email_confirmation_callbacks
32
+ # @return [Boolean] Allows selectively skipping email confirmation callbacks.
33
+ attr_accessor :skip_email_confirmation_callbacks
34
+
35
+ before_save :generate_email_confirmation, unless: -> { skip_email_confirmation_callbacks? || !email_changed? }
36
+ after_save(
37
+ :send_email_confirmation_instructions,
38
+ unless: -> { skip_email_confirmation_callbacks? || !unconfirmed_email? }
39
+ )
40
+ end
41
+
42
+ # Confirms the email if there is a pending email confirmation.
43
+ # This method updates the user's email to the unconfirmed email and clears the confirmation tokens.
44
+ #
45
+ # @return [Boolean] true if the email was successfully confirmed, false otherwise
46
+ def confirm_email
47
+ return false unless pending_email_confirmation?
48
+
49
+ self.skip_email_confirmation_callbacks = true
50
+
51
+ self.email = unconfirmed_email
52
+ self.unconfirmed_email = nil
53
+ self.email_confirmation_token = nil
54
+ self.email_confirmation_token_generated_at = nil
55
+
56
+ save do |result|
57
+ self.skip_email_confirmation_callbacks = nil
58
+
59
+ result
60
+ end
61
+ end
62
+
63
+ # Regenerates the email confirmation token and saves the user record.
64
+ #
65
+ # @return [Boolean] true if the token was successfully regenerated and saved, false otherwise
66
+ def regenerate_and_save_email_confirmation_token
67
+ generate_email_confirmation_token && save
68
+ end
69
+
70
+ # Sends email confirmation instructions to the unconfirmed email.
71
+ #
72
+ # @return [void]
73
+ def send_email_confirmation_instructions
74
+ params = { user: self, email_to: unconfirmed_email, token: email_confirmation_token, action: :email_confirmation_instructions }
75
+ Shieldify::Mailer.with(params).base_mailer.deliver_now
76
+ end
77
+
78
+ # Checks if the email confirmation token has expired.
79
+ #
80
+ # @return [Boolean] true if the token has expired, false otherwise
81
+ def email_confirmation_token_expired?
82
+ return true unless email_confirmation_token_generated_at
83
+
84
+ email_confirmation_token_generated_at < 24.hours.ago
85
+ end
86
+
87
+ # Checks if there is a pending email confirmation.
88
+ #
89
+ # @return [Boolean] true if there is a pending email confirmation, false otherwise
90
+ def pending_email_confirmation?
91
+ unconfirmed_email.present? && email_confirmation_token.present?
92
+ end
93
+
94
+ # Checks if the email has been confirmed.
95
+ #
96
+ # @return [Boolean] true if the email is confirmed, false otherwise
97
+ def confirmed?
98
+ email.present?
99
+ end
100
+
101
+ def skip_email_confirmation_callbacks?
102
+ @skip_email_confirmation_callbacks
103
+ end
104
+
105
+ private
106
+
107
+ def generate_email_confirmation
108
+ self.unconfirmed_email = email
109
+ self.email = email_was
110
+
111
+ generate_email_confirmation_token
112
+ end
113
+
114
+ def generate_email_confirmation_token
115
+ self.email_confirmation_token = SecureRandom.hex(16)
116
+ self.email_confirmation_token_generated_at = Time.current
117
+ end
118
+
119
+ class_methods do
120
+ def confirm_email_by_token(token)
121
+ user = find_by_email_confirmation_token(token)
122
+
123
+ return add_error_to_empty_user(:email_confirmation_token, :invalid) if user.blank?
124
+
125
+ if user.email_confirmation_token_expired?
126
+ msg = I18n.t('shieldify.models.email_authenticatable.confirmable.email_confirmation_token.errors.expired')
127
+ user.errors.add(:email_confirmation_token, msg)
128
+
129
+ return user
130
+ end
131
+
132
+ user.confirm_email
133
+ user
134
+ end
135
+
136
+ def resend_email_confirmation_instructions_to(email)
137
+ user = find_by_unconfirmed_email(email)
138
+
139
+ return add_error_to_empty_user(:unconfirmed_email, :not_found) if user.nil?
140
+
141
+ user.regenerate_and_save_email_confirmation_token
142
+ user
143
+ end
144
+
145
+ def add_error_to_empty_user(param, error)
146
+ user = new
147
+
148
+ user.errors.add(
149
+ param.to_sym,
150
+ I18n.t("shieldify.models.email_authenticatable.confirmable.#{param.to_sym}.errors.#{error.to_sym}")
151
+ )
152
+
153
+ user
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shieldify
4
+ module Models
5
+ module EmailAuthenticatable
6
+ # This module provides registration functionality for users, including email normalization,
7
+ # validation of email and password, and methods for registering a user and updating their
8
+ # email and password.
9
+ #
10
+ # @example Including the module in a User model
11
+ # class User < ApplicationRecord
12
+ # include Shieldify::Models::EmailAuthenticatable::Registerable
13
+ # end
14
+ #
15
+ # @see .register
16
+ # @see #update_password
17
+ # @see #update_email
18
+ # @see #send_email_changed_notification
19
+ # @see #send_password_changed_notification
20
+ module Registerable
21
+ extend ActiveSupport::Concern
22
+
23
+ included do
24
+ before_validation :normalize_email
25
+
26
+ validates :email, presence: true, if: -> { password.present? && new_record? }
27
+ validates :email, format: { with: Shieldify::Configuration.email_regexp }, if: -> { email.present? }
28
+ validates :email, uniqueness: true, if: -> { email.present? }
29
+
30
+ validates :password, presence: true, if: -> { email.present? && new_record? }
31
+ validate :password_complexity, if: -> { password.present? }
32
+ validates :password, length: { minimum: 8 }, if: -> { password.present? }
33
+ end
34
+
35
+ class_methods do
36
+ # Registers a new user with the given email and password.
37
+ #
38
+ # @param email [String] The email of the user.
39
+ # @param password [String] The password of the user.
40
+ # @param password_confirmation [String] The password confirmation.
41
+ # @return [User] The newly registered user.
42
+ def register(email:, password:, password_confirmation:)
43
+ user = new(email: email, password: password, password_confirmation: password_confirmation)
44
+ user.save
45
+ user
46
+ end
47
+ end
48
+
49
+ # Updates the user's password if the current password is valid.
50
+ #
51
+ # @param current_password [String] The current password of the user.
52
+ # @param new_password [String] The new password.
53
+ # @param password_confirmation [String] The new password confirmation.
54
+ # @return [User] The user with the updated password, or with errors if the update failed.
55
+ def update_password(current_password:, new_password:, password_confirmation:)
56
+ if authenticate(current_password)
57
+ if update(password: new_password, password_confirmation: password_confirmation)
58
+ send_password_changed_notification if Shieldify::Configuration.send_password_changed_notification
59
+ end
60
+ else
61
+ errors.add(
62
+ :current_password,
63
+ I18n.t("shieldify.models.email_authenticatable.registerable.password.errors.invalid")
64
+ )
65
+ end
66
+
67
+ self
68
+ end
69
+
70
+ # Updates the user's email if the current password is valid.
71
+ #
72
+ # @param current_password [String] The current password of the user.
73
+ # @param new_email [String] The new email.
74
+ # @return [User] The user with the updated email, or with errors if the update failed.
75
+ def update_email(current_password:, new_email:)
76
+ if authenticate(current_password)
77
+ if update(email: new_email)
78
+ send_email_changed_notification if Shieldify::Configuration.send_email_changed_notification
79
+ end
80
+ else
81
+ errors.add(
82
+ :password,
83
+ I18n.t("shieldify.models.email_authenticatable.registerable.password.errors.invalid"))
84
+ end
85
+
86
+ self
87
+ end
88
+
89
+ private
90
+
91
+ def send_email_changed_notification
92
+ Shieldify::Mailer.with(user: self, email_to: email, action: :email_changed).base_mailer.deliver_now
93
+ end
94
+
95
+ def send_password_changed_notification
96
+ Shieldify::Mailer.with(user: self, email_to: email, action: :password_changed).base_mailer.deliver_now
97
+ end
98
+
99
+ def normalize_email
100
+ self.email = email.downcase.strip if email.present?
101
+ end
102
+
103
+ def password_complexity
104
+ return if password.blank?
105
+ regex = Shieldify::Configuration.password_complexity
106
+
107
+ unless password.match?(regex)
108
+ errors.add(
109
+ :password,
110
+ I18n.t("shieldify.models.email_authenticatable.registerable.password_complexity.format")
111
+ )
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shieldify
4
+ module Models
5
+ # This module provides email authentication functionality for users. It includes methods to authenticate
6
+ # users by their email and password, and uses `has_secure_password` for secure password handling.
7
+ #
8
+ # @example Including the module in a User model
9
+ # class User < ApplicationRecord
10
+ # include Shieldify::Models::EmailAuthenticatable
11
+ # end
12
+ #
13
+ # @see .authenticate_by_email
14
+ module EmailAuthenticatable
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ # Adds methods to set and authenticate against a BCrypt password. This mechanism requires you to have a
19
+ # password_digest attribute.
20
+ has_secure_password(validations: false)
21
+ end
22
+
23
+ class_methods do
24
+ # Authenticates a user by their email and password.
25
+ #
26
+ # @param email [String] The email of the user.
27
+ # @param password [String] The password of the user.
28
+ # @return [User] The authenticated user if the credentials are correct, or a new user object with errors if not.
29
+ def authenticate_by_email(email:, password:)
30
+ user = find_by(email: email)
31
+
32
+ return user if user&.authenticate(password)
33
+
34
+ user ||= new
35
+ user.errors.add(:email, "invalid email or password")
36
+ user
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,52 @@
1
+ module Shieldify
2
+ class Railtie < ::Rails::Railtie
3
+ initializer 'shieldify.add_routes' do |app|
4
+ app.routes.prepend do
5
+ get 'shfy/users/email/:token/confirm', to: 'users/emails#show', as: :users_email_confirmation
6
+ # post 'shfy/users/email/reset_password', to: 'users/emails/reset_passwords#create'
7
+ # put 'shfy/users/email/:token/reset_password', to: 'users/emails/reset_passwords#update'
8
+ # get 'shfy/users/access/:token/unlock', to: 'users/access#show'
9
+ end
10
+ end
11
+
12
+ initializer 'shieldify.configure_warden' do |app|
13
+ app.middleware.use Warden::Manager do |manager|
14
+ manager.strategies.add(:email, Shieldify::Strategies::Email)
15
+ manager.strategies.add(:jwt, Shieldify::Strategies::Jwt)
16
+
17
+ manager.default_strategies :email, :jwt
18
+
19
+ manager.default_strategies :email
20
+ manager.scope_defaults :default, store: false
21
+ manager.failure_app = ->(env) { Shieldify::FailureApp.call(env) }
22
+ end
23
+ end
24
+
25
+ initializer 'shieldify.insert_middleware', after: :load_config_initializers do |app|
26
+ app.middleware.insert_after Warden::Manager, Shieldify::Middleware
27
+ end
28
+
29
+ initializer 'shieldify.require' do
30
+ require_relative '../../app/models/jwt_session'
31
+ require_relative '../../app/controllers/users/emails_controller'
32
+ end
33
+
34
+ initializer 'shieldify.active_record' do
35
+ ActiveSupport.on_load(:active_record) do
36
+ include Shieldify::ModelExtensions
37
+ end
38
+ end
39
+
40
+ initializer 'shieldify.action_mailer' do |app|
41
+ ActiveSupport.on_load(:action_mailer) do
42
+ include app.routes.url_helpers
43
+ end
44
+ end
45
+
46
+ initializer 'shieldify.include_controller_helpers' do
47
+ ActiveSupport.on_load(:action_controller_api) do
48
+ include Shieldify::Controllers::Helpers
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,48 @@
1
+ module Shieldify
2
+ module Strategies
3
+ class Email < Warden::Strategies::Base
4
+ def valid?
5
+ json_body['email'].present? && json_body['password'].present?
6
+ end
7
+
8
+ def authenticate!
9
+ user = User.authenticate_by_email(email: json_body['email'], password: json_body['password'])
10
+ return fail!("Unauthorized: #{user.errors.full_messages.join(", ")}") unless user.errors.empty?
11
+
12
+ handle_user_authentication(user)
13
+ end
14
+
15
+ private
16
+
17
+ def json_body
18
+ request.body.rewind
19
+ JSON.parse(request.body.read) rescue {}
20
+ end
21
+
22
+ def handle_user_authentication(user)
23
+ JwtService.encode(user.id) do |success, token, jti, error|
24
+ if success
25
+ process_jwt_token(user, token, jti)
26
+ else
27
+ fail!("JWT token generation failed: #{error}")
28
+ end
29
+ end
30
+ end
31
+
32
+ def process_jwt_token(user, token, jti)
33
+ jwt_token = user.jwt_sessions.create(jti: jti)
34
+ if jwt_token.persisted?
35
+ set_jwt_to_header(token)
36
+ success!(user)
37
+ else
38
+ fail!("Failed to save JWT token: #{jwt_token.errors.full_messages.join(", ")}")
39
+ end
40
+ end
41
+
42
+ def set_jwt_to_header(token)
43
+ env['auth.jwt'] ||= {}
44
+ env['auth.jwt'] = token
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,78 @@
1
+ module Shieldify
2
+ module Strategies
3
+ class Jwt < Warden::Strategies::Base
4
+ def valid?
5
+ request.env['HTTP_AUTHORIZATION'].present?
6
+ end
7
+
8
+ def authenticate!
9
+ token = extract_token_from_header
10
+ return fail!("Authorization token not provided") unless token
11
+
12
+ JwtService.decode(token) do |success, payload, error|
13
+ if success
14
+ authenticate_with_payload(payload)
15
+ else
16
+ fail!("Invalid token: #{error}")
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def extract_token_from_header
24
+ request.env['HTTP_AUTHORIZATION']&.split(' ')&.last
25
+ end
26
+
27
+ def authenticate_with_payload(payload)
28
+ user = find_user(payload['sub'])
29
+ return unless user
30
+
31
+ jwt_session = find_jwt_session(user, payload['jti'])
32
+ return unless jwt_session
33
+
34
+ if token_expired_halfway?(payload['exp'])
35
+ refresh_token(user, jwt_session)
36
+ else
37
+ success!(user)
38
+ end
39
+ end
40
+
41
+ def find_user(user_id)
42
+ user = User.find_by(id: user_id)
43
+ fail!("Invalid token: user not found") unless user
44
+ user
45
+ end
46
+
47
+ def find_jwt_session(user, jti)
48
+ jwt_session = user.jwt_sessions.find_by(jti: jti)
49
+ fail!("Invalid token: session not found") unless jwt_session
50
+ jwt_session
51
+ end
52
+
53
+ def token_expired_halfway?(exp)
54
+ halfway_time = exp - (exp - Time.now.to_i) / 2
55
+ Time.now.to_i >= halfway_time
56
+ end
57
+
58
+ def refresh_token(user, jwt_session)
59
+ JwtService.encode(user.id) do |success, new_token, new_jti, error|
60
+ if success
61
+ jwt_session.update!(jti: new_jti)
62
+ set_jwt_to_header(new_token)
63
+ success!(user)
64
+ else
65
+ fail!("JWT token generation failed: #{error}")
66
+ end
67
+ end
68
+ end
69
+
70
+ def set_jwt_to_header(token)
71
+ env['auth.jwt'] ||= {}
72
+ env['auth.jwt'] = token
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+
@@ -0,0 +1,3 @@
1
+ module Shieldify
2
+ VERSION = "0.1.2-alpha"
3
+ end
data/lib/shieldify.rb ADDED
@@ -0,0 +1,74 @@
1
+ require "warden"
2
+ require "shieldify/failure_app"
3
+ require "shieldify/model_extensions"
4
+ require "shieldify/controllers/helpers"
5
+ require "shieldify/strategies/email"
6
+ require "shieldify/strategies/jwt"
7
+ require "shieldify/middleware/authentication"
8
+ require "shieldify/middleware"
9
+ require "shieldify/version"
10
+ require "shieldify/railtie"
11
+
12
+ module Shieldify
13
+ class Configuration
14
+ include Singleton
15
+
16
+ # Default mailer sender.
17
+ mattr_accessor :mailer_sender
18
+ @@mailer_sender = "shieldify@example.com"
19
+
20
+ # Default mailer sender.
21
+ mattr_accessor :reply_to
22
+ @@reply_to = "shieldify@example.com"
23
+
24
+ # Email regex used to validate email formats.
25
+ mattr_accessor :email_regexp
26
+ @@email_regexp = URI::MailTo::EMAIL_REGEXP
27
+
28
+ # Password complexity regex
29
+ # Explanation of the regular expression:
30
+ # - (?=.*\d) ensures there is at least one digit
31
+ # - (?=.*[a-z]) ensures there is at least one lowercase letter
32
+ # - (?=.*[A-Z]) ensures there is at least one uppercase letter
33
+ # - (?=.*[@$#!%*?&]) ensures there is at least one of these special characters
34
+ # - \S{8,} ensures the password is at least 8 characters in length and contains no spaces
35
+ mattr_accessor :password_complexity
36
+ @@password_complexity = /\A(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[@$#!%*?&])\S{6,}\z/
37
+
38
+ # Used to send notification to the original user email when their email is changed.
39
+ mattr_accessor :send_email_changed_notification
40
+ @@send_email_changed_notification = true
41
+
42
+ # Used to enable sending notification to user when their password is changed.
43
+ mattr_accessor :send_password_changed_notification
44
+ @@send_password_changed_notification = true
45
+
46
+ # Number of authentication tries before locking an account.
47
+ mattr_accessor :maximum_attempts
48
+ @@maximum_attempts = 20
49
+
50
+ # The parent mailer for internal mailers.
51
+ mattr_accessor :parent_mailer
52
+ @@parent_mailer = "ActionMailer::Base"
53
+
54
+ # JWT related
55
+ mattr_accessor :jwt_secret
56
+ @@jwt_secret = "whatever"
57
+
58
+ mattr_accessor :jwt_issuer
59
+ @@jwt_issuer = "Shieldify"
60
+
61
+ mattr_accessor :jwt_exp
62
+ @@jwt_exp = 24.hours.from_now.to_i
63
+ end
64
+
65
+ def self.setup
66
+ yield Configuration.instance
67
+ end
68
+ end
69
+
70
+ require "shieldify/models/email_authenticatable"
71
+ require "shieldify/models/email_authenticatable/registerable"
72
+ require "shieldify/models/email_authenticatable/confirmable"
73
+ require "shieldify/jwt_service"
74
+ require "shieldify/mailer"
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :shieldify do
3
+ # # Task goes here
4
+ # end