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,48 @@
1
+ es:
2
+ shieldify:
3
+ models:
4
+ email_authenticatable:
5
+ confirmable:
6
+ email_confirmation_token:
7
+ errors:
8
+ invalid: "inválido"
9
+ expired: "ha expirado"
10
+ unconfirmed_email:
11
+ errors:
12
+ not_found: "no encontrado"
13
+ email_confirmation_token_generated_at:
14
+ mailer:
15
+ email_confirmation_instructions:
16
+ subject: "Instrucciones de Confirmación de Email"
17
+ title: "Instrucciones de Confirmación de Email"
18
+ greeting: "Hola %{email},"
19
+ thanks: "Por favor confirma tu email haciendo clic en el siguiente enlace:"
20
+ confirm_account: "Confirmar email"
21
+ ignore: "Si no has solicitado esta confirmación, por favor ignora este correo."
22
+ reset_password_instructions:
23
+ subject: "Instrucciones para Restablecer Contraseña"
24
+ title: "Instrucciones para Restablecer Contraseña"
25
+ greeting: "Hola %{email},"
26
+ instructions: "Alguien ha solicitado un enlace para cambiar tu contraseña. Puedes hacerlo a través del enlace de abajo:"
27
+ change_password: "Cambiar mi contraseña"
28
+ ignore: "Si no solicitaste esto, por favor ignora este correo. Tu contraseña no cambiará."
29
+ link_expiration: "Este enlace expirará en %{expiration_hours} horas."
30
+ unlock_instructions:
31
+ subject: "Instrucciones para Desbloquear Cuenta"
32
+ title: "Instrucciones para Desbloquear Cuenta"
33
+ greeting: "Hola %{email},"
34
+ instructions: "Tu cuenta ha sido bloqueada debido a un número excesivo de intentos de inicio de sesión fallidos. Por favor, desbloquea tu cuenta haciendo clic en el siguiente enlace:"
35
+ unlock_account: "Desbloquear Cuenta"
36
+ ignore: "Si no has solicitado esto, por favor ignora este correo. Tu cuenta permanecerá segura."
37
+ email_changed:
38
+ subject: "Correo Electrónico Actualizado"
39
+ title: "Correo Electrónico Actualizado"
40
+ greeting: "Hola %{email},"
41
+ message: "Recibimos una solicitud para cambiar la dirección de correo electrónico asociada a tu cuenta a %{unconfirmed_email}."
42
+ ignore: "Si no hiciste este cambio, por favor contacta a soporte inmediatamente."
43
+ password_changed:
44
+ subject: "Contraseña Actualizada"
45
+ title: "Contraseña Actualizada"
46
+ greeting: "Hola %{email},"
47
+ message: "Esta es una notificación de que tu contraseña ha sido cambiada exitosamente."
48
+ advice: "Si no realizaste este cambio, por favor contacta a nuestro equipo de soporte inmediatamente."
@@ -0,0 +1,10 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= t('shieldify.mailer.confirmation_instructions.title') %></title>
5
+ <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
6
+ </head>
7
+ <body>
8
+ <%= yield %>
9
+ </body>
10
+ </html>
@@ -0,0 +1,3 @@
1
+ <%= t('shieldify.mailer.email_changed.title') %>
2
+
3
+ <%= yield %>
@@ -0,0 +1,3 @@
1
+ <h1><%= t('shieldify.mailer.email_changed.greeting', email: @user.email) %></h1>
2
+ <p><%= t('shieldify.mailer.email_changed.message', unconfirmed_email: @user.unconfirmed_email) %></p>
3
+ <p><%= t('shieldify.mailer.email_changed.ignore') %></p>
@@ -0,0 +1,5 @@
1
+ <%= t('shieldify.mailer.email_changed.greeting', email: @user.email) %>
2
+
3
+ <%= t('shieldify.mailer.email_changed.message', unconfirmed_email: @user.unconfirmed_email) %>
4
+
5
+ <%= t('shieldify.mailer.email_changed.ignore') %>
@@ -0,0 +1,7 @@
1
+ <h1><%= t('shieldify.mailer.email_confirmation_instructions.greeting', email: @user.unconfirmed_email) %></h1>
2
+
3
+ <p><%= t('shieldify.mailer.email_confirmation_instructions.thanks') %></p>
4
+
5
+ <p><%= link_to t('shieldify.mailer.email_confirmation_instructions.confirm_account'), users_email_confirmation_url(token: @token) %></p>
6
+
7
+ <p><%= t('shieldify.mailer.email_confirmation_instructions.ignore') %></p>
@@ -0,0 +1,7 @@
1
+ <%= t('shieldify.mailer.email_confirmation_instructions.greeting', email: @user.unconfirmed_email) %>
2
+
3
+ <%= t('shieldify.mailer.email_confirmation_instructions.thanks') %>
4
+
5
+ <%= users_email_confirmation_url(token: @token) %>
6
+
7
+ <%= t('shieldify.mailer.email_confirmation_instructions.ignore') %>
@@ -0,0 +1,3 @@
1
+ <h1><%= t('shieldify.mailer.password_changed.greeting', email: @user.email) %></h1>
2
+ <p><%= t('shieldify.mailer.password_changed.message') %></p>
3
+ <p><%= t('shieldify.mailer.password_changed.advice') %></p>
@@ -0,0 +1,5 @@
1
+ <%= t('shieldify.mailer.password_changed.greeting', email: @user.email) %>
2
+
3
+ <%= t('shieldify.mailer.password_changed.message') %>
4
+
5
+ <%= t('shieldify.mailer.password_changed.advice') %>
@@ -0,0 +1,5 @@
1
+ <h1><%= t('shieldify.mailer.reset_password_instructions.greeting', name: @user.name) %></h1>
2
+ <p><%= t('shieldify.mailer.reset_password_instructions.instructions') %></p>
3
+ <p><%= link_to t('shieldify.mailer.reset_password_instructions.change_password'), edit_password_url(@user.reset_password_token) %></p>
4
+ <p><%= t('shieldify.mailer.reset_password_instructions.link_expiration', expiration_hours: 24) %></p>
5
+ <p><%= t('shieldify.mailer.reset_password_instructions.ignore') %></p>
@@ -0,0 +1,9 @@
1
+ <%= t('shieldify.mailer.reset_password_instructions.greeting', name: @user.name) %>
2
+
3
+ <%= t('shieldify.mailer.reset_password_instructions.instructions') %>
4
+
5
+ <%= edit_password_url(@user.reset_password_token) %>
6
+
7
+ <%= t('shieldify.mailer.reset_password_instructions.link_expiration', expiration_hours: 24) %>
8
+
9
+ <%= t('shieldify.mailer.reset_password_instructions.ignore') %>
@@ -0,0 +1,4 @@
1
+ <h1><%= t('shieldify.mailer.unlock_instructions.greeting', name: @user.name) %></h1>
2
+ <p><%= t('shieldify.mailer.unlock_instructions.instructions') %></p>
3
+ <p><%= link_to t('shieldify.mailer.unlock_instructions.unlock_account'), unlock_url(@user.unlock_token) %></p>
4
+ <p><%= t('shieldify.mailer.unlock_instructions.ignore') %></p>
@@ -0,0 +1,7 @@
1
+ <%= t('shieldify.mailer.unlock_instructions.greeting', name: @user.name) %>
2
+
3
+ <%= t('shieldify.mailer.unlock_instructions.instructions') %>
4
+
5
+ <%= unlock_url(@user.unlock_token) %>
6
+
7
+ <%= t('shieldify.mailer.unlock_instructions.ignore') %>
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ShieldifyCreateUsers < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :users do |t|
6
+ ## Email registerable
7
+ t.string :email, default: ""
8
+ t.string :password_digest, default: ""
9
+
10
+ ## Email confirmable
11
+ t.string :unconfirmed_email
12
+ t.string :email_confirmation_token
13
+ t.string :email_confirmation_token_generated_at
14
+
15
+ t.timestamps null: false
16
+ end
17
+
18
+ create_table :jwt_sessions do |t|
19
+ t.string :jti, null: false
20
+ t.references :user, null: false, foreign_key: true
21
+
22
+ t.timestamps
23
+ end
24
+
25
+ add_index :users, :email, unique: true
26
+ add_index :jwt_sessions, :jti, unique: true
27
+ end
28
+ end
@@ -0,0 +1,2 @@
1
+ class User < ApplicationRecord
2
+ end
@@ -0,0 +1,29 @@
1
+ module Shieldify
2
+ module Controllers
3
+ module Helpers
4
+ def current_user
5
+ warden.user
6
+ end
7
+
8
+ def user_signed_in?
9
+ !!current_user
10
+ end
11
+
12
+ def authenticate_user!
13
+ unless user_signed_in?
14
+ respond_to_unauthorized
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def warden
21
+ request.env['warden']
22
+ end
23
+
24
+ def respond_to_unauthorized
25
+ render json: { error: 'No autorizado' }, status: :unauthorized
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,8 @@
1
+ module Shieldify
2
+ class FailureApp
3
+ def self.call(env)
4
+ error_message = env['warden.options'][:message] || 'Unauthorized'
5
+ [401, { 'Content-Type' => 'application/json' }, [{ error: error_message }.to_json]]
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,158 @@
1
+ require 'jwt'
2
+
3
+ class JwtService
4
+ @secret_key = Shieldify::Configuration.jwt_secret
5
+ @issuer = Shieldify::Configuration.jwt_issuer
6
+ @jwt_exp = Shieldify::Configuration.jwt_exp
7
+
8
+ class << self
9
+ # Generates a new JWT token for a user using their unique identifier.
10
+ #
11
+ # @param user_id [Integer] The unique identifier for the user for whom the token is being generated.
12
+ #
13
+ # This method constructs a JWT payload containing several fields including the user's ID, the token's
14
+ # expiration time, and other standard JWT claims. The JWT is then encoded using the HS256 algorithm
15
+ # with a secret key stored in the service's configuration. The method handles encoding issues and returns
16
+ # a structured array containing the results of the token generation process.
17
+ #
18
+ # The method can be called with or without a block:
19
+ #
20
+ # Without a block, it returns an array with four elements:
21
+ # - [Boolean] `result`: true if the token is successfully generated, false if an error occurred.
22
+ # - [String, nil] `token`: the generated JWT token if successful, otherwise nil.
23
+ # - [String, nil] `jti`: the unique JWT ID if successful, otherwise nil.
24
+ # - [String, nil] `errors`: description of the error if token generation failed, otherwise nil.
25
+ #
26
+ # With a block, the block is yielded with the same four elements, allowing for inline handling:
27
+ #
28
+ # Usage Examples:
29
+ #
30
+ # Without a block:
31
+ # success, token, jti, error = JwtService.encode(user_id)
32
+ # if success
33
+ # puts "JWT generated successfully: #{token}, JTI: #{jti}"
34
+ # else
35
+ # puts "Error: #{error}"
36
+ # end
37
+ #
38
+ # With a block:
39
+ # JwtService.encode(user_id) do |success, token, jti, error|
40
+ # if success
41
+ # puts "JWT generated successfully: #{token}, JTI: #{jti}"
42
+ # else
43
+ # puts "Error: #{error}"
44
+ # end
45
+ # end
46
+ #
47
+ # This method provides a reliable way to generate JWTs with a standard set of claims and handles
48
+ # any exceptions that may occur during the encoding process.
49
+ def encode(user_id)
50
+ begin
51
+ payload = jwt_payload(user_id)
52
+ jti = payload[:jti]
53
+ token = JWT.encode(payload, secret_key, 'HS256')
54
+ result = [true, token, jti, nil]
55
+ rescue StandardError => e
56
+ result = [false, nil, nil, e.message]
57
+ end
58
+
59
+ if block_given?
60
+ yield(*result)
61
+ else
62
+ result
63
+ end
64
+ end
65
+
66
+ # Decodes a JWT token to verify its authenticity and check if it is still valid.
67
+ #
68
+ # @param token [String] The JWT token to be decoded.
69
+ #
70
+ # This method uses JWT.decode to attempt to decode the token using a predefined secret key and issuer.
71
+ # It handles various errors that might occur during the decoding process, such as expiration or incorrect
72
+ # formatting of the token. It encapsulates the results and errors into a structured array format.
73
+ #
74
+ # The method can be called with or without a block:
75
+ #
76
+ # Without a block, it returns an array with three elements:
77
+ # - [Boolean] `result`: true if the token is successfully decoded, false if an error occurred.
78
+ # - [Hash, nil] `payload`: the decoded payload of the token if successful, otherwise nil.
79
+ # - [String, nil] `errors`: description of the error if decoding failed, otherwise nil.
80
+ #
81
+ # With a block, the block is yielded with the same three elements, allowing for inline handling:
82
+ #
83
+ # Usage Examples:
84
+ #
85
+ # Without a block:
86
+ # success, payload, error = JwtService.decode(token)
87
+ # if success
88
+ # puts "Decoded Payload: #{payload}"
89
+ # else
90
+ # puts "Error: #{error}"
91
+ # end
92
+ #
93
+ # With a block:
94
+ # JwtService.decode(token) do |success, payload, error|
95
+ # if success
96
+ # puts "Decoded Payload: #{payload}"
97
+ # else
98
+ # puts "Error: #{error}"
99
+ # end
100
+ # end
101
+ #
102
+ # This method ensures robust handling of JWTs by validating their integrity and relevance,
103
+ # adhering to the security settings defined by the secret_key and issuer configuration.
104
+ def decode(token)
105
+ decoded_token = JWT.decode(token, secret_key, true, decode_options).first
106
+ result = [true, decoded_token, nil] # result: true (success), payload: decoded_token, errors: nil
107
+ rescue *jwt_exceptions => e
108
+ result = [false, nil, e.message]
109
+ rescue => e
110
+ result = [false, nil, "Unexpected error: #{e.message}"]
111
+ ensure
112
+ if block_given?
113
+ yield(*result)
114
+ else
115
+ result
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def decode_options
122
+ { algorithm: 'HS256', verify_iss: true, iss: issuer, verify_expiration: true }
123
+ end
124
+
125
+ def jwt_payload(user_id)
126
+ {
127
+ sub: user_id,
128
+ exp: jwt_exp,
129
+ nbf: Time.now.to_i,
130
+ iss: issuer,
131
+ jti: SecureRandom.hex,
132
+ iat: Time.now.to_i
133
+ }
134
+ end
135
+
136
+ def jwt_exceptions
137
+ [
138
+ JWT::ExpiredSignature,
139
+ JWT::InvalidIssuerError,
140
+ JWT::DecodeError,
141
+ JWT::VerificationError,
142
+ JWT::InvalidIatError
143
+ ]
144
+ end
145
+
146
+ def secret_key
147
+ @secret_key
148
+ end
149
+
150
+ def issuer
151
+ @issuer
152
+ end
153
+
154
+ def jwt_exp
155
+ @jwt_exp
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shieldify
4
+ class Mailer < Shieldify::Configuration.parent_mailer.constantize
5
+ layout 'layouts/shieldify/mailer'
6
+
7
+ default(
8
+ from: Shieldify::Configuration.mailer_sender,
9
+ reply_to: Shieldify::Configuration.reply_to
10
+ )
11
+
12
+ def base_mailer
13
+ initialize_email_resources(params)
14
+
15
+ mail(define_headers)
16
+ end
17
+
18
+ private
19
+
20
+ def define_headers
21
+ headers = {
22
+ to: email_to,
23
+ subject: define_subject,
24
+ template_path: "shieldify/mailer",
25
+ template_name: action
26
+ }
27
+
28
+ headers.store(:reply_to, reply_to) if instance_variable_defined?(:@reply_to)
29
+ headers.store(:from, from) if instance_variable_defined?(:@from)
30
+ headers
31
+ end
32
+
33
+ def initialize_email_resources(options)
34
+ options.each do |key, value|
35
+ self.class.send(:attr_accessor, key)
36
+ instance_variable_set("@#{key}", value)
37
+ end
38
+ end
39
+
40
+ def define_subject
41
+ I18n.t("shieldify.mailer.#{action}.subject")
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,27 @@
1
+ module Shieldify
2
+ class Middleware
3
+ class Authentication
4
+ attr_reader :app
5
+
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ env['warden'].authenticate!(:email, :jwt)
12
+
13
+ status, headers, response = app.call(env)
14
+ headers = headers_with_token(env, headers)
15
+ [status, headers, response]
16
+ end
17
+
18
+ private
19
+
20
+ def headers_with_token(env, headers)
21
+ token = env["auth.jwt"]
22
+ headers['Authorization'] = "Bearer #{token}"
23
+ headers
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ module Shieldify
2
+ class Middleware
3
+ attr_reader :app, :request
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ @request = Rack::Request.new(env)
11
+
12
+ if should_authenticate?
13
+ builder = Rack::Builder.new
14
+ builder.use(Shieldify::Middleware::Authentication)
15
+ builder.run(app)
16
+ builder.call(env)
17
+ else
18
+ app.call(env)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def should_authenticate?
25
+ should_authenticate_by_email? || should_authenticate_by_jwt?
26
+ end
27
+
28
+ def should_authenticate_by_email?
29
+ request.path.end_with?('/shfy/login')
30
+ end
31
+
32
+ def should_authenticate_by_jwt?
33
+ request.env['HTTP_AUTHORIZATION'].present?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shieldify
4
+ # This module provides extensions for models to include Shieldify functionality.
5
+ # It allows dynamic inclusion of modules and submodules for various authentication features.
6
+ #
7
+ # The primary purpose of this module is to simplify the process of adding authentication
8
+ # capabilities to models in a Rails application. By including this module,
9
+ # developers can easily integrate functionalities such as email-based authentication,
10
+ # user registration, email confirmation, and more, depending on the modules and submodules
11
+ # specified.
12
+ #
13
+ # This module uses ActiveSupport::Concern to extend the including model with class methods
14
+ # and associations necessary for managing JWT sessions and other authentication-related tasks.
15
+ #
16
+ # @example Including the module in a User model
17
+ # # To use this module, include it in your model (typically a User model).
18
+ # # Then, use the `shieldify` method to specify the desired modules and submodules.
19
+ # # In this example, the User model is being extended with email authentication,
20
+ # # and two submodules: registerable and confirmable.
21
+ # class User < ApplicationRecord
22
+ # include Shieldify::ModelExtensions
23
+ #
24
+ # # The `shieldify` method is called with a hash where the key is the main module
25
+ # # (:email_authenticatable) and the value is an array of submodules
26
+ # # (%i[registerable confirmable]).
27
+ # # This will dynamically include the corresponding modules and submodules
28
+ # # from the Shieldify::Models namespace into the User model.
29
+ # shieldify email_authenticatable: %i[registerable confirmable]
30
+ # end
31
+ #
32
+ # @see Shieldify::ModelExtensions#shieldify
33
+ module ModelExtensions
34
+ extend ActiveSupport::Concern
35
+
36
+ class_methods do
37
+ # Dynamically includes modules. Accepts a hash where the keys are symbols
38
+ # of main modules and the values are arrays of submodules to include.
39
+ #
40
+ # @example
41
+ # shieldify email_authenticatable: %i[registerable confirmable]
42
+ #
43
+ # This method is intended to be used within a user class or any other class
44
+ # that acts as a user.
45
+ #
46
+ # @param modules [Hash<Symbol, Array<Symbol>>] A hash where the keys are main modules and the values are arrays of submodules to include.
47
+ # @return [void]
48
+ def shieldify(modules)
49
+ modules.each do |parent, submodules|
50
+ include_parent_module(parent)
51
+
52
+ submodules.each do |submodule|
53
+ include_submodule(parent, submodule)
54
+ end
55
+ end
56
+
57
+ has_many :jwt_sessions, dependent: :destroy
58
+ end
59
+
60
+ private
61
+
62
+ # Includes the parent module based on the provided symbol
63
+ def include_parent_module(parent_module)
64
+ include "Shieldify::Models::#{parent_module.to_s.camelize}".constantize
65
+ end
66
+
67
+ # Includes a specific submodule within a parent module
68
+ def include_submodule(parent_module, submodule)
69
+ include "Shieldify::Models::#{parent_module.to_s.camelize}::#{submodule.to_s.camelize}".constantize
70
+ end
71
+ end
72
+ end
73
+ end