dscf-core 0.1.2 → 0.1.3

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/dscf/core/authenticatable.rb +81 -0
  3. data/app/controllers/concerns/dscf/core/common.rb +200 -0
  4. data/app/controllers/concerns/dscf/core/filterable.rb +12 -0
  5. data/app/controllers/concerns/dscf/core/json_response.rb +66 -0
  6. data/app/controllers/concerns/dscf/core/pagination.rb +71 -0
  7. data/app/controllers/concerns/dscf/core/token_authenticatable.rb +53 -0
  8. data/app/controllers/dscf/core/application_controller.rb +19 -0
  9. data/app/controllers/dscf/core/auth_controller.rb +120 -0
  10. data/app/errors/dscf/core/authentication_error.rb +19 -0
  11. data/app/models/concerns/dscf/core/user_authenticatable.rb +58 -0
  12. data/app/models/dscf/core/refresh_token.rb +36 -0
  13. data/app/models/dscf/core/user.rb +11 -4
  14. data/app/models/dscf/core/user_profile.rb +19 -0
  15. data/app/services/dscf/core/auth_service.rb +75 -0
  16. data/app/services/dscf/core/token_service.rb +55 -0
  17. data/config/initializers/jwt.rb +23 -0
  18. data/config/initializers/ransack.rb +4 -0
  19. data/config/locales/en.yml +23 -0
  20. data/config/routes.rb +5 -0
  21. data/db/migrate/20250822092031_create_dscf_core_user_profiles.rb +3 -3
  22. data/db/migrate/20250824114725_create_dscf_core_refresh_tokens.rb +15 -0
  23. data/db/migrate/20250824191957_change_pep_status_to_integer_in_user_profiles.rb +5 -0
  24. data/db/migrate/20250824200927_make_email_and_phone_optional_for_users.rb +6 -0
  25. data/db/migrate/20250825192113_add_defaults_to_user_profiles.rb +6 -0
  26. data/lib/dscf/core/version.rb +1 -1
  27. data/lib/dscf/core.rb +2 -0
  28. data/spec/factories/dscf/core/refresh_tokens.rb +25 -0
  29. data/spec/factories/dscf/core/user_profiles.rb +4 -4
  30. metadata +149 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 113ae71c73f1677c95192abafbe070227cb8938204430fc320e0ee7667546f74
4
- data.tar.gz: d688f387984a84dc6e2078cacf65cb74560ad568073d4965b6943a5a0997ac9b
3
+ metadata.gz: f4107923adddc9fd29ff5ac9526119693b1fce5d4aabc9a401bcc0f38a1b97c2
4
+ data.tar.gz: 4f23eecacbcb23c3cf40adbb0ed1dd467f48352b97aa7e6f9c6b51592d278719
5
5
  SHA512:
