securial 0.4.2 → 0.5.0

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +56 -11
  3. data/app/controllers/concerns/securial/identity.rb +3 -3
  4. data/app/models/securial/session.rb +2 -2
  5. data/app/views/securial/roles/_securial_role.json.jbuilder +1 -2
  6. data/app/views/securial/sessions/_session.json.jbuilder +1 -2
  7. data/app/views/securial/shared/_timestamps.json.jbuilder +8 -0
  8. data/app/views/securial/users/_securial_user.json.jbuilder +1 -2
  9. data/lib/generators/securial/install/templates/securial_initializer.erb +11 -0
  10. data/lib/generators/securial/jbuilder/templates/_resource.json.erb +1 -2
  11. data/lib/securial/config/_index.rb +3 -0
  12. data/lib/securial/config/configuration.rb +42 -0
  13. data/lib/securial/config/errors.rb +19 -0
  14. data/lib/securial/config/validation.rb +179 -0
  15. data/lib/securial/engine.rb +10 -11
  16. data/lib/securial/helpers/_index.rb +2 -0
  17. data/lib/securial/helpers/regex_helper.rb +7 -7
  18. data/lib/securial/inspectors/_index.rb +1 -0
  19. data/lib/securial/inspectors/route_inspector.rb +52 -0
  20. data/lib/securial/logger.rb +2 -2
  21. data/lib/securial/sessions/_index.rb +2 -0
  22. data/lib/securial/sessions/errors.rb +15 -0
  23. data/lib/securial/sessions/session_encoder.rb +56 -0
  24. data/lib/securial/version.rb +1 -1
  25. data/lib/securial.rb +1 -71
  26. metadata +14 -11
  27. data/app/views/securial/passwords/_password.json.jbuilder +0 -2
  28. data/app/views/securial/passwords/index.json.jbuilder +0 -1
  29. data/app/views/securial/passwords/show.json.jbuilder +0 -1
  30. data/lib/securial/configuration.rb +0 -35
  31. data/lib/securial/errors/config_errors.rb +0 -12
  32. data/lib/securial/errors/session_errors.rb +0 -6
  33. data/lib/securial/helpers/auth_helper.rb +0 -46
  34. data/lib/securial/route_inspector.rb +0 -50
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e0abc0d4c0a4b2a56220ab298edd28f767b4d8909a6af78f3097421cfc2fdda
4
- data.tar.gz: 9230d0f2c93d1899cb642206adf79ceec04936894720dc989cc2b25c873f8774
3
+ metadata.gz: a67cb5167555d544a8787d5731542215aa5d43a50955cbb4a0a929d9df39ed08
4
+ data.tar.gz: 6ee38fc5624167b7cf6d9307dc07224e0762a163145df864247350c523b09a5a
5
5
  SHA512:
6
- metadata.gz: 04102e38f339f980e2792c6dcc0559ef68a48506c31fc6b6625f44f7cc8f0a668d32c7e42583dfb7abe2d45690ca3e0df9ed9acb916f5c1630c651966413f8b2
7
- data.tar.gz: 7db35473e9c983e9c6f803889da638ac210795ea8a9bc1270efd8136fc6a7c293ac01b7b8ca53b43fa695b33e872884e215a0496763222a0159a8c0dd5efe3d0
6
+ metadata.gz: a85b1cd94066087eab59f7c883e4b822a16e91e03a2df80e4100f7a81cc25712427d35719bb90dbf509c028081bbd215436d097e2056e054e13433fc1b97d9ef
7
+ data.tar.gz: a39f8dfd001c239fc2f5612e9777f587d688c93de8070656fe7e8fa38a3610463ce18502bef35b12d75c45eb9ee5f27608f8a25d2dc00aa9394f2f8dd5a7bc38
data/README.md CHANGED
@@ -19,15 +19,55 @@
19
19
  - ✅ Clean, JSON-based API responses
20
20
  - ✅ Database-agnostic support
21
21
 
22
- Next, mount the engine in `config/routes.rb`:
23
22
 
24
- ```ruby
25
- Rails.application.routes.draw do
26
- mount Securial::Engine => "/securial"
27
- end
28
- ```
23
+ ### 🚀 Why Choose Securial?
24
+
25
+ Securial isn't just another auth library — it's designed to give you control, flexibility, and peace of mind when building secure Rails APIs.
26
+
27
+ **🔧 Built for Developers**
28
+ - Easy to mount and extend using familiar Rails conventions.
29
+ - Fully customizable controllers, serializers, and logic — no more black-box auth.
30
+
31
+ **🧩 Modular by Design**
32
+ - Use only the components you need: JWT, sessions, API tokens — or all of them together.
33
+ - Clean separation of concerns makes testing and debugging simpler.
34
+
35
+ **⚡ API-First Approach**
36
+ - JSON-only responses make Securial ideal for frontend frameworks and mobile apps.
37
+ - No HTML views or form helpers — just clean endpoints that work out of the box.
38
+
39
+ **🛡️ Secure by Default**
40
+ - Uses industry best practices for token management and access control.
41
+ - No reliance on client-side sessions or cookies.
42
+
43
+ **📦 Lightweight, Database-Agnostic**
44
+ - No assumptions about your schema or ORM — works with any relational database.
45
+ - Minimal dependencies, fast to integrate.
46
+
47
+ **🌱 Ready to Grow With You**
48
+ - Start small with basic JWT auth, scale to multi-token API clients, admin scopes, or full RBAC.
49
+ - Perfect for startups, side projects, and production APIs alike.
29
50
 
