shieldify 0.1.2.pre.alpha

Sign up to get free protection for your applications and to get access to all the features.
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