6
- metadata.gz: e5f9c9c60bcec896a04c131a93b574f7f8dda64a15793816d3f9422cbeaed04788b6200015c8a0b3ea17302b43ac6592c3acce724bbbb016468da8ffa7e23749
7
- data.tar.gz: 6d0ad2580955a6c87d33d4b7b6131ea9a65944c79f032568dd361c9d301bd3ce77eaada16ddf1b5e87c932e5e6d630151e6ebd97daf8d7712af212d20cfbc05a
6
+ metadata.gz: 0c11ba4c15b33280800732598d62e028269ce3ad4eecf8aa642c5d9825df338ff3cc9f0266697459914e44a9773fba326c876fec3347a1808d04ff02925fcd07
7
+ data.tar.gz: a3806b08908d24011e7390a4cd0e134f4f18d728029cebf4cfc16cf2fcc1512358a38422b6fd755f01a0a220f3c0c00b0fffacc1769a659ef699c5c4fae836e6
@@ -0,0 +1,81 @@
1
+ module Dscf
2
+ module Core
3
+ module Authenticatable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :authenticate_user, if: :authentication_required?
8
+ rescue_from AuthenticationError, with: :handle_authentication_error
9
+ end
10
+
11
+ def current_user
12
+ @current_user ||= authenticate_from_token
13
+ end
14
+
15
+ def authenticate_user!
16
+ raise AuthenticationError, "Authentication required" unless current_user
17
+ end
18
+
19
+ def authenticate_user
20
+ authenticate_user! if authentication_required?
21
+ end
22
+
23
+ def sign_in(user, request)
24
+ tokens = user.generate_auth_tokens(request)
25
+ @current_user = user
26
+ tokens
27
+ end
28
+
29
+ def sign_out
30
+ # Revoke the current refresh token if available
31
+ refresh_token_value = extract_refresh_token_from_params
32
+ AuthService.revoke_refresh_token(refresh_token_value) if refresh_token_value
33
+ @current_user = nil
34
+ end
35
+
36
+ def refresh_token
37
+ refresh_token_value = extract_refresh_token_from_params
38
+ return nil unless refresh_token_value
39
+
40
+ begin
41
+ AuthService.refresh_access_token(refresh_token_value, request)
42
+ rescue AuthenticationError
43
+ nil
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def authenticate_from_token
50
+ access_token = extract_access_token_from_header
51
+ return nil unless access_token
52
+
53
+ payload = TokenService.decode(access_token)
54
+ return nil unless payload && payload["type"] == "access"
55
+
56
+ User.find_by(id: payload["user_id"])
57
+ rescue AuthenticationError
58
+ nil
59
+ end
60
+
61
+ def extract_access_token_from_header
62
+ auth_header = request.headers["Authorization"]
63
+ return nil unless auth_header&.start_with?("Bearer ")
64
+
65
+ auth_header.split(" ").last
66
+ end
67
+
68
+ def extract_refresh_token_from_params
69
+ params[:refresh_token]
70
+ end
71
+
72
+ def authentication_required?
73
+ true # Override in specific controllers if needed
74
+ end
75
+
76
+ def handle_authentication_error(error)
77
+ render json: error.to_hash, status: error.status_code
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,200 @@
1
+ module Dscf
2
+ module Core
3
+ module Common
4
+ extend ActiveSupport::Concern
5
+ include Dscf::Core::Pagination
6
+ include Dscf::Core::JsonResponse
7
+ include Dscf::Core::Filterable
8
+
9
+ included do
10
+ before_action :set_clazz
11
+ before_action :set_object, only: %i[show update]
12
+ end
13
+
14
+ def index
15
+ data = nil
16
+ options = {}
17
+ if block_given?
18
+ incoming = yield
19
+ if incoming.instance_of?(Array)
20
+ data, options = incoming
21
+ elsif incoming.instance_of?(Hash)
22
+ options = incoming
23
+ else
24
+ data = incoming
25
+ end
26
+ else
27
+ data = @clazz.all
28
+ end
29
+
30
+ # Apply eager loading if defined
31
+ data = data.includes(eager_loaded_associations) if eager_loaded_associations.present?
32
+
33
+ # Apply Ransack filtering
34
+ data = filter_records(data)
35
+
36
+ # Get total count before pagination
37
+ total_count = data.count if params[:page]
38
+
39
+ data = data.then(&paginate) if params[:page]
40
+
41
+ # Add action specific serializer includes
42
+ includes = serializer_includes_for_action(:index)
43
+ options[:include] = includes if includes.present?
44
+
45
+ # Add pagination metadata if paginated
46
+ if params[:page]
47
+ total_pages = (total_count.to_f / per_page).ceil
48
+ count = data.is_a?(Array) ? data.length : data.count
49
+
50
+ options[:pagination] = {
51
+ current_page: page_no,
52
+ per_page: per_page,
53
+ count: count,
54
+ total_count: total_count,
55
+ total_pages: total_pages,
56
+ links: pagination_links(total_pages)
57
+ }
58
+ end
59
+
60
+ render_success(data: data, serializer_options: options)
61
+ end
62
+
63
+ def show
64
+ data = nil
65
+ options = {}
66
+ if block_given?
67
+ incoming = yield
68
+ if incoming.instance_of?(Array)
69
+ data, options = incoming
70
+ elsif incoming.instance_of?(Hash)
71
+ data = @obj
72
+ options = incoming
73
+ else
74
+ data = incoming
75
+ end
76
+ else
77
+ data = @obj
78
+ end
79
+
80
+ data = @clazz.includes(eager_loaded_associations).find(params[:id]) if data.is_a?(@clazz) && eager_loaded_associations.present?
81
+
82
+ includes = serializer_includes_for_action(:show)
83
+ options[:include] = includes if includes.present?
84
+
85
+ render_success(data: data, serializer_options: options)
86
+ end
87
+
88
+ def create
89
+ obj = nil
90
+ options = {}
91
+ if block_given?
92
+ incoming = yield
93
+ if incoming.instance_of?(Array)
94
+ obj, options = incoming
95
+ elsif incoming.instance_of?(Hash)
96
+ obj = @clazz.new(model_params)
97
+ options = incoming
98
+ else
99
+ obj = incoming
100
+ end
101
+ else
102
+ obj = @clazz.new(model_params)
103
+ end
104
+
105
+ if obj.save
106
+ obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
107
+
108
+ includes = serializer_includes_for_action(:create)
109
+ options[:include] = includes if includes.present?
110
+
111
+ render_success(data: obj, serializer_options: options, status: :created)
112
+ else
113
+ render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
114
+ end
115
+ rescue StandardError => e
116
+ render_error(error: e.message)
117
+ end
118
+
119
+ def update
120
+ obj = nil
121
+ options = {}
122
+ if block_given?
123
+ incoming = yield
124
+ if incoming.instance_of?(Array)
125
+ obj, options = incoming
126
+ elsif incoming.instance_of?(Hash)
127
+ obj = set_object
128
+ options = incoming
129
+ else
130
+ obj = incoming
131
+ end
132
+ else
133
+ obj = set_object
134
+ end
135
+
136
+ if obj.update(model_params)
137
+ obj = @clazz.includes(eager_loaded_associations).find(obj.id) if eager_loaded_associations.present?
138
+
139
+ includes = serializer_includes_for_action(:update)
140
+ options[:include] = includes if includes.present?
141
+
142
+ render_success(data: obj, serializer_options: options)
143
+ else
144
+ render_error(errors: obj.errors.full_messages[0], status: :unprocessable_entity)
145
+ end
146
+ rescue StandardError => e
147
+ render_error(error: e.message)
148
+ end
149
+
150
+ private
151
+
152
+ def set_clazz
153
+ model_name = controller_name.classify
154
+
155
+ controller_namespace = self.class.name.deconstantize
156
+ engine_namespace = controller_namespace.split("::")[0..1].join("::")
157
+
158
+ @clazz = "#{engine_namespace}::#{model_name}".constantize
159
+ rescue NameError
160
+ @clazz = "Dscf::Core::#{model_name}".constantize
161
+ end
162
+
163
+ def set_object
164
+ @obj = if eager_loaded_associations.present?
165
+ @clazz.includes(eager_loaded_associations).find(params[:id])
166
+ else
167
+ @clazz.find(params[:id])
168
+ end
169
+ end
170
+
171
+ # Override in controllers to define what to eager load
172
+ def eager_loaded_associations
173
+ []
174
+ end
175
+
176
+ # Override in controllers to define allowed order columns for pagination
177
+ def allowed_order_columns
178
+ %w[id created_at updated_at]
179
+ end
180
+
181
+ # Override in controllers to define serializer includes
182
+ def default_serializer_includes
183
+ {}
184
+ end
185
+
186
+ def serializer_includes_for_action(action)
187
+ includes = default_serializer_includes
188
+
189
+ if includes.is_a?(Hash)
190
+ includes[action] || includes[:default] || []
191
+ else
192
+ includes.present? ? includes : []
193
+ end
194
+ end
195
+
196
+ # This method should be overridden by respective child controllers
197
+ def model_params; end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,12 @@
1
+ module Dscf
2
+ module Core
3
+ module Filterable
4
+ extend ActiveSupport::Concern
5
+
6
+ def filter_records(records)
7
+ @q = records.ransack(params[:q])
8
+ @q.result(distinct: true)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,66 @@
1
+ module Dscf
2
+ module Core
3
+ module JsonResponse
4
+ extend ActiveSupport::Concern
5
+
6
+ def serialize(data, options = {})
7
+ case data
8
+ when ActiveRecord::Base, ActiveRecord::Relation
9
+ ActiveModelSerializers::SerializableResource.new(data, options)
10
+ when Hash
11
+ data.transform_values { |value| serialize(value, options) }
12
+ when Array
13
+ data.map { |value| serialize(value, options) }
14
+ else
15
+ data
16
+ end
17
+ end
18
+
19
+ def render_success(message_key = nil, data: nil, status: :ok, serializer_options: {}, **options)
20
+ response = {success: true}
21
+
22
+ if message_key
23
+ response[:message] = I18n.t(message_key)
24
+ else
25
+ model_key = @clazz&.name&.demodulize&.underscore
26
+ action_key = action_name
27
+ i18n_key = "#{model_key}.success.#{action_key}"
28
+
29
+ response[:message] = I18n.t(i18n_key) if I18n.exists?(i18n_key)
30
+ end
31
+
32
+ if data
33
+ serialized_data = serialize(data, serializer_options)
34
+ response[:data] = serialized_data
35
+ end
36
+
37
+ # Add pagination metadata if present (handled by Common concern)
38
+ response[:pagination] = serializer_options[:pagination] if serializer_options[:pagination]
39
+
40
+ response.merge!(options)
41
+ render json: response, status: status
42
+ end
43
+
44
+ def render_error(message_key = nil, status: :unprocessable_entity, errors: nil, **options)
45
+ response = {
46
+ success: false
47
+ }
48
+
49
+ if message_key
50
+ response[:error] = I18n.t(message_key)
51
+ else
52
+ model_key = @clazz&.name&.demodulize&.underscore
53
+ action_key = action_name
54
+ i18n_key = "#{model_key}.errors.#{action_key}"
55
+
56
+ response[:error] = I18n.t(i18n_key) if I18n.exists?(i18n_key)
57
+ end
58
+
59
+ response[:errors] = errors if errors
60
+ response.merge!(options)
61
+
62
+ render json: response, status: status
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,71 @@
1
+ module Dscf
2
+ module Core
3
+ module Pagination
4
+ extend ActiveSupport::Concern
5
+
6
+ def default_per_page
7
+ 10
8
+ end
9
+
10
+ def page_no
11
+ params[:page]&.to_i || 1
12
+ end
13
+
14
+ def per_page
15
+ params[:per_page]&.to_i || default_per_page
16
+ end
17
+
18
+ def paginate_offset
19
+ (page_no - 1) * per_page
20
+ end
21
+
22
+ def order_by
23
+ allowed_columns = if respond_to?(:allowed_order_columns, true)
24
+ allowed_order_columns
25
+ else
26
+ %w[id created_at updated_at]
27
+ end
28
+ requested_column = params.fetch(:order_by, "id").to_s
29
+ allowed_columns.include?(requested_column) ? requested_column : "id"
30
+ end
31
+
32
+ def order_direction
33
+ if %w[asc desc].include?(params.fetch(:order_direction, "asc").to_s.downcase)
34
+ params.fetch(:order_direction, "asc").to_s.downcase
35
+ else
36
+ "asc"
37
+ end
38
+ end
39
+
40
+ def paginate
41
+ ->(it) { it.limit(per_page).offset(paginate_offset).order("#{order_by}": order_direction) }
42
+ end
43
+
44
+ # Generate HATEOAS pagination links
45
+ def pagination_links(total_pages)
46
+ base_path = request.path
47
+ current_params = request.query_parameters.except("page")
48
+
49
+ links = {}
50
+
51
+ links[:first] = build_page_url(base_path, current_params, 1)
52
+
53
+ links[:prev] = (build_page_url(base_path, current_params, page_no - 1) if page_no > 1)
54
+
55
+ links[:next] = (build_page_url(base_path, current_params, page_no + 1) if page_no < total_pages)
56
+
57
+ links[:last] = build_page_url(base_path, current_params, total_pages)
58
+
59
+ links
60
+ end
61
+
62
+ private
63
+
64
+ def build_page_url(base_path, params, page)
65
+ params_with_page = params.merge(page: page, per_page: per_page)
66
+ query_string = params_with_page.to_query
67
+ "#{base_path}?#{query_string}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,53 @@
1
+ module Dscf
2
+ module Core
3
+ module TokenAuthenticatable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :validate_token_expiry
8
+ before_action :validate_device_consistency, if: :current_user
9
+ end
10
+
11
+ def validate_token_expiry
12
+ return unless current_user
13
+
14
+ access_token = extract_access_token_from_header
15
+ return unless access_token
16
+
17
+ payload = TokenService.decode(access_token)
18
+ return unless payload
19
+
20
+ # Check if token is close to expiry (within 5 minutes)
21
+ if payload["exp"] && payload["exp"] - Time.current.to_i < 300
22
+ Rails.logger.info("Access token close to expiry for user #{current_user.id}")
23
+ end
24
+ rescue AuthenticationError
25
+ handle_expired_token
26
+ end
27
+
28
+ def validate_device_consistency
29
+ return unless current_user && request.params[:device_id]
30
+
31
+ refresh_token_record = current_user.refresh_tokens.active.find_by(device: request.params[:device_id])
32
+ return if refresh_token_record
33
+
34
+ # Device mismatch - could indicate suspicious activity
35
+ Rails.logger.warn("Device mismatch for user #{current_user.id}: #{request.params[:device_id]}")
36
+ end
37
+
38
+ def require_valid_refresh_token
39
+ refresh_token_value = extract_refresh_token_from_params
40
+ return if refresh_token_value && RefreshToken.active.exists?(refresh_token: refresh_token_value)
41
+
42
+ raise AuthenticationError, "Valid refresh token required"
43
+ end
44
+
45
+ private
46
+
47
+ def handle_expired_token
48
+ Rails.logger.warn("Expired token detected for request to #{request.path}")
49
+ raise AuthenticationError, "Session expired"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,6 +1,25 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class ApplicationController < ActionController::API
4
+ include Authenticatable
5
+ include TokenAuthenticatable
6
+ include JsonResponse
7
+
8
+ # Handle CORS for authentication
9
+ before_action :set_cors_headers
10
+
11
+ private
12
+
13
+ def set_cors_headers
14
+ headers["Access-Control-Allow-Origin"] = request.headers["Origin"] || "*"
15
+ headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
16
+ headers["Access-Control-Allow-Headers"] = "Origin, Content-Type, Accept, Authorization, X-Requested-With"
17
+ headers["Access-Control-Allow-Credentials"] = "false"
18
+ end
19
+
20
+ def authentication_required?
21
+ false # Override in specific controllers
22
+ end
4
23
  end