30
- Full installation steps are available in the [Wiki › Installation](https://github.com/AlyBadawy/Securial/wiki/Installation).
51
+ ## 🚀 Installation
52
+
53
+ Securial can be installed on an existing Rails application or use the `securial new app_name` command to create a new Securial-ready Rails app.
54
+
55
+ ### Installation on an existing Rails app:
56
+
57
+ To add Securial to an existing Rails app:
58
+
59
+ - Add `gem "securial"` to your GemFile
60
+ - Run `bundle install`
61
+ - Run `rails generate securial:install`
62
+ - Mount the Securial engine in your Rails application `config/routes.rb` file:
63
+ ```ruby
64
+ Rails.application.routes.draw do
65
+ mount Securial::Engine => "/securial"
66
+ end
67
+ ```
68
+ - Run the migrations by running the command: `rails db:migrate`
69
+
70
+ 💡 Full installation steps are available in the [Wiki › Installation](https://github.com/AlyBadawy/Securial/wiki/Installation).
31
71
 
32
72
  ## ⚙️ Configuration
33
73
 
@@ -49,24 +89,29 @@ After installation and mounting, **Securial** exposes endpoints like:
49
89
 
50
90
  Full details, including authentication flows and protected routes, are available in the [Wiki › Authentication module docs](https://github.com/AlyBadawy/Securial/wiki/Authentication).
51
91
 
52
- 🧩 Modules
92
+ ## 🧩 Modules
53
93
 
54
94
  **Securial** is organized into modular components including:
55
95
 
56
96
  - Authentication
57
97
  - User Management
58
98
  - Generators
59
- - Securial::Identity concern
99
+ - Identity concern
100
+ - Configuration
60
101
 
61
102
  Explore all modules in the [Wiki](https://github.com/AlyBadawy/Securial/wiki).
62
103
 
63
104
  ## 🛠 Development & Testing
64
105
 
106
+ - Clone the repo on your computer
107
+ - Run `bundle install`
108
+ - Start coding right away 🏃‍♂️
109
+
110
+
65
111
  To run the test suite:
66
112
 
67
113
  ```bash
68
- $ RAILS_ENV=test bundle db:schema:load
69
- $ bundle exec rspec
114
+ $ bin/test
70
115
  ```
71
116
 
72
117
  View the coverage report:
@@ -32,9 +32,9 @@ module Securial
32
32
  if auth_header.present? && auth_header.start_with?("Bearer ")
33
33
  token = auth_header.split(" ").last
34
34
  begin
35
- decoded_token = AuthHelper.decode(token)
35
+ decoded_token = Securial::Sessions::SessionEncoder.decode(token)
36
36
  Current.session = Session.find_by!(id: decoded_token["jti"], revoked: false)
37
- rescue JWT::DecodeError, ActiveRecord::RecordNotFound => e
37
+ rescue Securial::Sessions::Errors::SessionDecodeError, ActiveRecord::RecordNotFound => e
38
38
  render status: :unauthorized, json: { error: "Invalid token: #{e.message}" } and return
39
39
  end
40
40
  else
@@ -55,7 +55,7 @@ module Securial
55
55
  end
56
56
 
57
57
  def create_jwt_for_current_session
58
- AuthHelper.encode(Current.session)
58
+ Securial::Sessions::SessionEncoder.encode(Current.session)
59
59
  end
60
60
 
61
61
  def internal_rails_request?
@@ -15,8 +15,8 @@ module Securial
15
15
  end
16
16
 
17
17
  def refresh!
18
- raise Securial::SessionErrors::SessionRevokedError, "Session is revoked" if revoked
19
- raise Securial::SessionErrors::SessionExpiredError, "Session is expired" if refresh_token_expires_at < Time.current
18
+ raise Securial::Sessions::Errors::SessionRevokedError, "Session is revoked" if revoked
19
+ raise Securial::Sessions::Errors::SessionExpiredError, "Session is expired" if refresh_token_expires_at < Time.current
20
20
 
21
21
  update!(refresh_token: SecureRandom.hex(64),
22
22
  refresh_count: self.refresh_count + 1,
@@ -3,7 +3,6 @@ json.id securial_role.id
3
3
  json.role_name securial_role.role_name
4
4
  json.hide_from_profile securial_role.hide_from_profile
5
5
 
6
- json.created_at securial_role.created_at
7
- json.updated_at securial_role.updated_at
6
+ json.partial! "securial/shared/timestamps", record: securial_role
8
7
 
9
8
  json.url securial.roles_url(securial_role, format: :json)
@@ -9,7 +9,6 @@ json.refresh_token_expires_at securial_session.refresh_token_expires_at
9
9
  json.revoked securial_session.revoked
10
10
  json.user_id securial_session.user_id
11
11
 
12
- json.created_at securial_session.created_at
13
- json.updated_at securial_session.updated_at
12
+ json.partial! "securial/shared/timestamps", record: securial_session
14
13
 
15
14
  json.url "No URL available for this action"
@@ -0,0 +1,8 @@
1
+ if Securial.configuration.timestamps_in_response == :all ||
2
+ (
3
+ Securial.configuration.timestamps_in_response == :admins_only &&
4
+ current_user&.admin?
5
+ )
6
+ json.created_at record.created_at if record.respond_to?(:created_at)
7
+ json.updated_at record.updated_at if record.respond_to?(:updated_at)
8
+ end
@@ -8,7 +8,6 @@ json.bio securial_user.bio
8
8
 
9
9
  json.roles securial_user.roles, partial: "securial/roles/securial_role", as: :securial_role
10
10
 
11
- json.created_at securial_user.created_at
12
- json.updated_at securial_user.updated_at
11
+ json.partial! "securial/shared/timestamps", record: securial_user
13
12
 
14
13
  json.url securial.user_url(securial_user, format: :json)
@@ -106,4 +106,15 @@ Securial.configure do |config|
106
106
  # that they cannot be tampered with. It is important to keep this
107
107
  # secret secure and not share it with anyone.
108
108
  config.reset_password_token_secret = "reset_secret"
109
+
110
+ ### Timestamp Configuration
111
+ ## Set whether to use timestamps in the json responses.
112
+ # The options are:
113
+ # :none - no timestamps will be included in the json responses.
114
+ # :admins_only - the created_at and updated_at timestamps will be included
115
+ # for admin users. This is useful for keeping the json responses clean
116
+ # for regular users while still providing timestamps for admin users.
117
+ # :all - the created_at and updated_at timestamps will be included
118
+ # for all users. This is useful for debugging and development purposes.
119
+ config.timestamps_in_response = Rails.env.production? ? :admins_only : :all
109
120
  end
@@ -4,7 +4,6 @@ json.id <%= singular_table_name %>.id
4
4
  json.<%= attr %> <%= singular_table_name %>.<%= attr %>
5
5
  <% end -%>
6
6
 
7
- json.created_at <%= singular_table_name %>.created_at
8
- json.updated_at <%= singular_table_name %>.updated_at
7
+ json.partial! "securial/shared/timestamps", record: <%= singular_table_name %>
9
8
 
10
9
  json.url securial.<%= name.pluralize.downcase %>_url(<%= singular_table_name %>, format: :json)
@@ -0,0 +1,3 @@
1
+ require_relative "./configuration"
2
+ require_relative "./validation"
3
+ require_relative "./errors"
@@ -0,0 +1,42 @@
1
+ module Securial
2
+ module Config
3
+ VALID_SESSION_ENCRYPTION_ALGORITHMS = [:hs256, :hs384, :hs512].freeze
4
+ VALID_TIMESTAMP_OPTIONS = [:all, :admins_only, :none].freeze
5
+
6
+ class Configuration
7
+ attr_accessor :log_to_file, :log_to_stdout
8
+ attr_accessor :log_file_level, :log_stdout_level
9
+ attr_accessor :admin_role
10
+ attr_accessor :session_expiration_duration
11
+ attr_accessor :session_secret, :session_algorithm
12
+ attr_accessor :mailer_sender
13
+ attr_accessor :password_reset_email_subject
14
+ attr_accessor :password_min_length, :password_max_length
15
+ attr_accessor :password_complexity
16
+ attr_accessor :password_expires_in
17
+ attr_accessor :reset_password_token_expires_in
18
+ attr_accessor :reset_password_token_secret
19
+ attr_accessor :timestamps_in_response
20
+
21
+ def initialize
22
+ @log_to_file = !Rails.env.test?
23
+ @log_to_stdout = !Rails.env.test?
24
+ @log_file_level = :info
25
+ @log_stdout_level = :info
26
+ @admin_role = :admin
27
+ @session_expiration_duration = 3.minutes
28
+ @session_secret = "secret"
29
+ @session_algorithm = :hs256
30
+ @mailer_sender = "no-reply@example.com"
31
+ @password_reset_email_subject = "SECURIAL: Password Reset Instructions"
32
+ @password_min_length = 8
33
+ @password_max_length = 128
34
+ @password_complexity = Securial::RegexHelper::PASSWORD_REGEX
35
+ @password_expires_in = 90.days
36
+ @reset_password_token_expires_in = 2.hours
37
+ @reset_password_token_secret = "reset_secret"
38
+ @timestamps_in_response = :all
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,19 @@
1
+ module Securial
2
+ module Config
3
+ module Errors
4
+ class BaseConfigError < StandardError
5
+ def backtrace; []; end
6
+ end
7
+
8
+ class ConfigAdminRoleError < BaseConfigError; end
9
+
10
+ class ConfigSessionExpirationDurationError < BaseConfigError; end
11
+ class ConfigSessionAlgorithmError < BaseConfigError; end
12
+ class ConfigSessionSecretError < BaseConfigError; end
13
+
14
+ class ConfigMailerSenderError < BaseConfigError; end
15
+ class ConfigPasswordError < BaseConfigError; end
16
+ class ConfigTimestampsInResponseError < BaseConfigError; end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,179 @@
1
+ require "securial/logger"
2
+
3
+ module Securial
4
+ module Config
5
+ module Validation
6
+ class << self
7
+ def validate_all!(config)
8
+ validate_admin_role!(config)
9
+ validate_session_config!(config)
10
+ validate_mailer_sender!(config)
11
+ validate_password_config!(config)
12
+ validate_timestamps_in_response!(config)
13
+ end
14
+
15
+ private
16
+
17
+ def validate_admin_role!(config)
18
+ if config.admin_role.nil? || config.admin_role.to_s.strip.empty?
19
+ error_message = "Admin role is not set."
20
+ Securial::ENGINE_LOGGER.error(error_message)
21
+ raise Securial::Config::Errors::ConfigAdminRoleError, error_message
22
+ end
23
+
24
+ unless config.admin_role.is_a?(Symbol) || config.admin_role.is_a?(String)
25
+ error_message = "Admin role must be a Symbol or String."
26
+ Securial::ENGINE_LOGGER.error(error_message)
27
+ raise Securial::Config::Errors::ConfigAdminRoleError, error_message
28
+ end
29
+
30
+ if config.admin_role.to_s.pluralize.downcase == "accounts"
31
+ error_message = "The admin role cannot be 'account' or 'accounts' as it conflicts with the default routes."
32
+ Securial::ENGINE_LOGGER.error(error_message)
33
+ raise Securial::Config::Errors::ConfigAdminRoleError, error_message
34
+ end
35
+ end
36
+
37
+ def validate_session_config!(config)
38
+ validate_session_expiry_duration!(config)
39
+ validate_session_algorithm!(config)
40
+ validate_session_secret!(config)
41
+ end
42
+
43
+ def validate_session_expiry_duration!(config)
44
+ if config.session_expiration_duration.nil?
45
+ error_message = "Session expiration duration is not set."
46
+ Securial::ENGINE_LOGGER.error(error_message)
47
+ raise Securial::Config::Errors::ConfigSessionExpirationDurationError, error_message
48
+ end
49
+ if config.session_expiration_duration.class != ActiveSupport::Duration
50
+ error_message = "Session expiration duration must be an ActiveSupport::Duration."
51
+ Securial::ENGINE_LOGGER.error(error_message)
52
+ raise Securial::Config::Errors::ConfigSessionExpirationDurationError, error_message
53
+ end
54
+ if config.session_expiration_duration <= 0
55
+ Securial::ENGINE_LOGGER.error("Session expiration duration must be greater than 0.")
56
+ raise Securial::Config::Errors::ConfigSessionExpirationDurationError, "Session expiration duration must be greater than 0."
57
+ end
58
+ end
59
+
60
+ def validate_session_algorithm!(config)
61
+ if config.session_algorithm.blank?
62
+ error_message = "Session algorithm is not set."
63
+ Securial::ENGINE_LOGGER.error(error_message)
64
+ raise Securial::Config::Errors::ConfigSessionAlgorithmError, error_message
65
+ end
66
+ unless config.session_algorithm.is_a?(Symbol)
67
+ error_message = "Session algorithm must be a Symbol."
68
+ Securial::ENGINE_LOGGER.error(error_message)
69
+ raise Securial::Config::Errors::ConfigSessionAlgorithmError, error_message
70
+ end
71
+ valid_algorithms = Securial::Config::VALID_SESSION_ENCRYPTION_ALGORITHMS
72
+ unless valid_algorithms.include?(config.session_algorithm)
73
+ error_message = "Invalid session algorithm. Valid options are: #{valid_algorithms.map(&:inspect).join(', ')}."
74
+ Securial::ENGINE_LOGGER.error(error_message)
75
+ raise Securial::Config::Errors::ConfigSessionAlgorithmError, error_message
76
+ end
77
+ end
78
+
79
+ def validate_session_secret!(config)
80
+ if config.session_secret.blank?
81
+ error_message = "Session secret is not set."
82
+ Securial::ENGINE_LOGGER.error(error_message)
83
+ raise Securial::Config::Errors::ConfigSessionSecretError, error_message
84
+ end
85
+ unless config.session_secret.is_a?(String)
86
+ error_message = "Session secret must be a String."
87
+ Securial::ENGINE_LOGGER.error(error_message)
88
+ raise Securial::Config::Errors::ConfigSessionSecretError, error_message
89
+ end
90
+ end
91
+
92
+ def validate_mailer_sender!(config)
93
+ if config.mailer_sender.blank?
94
+ error_message = "Mailer sender is not set."
95
+ Securial::ENGINE_LOGGER.error(error_message)
96
+ raise Securial::Config::Errors::ConfigMailerSenderError, error_message
97
+ end
98
+ if config.mailer_sender !~ URI::MailTo::EMAIL_REGEXP
99
+ error_message = "Mailer sender is not a valid email address."
100
+ Securial::ENGINE_LOGGER.error(error_message)
101
+ raise Securial::Config::Errors::ConfigMailerSenderError, error_message
102
+ end
103
+ end
104
+
105
+ def validate_password_config!(config)
106
+ validate_password_reset_subject!(config)
107
+ validate_password_min_max_length!(config)
108
+ validate_password_complexity!(config)
109
+ validate_password_expiration!(config)
110
+ validate_password_reset_token!(config)
111
+ end
112
+
113
+ def validate_password_reset_token!(config)
114
+ if config.reset_password_token_secret.blank?
115
+ error_message = "Reset password token secret is not set."
116
+ Securial::ENGINE_LOGGER.error(error_message)
117
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
118
+ end
119
+ unless config.reset_password_token_secret.is_a?(String)
120
+ error_message = "Reset password token secret must be a String."
121
+ Securial::ENGINE_LOGGER.error(error_message)
122
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
123
+ end
124
+ end
125
+
126
+ def validate_password_reset_subject!(config)
127
+ if config.password_reset_email_subject.blank?
128
+ error_message = "Password reset email subject is not set."
129
+ Securial::ENGINE_LOGGER.error(error_message)
130
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
131
+ end
132
+ unless config.password_reset_email_subject.is_a?(String)
133
+ error_message = "Password reset email subject must be a String."
134
+ Securial::ENGINE_LOGGER.error(error_message)
135
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
136
+ end
137
+ end
138
+
139
+ def validate_password_min_max_length!(config)
140
+ unless config.password_min_length.is_a?(Integer) && config.password_min_length > 0
141
+ error_message = "Password minimum length must be a positive integer."
142
+ Securial::ENGINE_LOGGER.error(error_message)
143
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
144
+ end
145
+ unless config.password_max_length.is_a?(Integer) && config.password_max_length >= config.password_min_length
146
+ error_message = "Password maximum length must be an integer greater than or equal to the minimum length."
147
+ Securial::ENGINE_LOGGER.error(error_message)
148
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
149
+ end
150
+ end
151
+
152
+ def validate_password_complexity!(config)
153
+ if config.password_complexity.nil? || !config.password_complexity.is_a?(Regexp)
154
+ error_message = "Password complexity regex is not set or is not a valid Regexp."
155
+ Securial::ENGINE_LOGGER.error(error_message)
156
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
157
+ end
158
+ end
159
+
160
+ def validate_password_expiration!(config)
161
+ if config.password_expires_in.nil? || !config.password_expires_in.is_a?(ActiveSupport::Duration) || config.password_expires_in <= 0
162
+ error_message = "Password expiration duration is not set or is not a valid ActiveSupport::Duration."
163
+ Securial::ENGINE_LOGGER.error(error_message)
164
+ raise Securial::Config::Errors::ConfigPasswordError, error_message
165
+ end
166
+ end
167
+
168
+ def validate_timestamps_in_response!(config)
169
+ valid_options = Securial::Config::VALID_TIMESTAMP_OPTIONS
170
+ unless valid_options.include?(config.timestamps_in_response)
171
+ error_message = "Invalid timestamps_in_response option. Valid options are: #{valid_options.map(&:inspect).join(', ')}."
172
+ Securial::ENGINE_LOGGER.error(error_message)
173
+ raise Securial::Config::Errors::ConfigTimestampsInResponseError, error_message
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -1,14 +1,8 @@
1
1
  require_relative "./logger"
2
-
3
- Dir[File.join(__dir__, "errors", "*.rb")].each { |f| require_relative f }
4
-
5
- require_relative "./configuration"
6
-
7
- require_relative "./helpers/auth_helper"
8
- require_relative "./helpers/normalizing_helper"
9
- require_relative "./helpers/regex_helper"
10
-
11
- require_relative "./route_inspector"
2
+ require_relative "./config/_index"
3
+ require_relative "./helpers/_index"
4
+ require_relative "./sessions/_index"
5
+ require_relative "./inspectors/_index"
12
6
 
13
7
  require_relative "./middleware/request_logger_tag"
14
8
  require "jwt"
@@ -31,7 +25,12 @@ module Securial
31
25
  end
32
26
 
33
27
  initializer "securial.engine_initialized" do |app|
34
- Securial::ENGINE_LOGGER.info("[Securial] Engine mounted. Host app: #{app.class.name}")
28
+ Securial::ENGINE_LOGGER.info("[Securial] Initializing Engine... Host app: #{app.class.name}")
29
+ end
30
+
31
+ initializer "securial.config" do
32
+ Securial::ENGINE_LOGGER.info("[Securial] Validating configuration in `config/initializers/securial.rb`...")
33
+ Securial::Config::Validation.validate_all!(Securial.configuration)
35
34
  end
36
35
 
37
36
  initializer "securial.factories", after: "factory_bot.set_factory_paths" do
@@ -0,0 +1,2 @@
1
+ require_relative "normalizing_helper"
2
+ require_relative "regex_helper"
@@ -4,14 +4,14 @@ module Securial
4
4
  USERNAME_REGEX = /\A(?![0-9])[a-zA-Z](?:[a-zA-Z0-9]|[._](?![._]))*[a-zA-Z0-9]\z/
5
5
  PASSWORD_REGEX = %r{\A(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])[a-zA-Z].*\z}
6
6
 
7
- module_function
7
+ class << self
8
+ def valid_email?(email)
9
+ email.match?(EMAIL_REGEX)
10
+ end
8
11
 
9
- def valid_email?(email)
10
- email.match?(EMAIL_REGEX)
11
- end
12
-
13
- def valid_username?(username)
14
- username.match?(USERNAME_REGEX)
12
+ def valid_username?(username)
13
+ username.match?(USERNAME_REGEX)
14
+ end
15
15
  end
16
16
  end
17
17
  end
@@ -0,0 +1 @@
1
+ require_relative "route_inspector"
@@ -0,0 +1,52 @@
1
+ module Securial
2
+ module Inspectors
3
+ module RouteInspector
4
+ def self.print_routes(controller: nil)
5
+ filtered = Securial::Engine.routes.routes.select do |r|
6
+ ctrl = r.defaults[:controller]
7
+ controller.nil? || ctrl == "securial/#{controller}"
8
+ end
9
+
10
+ print_headers(filtered, controller)
11
+ print_details(filtered, controller)
12
+ true
13
+ end
14
+
15
+ class << self
16
+ private
17
+
18
+ # rubocop:disable Rails/Output
19
+ def print_headers(filtered, controller)
20
+ Securial::ENGINE_LOGGER.debug "Securial engine routes:"
21
+ Securial::ENGINE_LOGGER.debug "Total routes: #{filtered.size}"
22
+ Securial::ENGINE_LOGGER.debug "Filtered by controller: #{controller}" if controller
23
+ Securial::ENGINE_LOGGER.debug "Filtered routes: #{filtered.size}" if controller
24
+ Securial::ENGINE_LOGGER.debug "-" * 120
25
+ Securial::ENGINE_LOGGER.debug "#{'Verb'.ljust(8)} #{'Path'.ljust(45)} #{'Controller#Action'.ljust(40)} Name"
26
+ Securial::ENGINE_LOGGER.debug "-" * 120
27
+ end
28
+
29
+ def print_details(filtered, controller) # rubocop:disable Rails/Output
30
+ if filtered.empty?
31
+ if controller
32
+ Securial::ENGINE_LOGGER.debug "No routes found for controller: #{controller}"
33
+ else
34
+ Securial::ENGINE_LOGGER.debug "No routes found for Securial engine"
35
+ end
36
+ Securial::ENGINE_LOGGER.debug "-" * 120
37
+ return
38
+ end
39
+
40
+ Securial::ENGINE_LOGGER.debug filtered.map { |r|
41
+ name = r.name || ""
42
+ verb = r.verb.to_s.ljust(8)
43
+ path = r.path.spec.to_s.sub(/\(\.:format\)/, "").ljust(45)
44
+ ctrl_action = "#{r.defaults[:controller]}##{r.defaults[:action]}"
45
+ "#{verb} #{path} #{ctrl_action.ljust(40)} #{name}"
46
+ }.join("\n")
47
+ end
48
+ # rubocop:enable Rails/Output
49
+ end
50
+ end
51
+ end
52
+ end
@@ -7,13 +7,13 @@ module Securial
7
7
  def self.build
8
8
  outputs = []
9
9
 
10
- if Securial.configuration.log_to_file
10
+ unless Securial.configuration.log_to_file == false
11
11
  log_file = Rails.root.join("log", "securial.log").open("a")
12
12
  log_file.sync = true
13
13
  outputs << log_file
14
14
  end
15
15
 
16
- if Securial.configuration.log_to_stdout
16
+ unless Securial.configuration.log_to_stdout == false
17
17
  outputs << STDOUT
18
18
  end
19
19
 
@@ -0,0 +1,2 @@
1
+ require_relative "errors"
2
+ require_relative "session_encoder"
@@ -0,0 +1,15 @@
1
+ module Securial
2
+ module Sessions
3
+ module Errors
4
+ class BaseSessionError < StandardError
5
+ def backtrace; []; end
6
+ end
7
+
8
+ class SessionEncodeError < BaseSessionError; end
9
+ class SessionDecodeError < BaseSessionError; end
10
+
11
+ class SessionRevokedError < BaseSessionError; end
12
+ class SessionExpiredError < BaseSessionError; end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,56 @@
1
+ module Securial
2
+ module Sessions
3
+ module SessionEncoder
4
+ class << self
5
+ def encode(session)
6
+ return nil unless session && session.class == Securial::Session
7
+
8
+ base_payload = {
9
+ jti: session.id,
10
+ exp: expiry_duration.from_now.to_i,
11
+ sub: "session-access-token",
12
+ refresh_count: session.refresh_count,
13
+ }
14
+
15
+ session_payload = {
16
+ ip: session.ip_address,
17
+ agent: session.user_agent,
18
+ }
19
+
20
+ payload = base_payload.merge(session_payload)
21
+ begin
22
+ JWT.encode(payload, secret, algorithm, { kid: "hmac" })
23
+ rescue JWT::EncodeError => e
24
+ raise Errors::SessionEncodeError, "Failed to encode session: #{e.message}"
25
+ end
26
+ end
27
+
28
+ def decode(token)
29
+ begin
30
+ decoded = JWT.decode(token, secret, true, { algorithm: algorithm, verify_jti: true, iss: "securial" })
31
+ rescue JWT::DecodeError => e
32
+ raise Securial::Sessions::Errors::SessionDecodeError, "Failed to decode session token: #{e.message}"
33
+ end
34
+ decoded.first
35
+ end
36
+
37
+ private
38
+
39
+ def secret
40
+ # Config::Validation.validate_session_secret!(Securial.configuration)
41
+ Securial.configuration.session_secret
42
+ end
43
+
44
+ def algorithm
45
+ # Config::Validation.validate_session_algorithm!(Securial.configuration)
46
+ Securial.configuration.session_algorithm.to_s.upcase
47
+ end
48
+
49
+ def expiry_duration
50
+ # Config::Validation.validate_session_expiry_duration!(Securial.configuration)
51
+ Securial.configuration.session_expiration_duration
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,3 +1,3 @@
1
1
  module Securial
2
- VERSION = "0.4.2"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/securial.rb CHANGED
@@ -8,17 +8,11 @@ module Securial
8
8
  attr_writer :configuration
9
9
 
10
10
  def configuration
11
- @configuration ||= Configuration.new
11
+ @configuration ||= Securial::Config::Configuration.new
12
12
  end
13
13
 
14
14
  def configure
15
15
  yield(configuration)
16
- validate_admin_role!
17
- validate_session_expiry_duration!
18
- validate_session_algorithm!
19
- validate_session_secret!
20
- validate_mailer_sender!
21
- validate_password_config!
22
16
  end
23
17
 
24
18
  # Returns the pluralized form of the admin role.
@@ -26,69 +20,5 @@ module Securial
26
20
  def admin_namespace
27
21
  configuration.admin_role.to_s.pluralize.downcase
28
22
  end
29
-
30
- def validate_admin_role!
31
- error_message = "The admin role cannot be 'account' or 'accounts' as it conflicts with the default routes."
32
-
33
- if configuration.admin_role.to_s.pluralize.downcase == "accounts"
34
- Securial::ENGINE_LOGGER.error(error_message)
35
- raise Securial::ConfigErrors::ConfigAdminRoleError, error_message
36
- end
37
- end
38
-
39
- def validate_session_expiry_duration!
40
- if configuration.session_expiration_duration.nil?
41
- error_message = "Session expiration duration is not set."
42
- Securial::ENGINE_LOGGER.error(error_message)
43
- raise Securial::ConfigErrors::ConfigSessionExpirationDurationError, error_message
44
- end
45
- if configuration.session_expiration_duration.class != ActiveSupport::Duration
46
- error_message = "Session expiration duration must be an ActiveSupport::Duration."
47
- Securial::ENGINE_LOGGER.error(error_message)
48
- raise Securial::ConfigErrors::ConfigSessionExpirationDurationError, error_message
49
- end
50
- if configuration.session_expiration_duration <= 0
51
- Securial::ENGINE_LOGGER.error("Session expiration duration must be greater than 0.")
52
- raise Securial::ConfigErrors::ConfigSessionExpirationDurationError, "Session expiration duration must be greater than 0."
53
- end
54
- end
55
-
56
- def validate_session_algorithm!
57
- valid_algorithms = [:hs256, :hs384, :hs512]
58
- unless valid_algorithms.include?(configuration.session_algorithm)
59
- error_message = "Invalid session algorithm. Valid options are: #{valid_algorithms.join(', ')}."
60
- Securial::ENGINE_LOGGER.error(error_message)
61
- raise Securial::ConfigErrors::ConfigSessionAlgorithmError, error_message
62
- end
63
- end
64
-
65
- def validate_session_secret!
66
- if configuration.session_secret.blank?
67
- error_message = "Session secret is not set."
68
- Securial::ENGINE_LOGGER.error(error_message)
69
- raise Securial::ConfigErrors::ConfigSessionSecretError, error_message
70
- end
71
- end
72
-
73
- def validate_mailer_sender!
74
- if configuration.mailer_sender.blank?
75
- error_message = "Mailer sender is not set."
76
- Securial::ENGINE_LOGGER.error(error_message)
77
- raise Securial::ConfigErrors::ConfigMailerSenderError, error_message
78
- end
79
- if configuration.mailer_sender !~ URI::MailTo::EMAIL_REGEXP
80
- error_message = "Mailer sender is not a valid email address."
81
- Securial::ENGINE_LOGGER.error(error_message)
82
- raise Securial::ConfigErrors::ConfigMailerSenderError, error_message
83
- end
84
- end
85
-
86
- def validate_password_config!
87
- if configuration.password_reset_email_subject.blank?
88
- error_message = "Password reset email subject is not set."
89
- Securial::ENGINE_LOGGER.error(error_message)
90
- raise Securial::ConfigErrors::ConfigMailerSenderError, error_message
91
- end
92
- end
93
23
  end
94
24
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: securial
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aly Badawy
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-05-27 00:00:00.000000000 Z
10
+ date: 2025-05-28 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -353,9 +353,6 @@ files:
353
353
  - app/views/layouts/securial/mailer.html.erb
354
354
  - app/views/layouts/securial/mailer.text.erb
355
355
  - app/views/securial/accounts/show.json.jbuilder
356
- - app/views/securial/passwords/_password.json.jbuilder
357
- - app/views/securial/passwords/index.json.jbuilder
358
- - app/views/securial/passwords/show.json.jbuilder
359
356
  - app/views/securial/role_assignments/show.json.jbuilder
360
357
  - app/views/securial/roles/_securial_role.json.jbuilder
361
358
  - app/views/securial/roles/index.json.jbuilder
@@ -365,6 +362,7 @@ files:
365
362
  - app/views/securial/sessions/_session.json.jbuilder
366
363
  - app/views/securial/sessions/index.json.jbuilder
367
364
  - app/views/securial/sessions/show.json.jbuilder
365
+ - app/views/securial/shared/_timestamps.json.jbuilder
368
366
  - app/views/securial/status/show.json.jbuilder
369
367
  - app/views/securial/users/_securial_user.json.jbuilder
370
368
  - app/views/securial/users/index.json.jbuilder
@@ -390,27 +388,32 @@ files:
390
388
  - lib/generators/securial/scaffold/templates/routes.erb
391
389
  - lib/generators/securial/scaffold/templates/routing_spec.erb
392
390
  - lib/securial.rb
393
- - lib/securial/configuration.rb
391
+ - lib/securial/config/_index.rb
392
+ - lib/securial/config/configuration.rb
393
+ - lib/securial/config/errors.rb
394
+ - lib/securial/config/validation.rb
394
395
  - lib/securial/engine.rb
395
- - lib/securial/errors/config_errors.rb
396
- - lib/securial/errors/session_errors.rb
397
396
  - lib/securial/factories/securial/role_assignments.rb
398
397
  - lib/securial/factories/securial/roles.rb
399
398
  - lib/securial/factories/securial/sessions.rb
400
399
  - lib/securial/factories/securial/users.rb
401
- - lib/securial/helpers/auth_helper.rb
400
+ - lib/securial/helpers/_index.rb
402
401
  - lib/securial/helpers/normalizing_helper.rb
403
402
  - lib/securial/helpers/regex_helper.rb
403
+ - lib/securial/inspectors/_index.rb
404
+ - lib/securial/inspectors/route_inspector.rb
404
405
  - lib/securial/logger.rb
405
406
  - lib/securial/middleware/request_logger_tag.rb
406
- - lib/securial/route_inspector.rb
407
+ - lib/securial/sessions/_index.rb
408
+ - lib/securial/sessions/errors.rb
409
+ - lib/securial/sessions/session_encoder.rb
407
410
  - lib/securial/version.rb
408
411
  - lib/tasks/securial_tasks.rake
409
412
  homepage: https://github.com/AlyBadawy/Securial/wiki
410
413
  licenses:
411
414
  - MIT
412
415
  metadata:
413
- release_date: '2025-05-27'
416
+ release_date: '2025-05-28'
414
417
  allowed_push_host: https://rubygems.org
415
418
  homepage_uri: https://github.com/AlyBadawy/Securial/wiki
416
419
  source_code_uri: https://github.com/AlyBadawy/Securial
@@ -1,2 +0,0 @@
1
- json.extract! password, :id, :forgot_password, :reset_password, :created_at, :updated_at
2
- json.url password_url(password, format: :json)
@@ -1 +0,0 @@
1
- json.array! @passwords, partial: "securial/passwords/password", as: :password
@@ -1 +0,0 @@
1
- json.partial! "securial/passwords/password", password: @password
@@ -1,35 +0,0 @@
1
- module Securial
2
- class Configuration
3
- attr_accessor :log_to_file, :log_to_stdout
4
- attr_accessor :log_file_level, :log_stdout_level
5
- attr_accessor :admin_role
6
- attr_accessor :session_expiration_duration
7
- attr_accessor :session_secret, :session_algorithm
8
- attr_accessor :mailer_sender
9
- attr_accessor :password_reset_email_subject
10
- attr_accessor :password_min_length, :password_max_length
11
- attr_accessor :password_complexity
12
- attr_accessor :password_expires_in
13
- attr_accessor :reset_password_token_expires_in
14
- attr_accessor :reset_password_token_secret
15
-
16
- def initialize
17
- @log_to_file = !Rails.env.test?
18
- @log_to_stdout = !Rails.env.test?
19
- @log_file_level = :info
20
- @log_stdout_level = :info
21
- @admin_role = :admin
22
- @session_expiration_duration = 3.minutes
23
- @session_secret = "secret"
24
- @session_algorithm = :hs256
25
- @mailer_sender = "no-reply@example.com"
26
- @password_reset_email_subject = "SECURIAL: Password Reset Instructions"
27
- @password_min_length = 8
28
- @password_max_length = 128
29
- @password_complexity = Securial::RegexHelper::PASSWORD_REGEX
30
- @password_expires_in = 90.days
31
- @reset_password_token_expires_in = 2.hours
32
- @reset_password_token_secret = "reset_secret"
33
- end
34
- end
35
- end
@@ -1,12 +0,0 @@
1
- module Securial
2
- module ConfigErrors
3
- class ConfigAdminRoleError < StandardError; end
4
-
5
- class ConfigSessionExpirationDurationError < StandardError; end
6
- class ConfigSessionAlgorithmError < StandardError; end
7
- class ConfigSessionSecretError < StandardError; end
8
-
9
- class ConfigMailerSenderError < StandardError; end
10
- class ConfigPasswordError < StandardError; end
11
- end
12
- end
@@ -1,6 +0,0 @@
1
- module Securial
2
- module SessionErrors
3
- class SessionRevokedError < StandardError; end
4
- class SessionExpiredError < StandardError; end
5
- end
6
- end
@@ -1,46 +0,0 @@
1
- module Securial
2
- module AuthHelper
3
- class << self
4
- def encode(session)
5
- return nil unless session && session.class == Securial::Session
6
-
7
- base_payload = {
8
- jti: session.id,
9
- exp: expiry_duration.from_now.to_i,
10
- sub: "session-access-token",
11
- refresh_count: session.refresh_count,
12
- }
13
-
14
- session_payload = {
15
- ip: session.ip_address,
16
- agent: session.user_agent,
17
- }
18
-
19
- payload = base_payload.merge(session_payload)
20
- JWT.encode(payload, secret, algorithm, { kid: "hmac" })
21
- end
22
-
23
- def decode(token)
24
- decoded = JWT.decode(token, secret, true, { algorithm: algorithm, verify_jti: true, iss: "securial" })
25
- decoded.first
26
- end
27
-
28
- private
29
-
30
- def secret
31
- Securial.validate_session_secret!
32
- Securial.configuration.session_secret
33
- end
34
-
35
- def algorithm
36
- Securial.validate_session_algorithm!
37
- Securial.configuration.session_algorithm.to_s.upcase
38
- end
39
-
40
- def expiry_duration
41
- Securial.validate_session_expiry_duration!
42
- Securial.configuration.session_expiration_duration
43
- end
44
- end
45
- end
46
- end
@@ -1,50 +0,0 @@
1
- module Securial
2
- module RouteInspector
3
- def self.print_routes(controller: nil)
4
- filtered = Securial::Engine.routes.routes.select do |r|
5
- ctrl = r.defaults[:controller]
6
- controller.nil? || ctrl == "securial/#{controller}"
7
- end
8
-
9
- print_headers(filtered, controller)
10
- print_details(filtered, controller)
11
- true
12
- end
13
-
14
- class << self
15
- private
16
-
17
- # rubocop:disable Rails/Output
18
- def print_headers(filtered, controller)
19
- Securial::ENGINE_LOGGER.debug "Securial engine routes:"
20
- Securial::ENGINE_LOGGER.debug "Total routes: #{filtered.size}"
21
- Securial::ENGINE_LOGGER.debug "Filtered by controller: #{controller}" if controller
22
- Securial::ENGINE_LOGGER.debug "Filtered routes: #{filtered.size}" if controller
23
- Securial::ENGINE_LOGGER.debug "-" * 120
24
- Securial::ENGINE_LOGGER.debug "#{'Verb'.ljust(8)} #{'Path'.ljust(45)} #{'Controller#Action'.ljust(40)} Name"
25
- Securial::ENGINE_LOGGER.debug "-" * 120
26
- end
27
-
28
- def print_details(filtered, controller) # rubocop:disable Rails/Output
29
- if filtered.empty?
30
- if controller
31
- Securial::ENGINE_LOGGER.debug "No routes found for controller: #{controller}"
32
- else
33
- Securial::ENGINE_LOGGER.debug "No routes found for Securial engine"
34
- end
35
- Securial::ENGINE_LOGGER.debug "-" * 120
36
- return
37
- end
38
-
39
- Securial::ENGINE_LOGGER.debug filtered.map { |r|
40
- name = r.name || ""
41
- verb = r.verb.to_s.ljust(8)
42
- path = r.path.spec.to_s.sub(/\(\.:format\)/, "").ljust(45)
43
- ctrl_action = "#{r.defaults[:controller]}##{r.defaults[:action]}"
44
- "#{verb} #{path} #{ctrl_action.ljust(40)} #{name}"
45
- }.join("\n")
46
- end
47
- # rubocop:enable Rails/Output
48
- end
49
- end
50
- end