5
24
  end
6
25
  end
@@ -0,0 +1,120 @@
1
+ module Dscf
2
+ module Core
3
+ class AuthController < ApplicationController
4
+ skip_before_action :authenticate_user, only: %i[login signup refresh]
5
+ skip_before_action :validate_token_expiry, only: %i[login signup refresh]
6
+ skip_before_action :validate_device_consistency, only: %i[login signup refresh]
7
+
8
+ def login
9
+ user = AuthService.authenticate_user(params[:email_or_phone], params[:password])
10
+
11
+ if user&.valid_for_authentication?
12
+ tokens = sign_in(user, request)
13
+ render_success(
14
+ "auth.success.login",
15
+ data: {
16
+ user: user_with_profile(user),
17
+ access_token: tokens[:access_token],
18
+ refresh_token: tokens[:refresh_token].refresh_token
19
+ }
20
+ )
21
+ else
22
+ render_error("auth.errors.invalid_credentials", status: :unauthorized)
23
+ end
24
+ end
25
+
26
+ def signup
27
+ user = User.new(user_params)
28
+
29
+ return render_error("auth.errors.missing_email_or_phone") unless user.email.present? || user.phone.present?
30
+
31
+ ActiveRecord::Base.transaction do
32
+ if user.save
33
+ assign_default_role(user)
34
+ render_success(
35
+ "auth.success.signup",
36
+ data: {
37
+ user: user_with_profile(user)
38
+ },
39
+ status: :created
40
+ )
41
+ else
42
+ render_error(
43
+ "auth.errors.signup_failed",
44
+ errors: user.errors.full_messages,
45
+ status: :unprocessable_entity
46
+ )
47
+ end
48
+ end
49
+ end
50
+
51
+ def logout
52
+ sign_out
53
+ render_success("auth.success.logout")
54
+ end
55
+
56
+ def me
57
+ render_success(
58
+ "auth.success.me",
59
+ data: {
60
+ user: user_with_profile(current_user)
61
+ }
62
+ )
63
+ end
64
+
65
+ def refresh
66
+ new_tokens = refresh_token
67
+ if new_tokens
68
+ render_success(
69
+ "auth.success.refresh",
70
+ data: {
71
+ access_token: new_tokens[:access_token]
72
+ }
73
+ )
74
+ else
75
+ render_error("auth.errors.invalid_token", status: :unauthorized)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def user_params
82
+ params.require(:user).permit(
83
+ :email,
84
+ :phone,
85
+ :password,
86
+ :password_confirmation,
87
+ :temp_password,
88
+ user_profile_attributes: %i[first_name last_name date_of_birth address]
89
+ )
90
+ end
91
+
92
+ def refresh_params
93
+ params.permit(:refresh_token)
94
+ end
95
+
96
+ def assign_default_role(user)
97
+ role = Role.find_or_create_by!(name: "user") do |r|
98
+ r.code = "USER"
99
+ end
100
+
101
+ UserRole.create!(user: user, role: role)
102
+ end
103
+
104
+ def user_with_profile(user)
105
+ user.as_json(
106
+ only: %i[id email phone],
107
+ include: {
108
+ user_profile: {only: %i[first_name last_name verification_status]},
109
+ roles: {only: %i[name description]}
110
+ }
111
+ )
112
+ end
113
+
114
+ def authentication_required?
115
+ # Skip authentication for login, signup, and refresh endpoints
116
+ %w[login signup refresh].exclude?(action_name)
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,19 @@
1
+ module Dscf
2
+ module Core
3
+ class AuthenticationError < StandardError
4
+ attr_reader :status_code
5
+
6
+ def initialize(message = "Authentication failed", status_code = 401)
7
+ super(message)
8
+ @status_code = status_code
9
+ end
10
+
11
+ def to_hash
12
+ {
13
+ error: message,
14
+ status: status_code
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end