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
@@ -0,0 +1,58 @@
1
+ module Dscf
2
+ module Core
3
+ module UserAuthenticatable
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_secure_password
8
+
9
+ validates :email, uniqueness: true, allow_blank: true
10
+ validates :phone, uniqueness: true, allow_blank: true
11
+ validate :email_or_phone_present
12
+
13
+ has_many :refresh_tokens, dependent: :destroy, class_name: "Dscf::Core::RefreshToken"
14
+ end
15
+
16
+ def authenticate_with_password(password)
17
+ authenticate(password)
18
+ end
19
+
20
+ def generate_auth_tokens(request)
21
+ AuthService.generate_auth_tokens(self, request)
22
+ end
23
+
24
+ def valid_for_authentication?
25
+ active? && !locked?
26
+ end
27
+
28
+ def active?
29
+ true # Override in specific models if needed
30
+ end
31
+
32
+ def locked?
33
+ false # Override in specific models if needed
34
+ end
35
+
36
+ def track_device(request)
37
+ # Track device information for security
38
+ {
39
+ user_agent: request.user_agent,
40
+ ip_address: request.remote_ip,
41
+ device_id: request.params[:device_id]
42
+ }
43
+ end
44
+
45
+ def revoke_all_tokens
46
+ AuthService.revoke_all_user_tokens(self)
47
+ end
48
+
49
+ private
50
+
51
+ def email_or_phone_present
52
+ return unless email.blank? && phone.blank?
53
+
54
+ errors.add(:base, "Either email or phone must be provided")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ module Dscf
2
+ module Core
3
+ class RefreshToken < ApplicationRecord
4
+ belongs_to :user, class_name: "Dscf::Core::User"
5
+
6
+ validates :refresh_token, presence: true, uniqueness: true
7
+ validates :expires_at, presence: true
8
+ validates :user_id, presence: true
9
+
10
+ # scopes for querying tokens
11
+ scope :active, -> { where("expires_at > ?", Time.current) }
12
+ scope :for_device, ->(device) { where(device: device) }
13
+
14
+ # generate token and set expiry on creation
15
+ before_validation :set_token_and_expiry, on: :create
16
+
17
+ def expired?
18
+ expires_at < Time.current
19
+ end
20
+
21
+ def set_token_and_expiry
22
+ self.refresh_token ||= SecureRandom.hex(32)
23
+ self.expires_at ||= 30.days.from_now
24
+ end
25
+
26
+ def self.generate(user, request)
27
+ create(
28
+ user: user,
29
+ device: request.params[:device_id] || "unknown",
30
+ ip_address: request.remote_ip,
31
+ user_agent: request.user_agent
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,10 +1,7 @@
1
1
  module Dscf
2
2
  module Core
3
3
  class User < ApplicationRecord
4
- has_secure_password
5
-
6
- validates :email, presence: true, uniqueness: true
7
- validates :phone, presence: true, uniqueness: true
4
+ include UserAuthenticatable
8
5
 
9
6
  has_many :user_roles, class_name: "Dscf::Core::UserRole"
10
7
  has_many :roles, through: :user_roles, class_name: "Dscf::Core::Role"
@@ -12,6 +9,16 @@ module Dscf
12
9
  has_many :addresses, dependent: :destroy, class_name: "Dscf::Core::Address"
13
10
  has_many :documents, as: :documentable, dependent: :destroy, class_name: "Dscf::Core::Document"
14
11
  has_one :user_profile, dependent: :destroy, class_name: "Dscf::Core::UserProfile"
12
+
13
+ accepts_nested_attributes_for :user_profile
14
+
15
+ before_create :set_default_temp_password
16
+
17
+ private
18
+
19
+ def set_default_temp_password
20
+ self.temp_password = false if temp_password.nil?
21
+ end
15
22
  end
16
23
  end
17
24
  end
@@ -7,6 +7,25 @@ module Dscf
7
7
  validates :last_name, presence: true
8
8
  validates :watchlist_hit, inclusion: {in: [true, false]}
9
9
  validates :verification_status, presence: true
10
+
11
+ enum :gender, {
12
+ male: 0,
13
+ female: 1
14
+ }
15
+
16
+ enum :pep_status, {
17
+ no_exposure: 0,
18
+ low: 1,
19
+ medium: 2,
20
+ high: 3
21
+ }
22
+
23
+ enum :verification_status, {
24
+ pending: 0,
25
+ verified: 1,
26
+ rejected: 2,
27
+ under_review: 3
28
+ }
10
29
  end
11
30
  end
12
31
  end
@@ -0,0 +1,75 @@
1
+ require "ostruct"
2
+
3
+ module Dscf
4
+ module Core
5
+ class AuthService
6
+ class << self
7
+ def authenticate_user(email_or_phone, password)
8
+ user = find_user_by_email_or_phone(email_or_phone)
9
+ return nil unless user&.authenticate(password)
10
+
11
+ user
12
+ end
13
+
14
+ def generate_auth_tokens(user, request)
15
+ access_token = TokenService.issue(TokenService.access_token_payload(user))
16
+ refresh_token = create_refresh_token(user, request)
17
+
18
+ {
19
+ access_token: access_token,
20
+ refresh_token: refresh_token,
21
+ user: user
22
+ }
23
+ end
24
+
25
+ def refresh_access_token(refresh_token_value, request)
26
+ refresh_token = RefreshToken.active.find_by(refresh_token: refresh_token_value)
27
+ return nil unless refresh_token
28
+
29
+ # Validate device and IP for security
30
+ if refresh_token.ip_address != request.remote_ip
31
+ refresh_token.destroy
32
+ raise AuthenticationError, "Token compromised - IP address changed"
33
+ end
34
+
35
+ user = refresh_token.user
36
+ access_token = TokenService.issue(TokenService.access_token_payload(user))
37
+
38
+ {
39
+ access_token: access_token,
40
+ user: user
41
+ }
42
+ end
43
+
44
+ def revoke_refresh_token(token_value)
45
+ RefreshToken.find_by(refresh_token: token_value)&.destroy
46
+ end
47
+
48
+ def revoke_all_user_tokens(user)
49
+ user.refresh_tokens.destroy_all
50
+ end
51
+
52
+ private
53
+
54
+ def find_user_by_email_or_phone(email_or_phone)
55
+ if email_or_phone.include?("@")
56
+ User.find_by(email: email_or_phone)
57
+ else
58
+ User.find_by(phone: email_or_phone)
59
+ end
60
+ end
61
+
62
+ def create_refresh_token(user, request)
63
+ # Create a simple struct-like object for the refresh token generation
64
+ request_data = OpenStruct.new(
65
+ params: {device_id: request.params[:device_id]},
66
+ remote_ip: request.remote_ip,
67
+ user_agent: request.user_agent
68
+ )
69
+
70
+ RefreshToken.generate(user, request_data)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,55 @@
1
+ require "jwt"
2
+
3
+ module Dscf
4
+ module Core
5
+ class TokenService
6
+ class << self
7
+ def issue(payload, expires_in: 15.minutes)
8
+ payload = payload.merge(exp: Time.current.to_i + expires_in.to_i)
9
+ ::JWT.encode(payload, key, "HS256")
10
+ end
11
+
12
+ def decode(token)
13
+ return nil if token.blank?
14
+
15
+ begin
16
+ decoded = ::JWT.decode(token, key, true, algorithm: "HS256")
17
+ decoded.first
18
+ rescue ::JWT::ExpiredSignature
19
+ raise AuthenticationError, "Token has expired"
20
+ rescue ::JWT::DecodeError
21
+ raise AuthenticationError, "Invalid token"
22
+ end
23
+ end
24
+
25
+ def refresh_token_payload(user, device: nil, ip_address: nil)
26
+ {
27
+ user_id: user.id,
28
+ identifier: user.email || user.phone,
29
+ device: device,
30
+ ip_address: ip_address,
31
+ type: "refresh"
32
+ }
33
+ end
34
+
35
+ def access_token_payload(user)
36
+ {
37
+ user_id: user.id,
38
+ identifier: user.email || user.phone,
39
+ type: "access"
40
+ }
41
+ end
42
+
43
+ def key
44
+ @key ||= ENV.fetch("JWT_SECRET_KEY", nil) ||
45
+ if Rails.env.test?
46
+ "test_jwt_secret_key_for_testing_purposes_only_12345678901234567890"
47
+ else
48
+ Rails.application.credentials.secret_key_base
49
+ end ||
50
+ "fallback_secret_key_for_development_only_12345678901234567890"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,23 @@
1
+ # JWT Configuration
2
+ JWT_CONFIG = {
3
+ algorithm: "HS256",
4
+ access_token_expiry: 15.minutes,
5
+ refresh_token_expiry: 30.days,
6
+ issuer: "dscf-core",
7
+ audience: "dscf-api"
8
+ }.freeze
9
+
10
+ # JWT Secret Key Configuration
11
+ # Priority: ENV["JWT_SECRET_KEY"] > Rails credentials > Rails secret_key_base
12
+ JWT_SECRET_KEY = ENV.fetch("JWT_SECRET_KEY") do
13
+ if Rails.env.test?
14
+ "test_jwt_secret_key_for_testing_purposes_only_12345678901234567890"
15
+ else
16
+ Rails.application.credentials.secret_key_base
17
+ end
18
+ end
19
+
20
+ # # Validate JWT configuration on startup
21
+ # Rails.application.config.after_initialize do
22
+ # raise "JWT_SECRET_KEY must be set and at least 32 characters long" unless JWT_SECRET_KEY.present? && JWT_SECRET_KEY.length >= 32
23
+ # end
@@ -0,0 +1,4 @@
1
+ Ransack.configure do |config|
2
+ config.ignore_unknown_conditions = false
3
+
4
+ end
@@ -0,0 +1,23 @@
1
+ en:
2
+ auth:
3
+ success:
4
+ login: "Login successful."
5
+ signup: "Account created successfully. Please log in to continue."
6
+ logout: "Logged out successfully."
7
+ me: "Profile fetched successfully."
8
+ refresh: "Token refreshed successfully."
9
+ errors:
10
+ invalid_credentials: "Invalid credentials."
11
+ missing_email_or_phone: "Either email or phone must be provided."
12
+ signup_failed: "Failed to create account."
13
+ invalid_token: "Invalid refresh token."
14
+
15
+ role:
16
+ success:
17
+ show: "Role details retrieved successfully"
18
+ create: "Role created successfully"
19
+ update: "Role updated successfully"
20
+ errors:
21
+ create: "Failed to create role"
22
+ update: "Failed to update role"
23
+ show: "Role not found"
data/config/routes.rb CHANGED
@@ -1,2 +1,7 @@
1
1
  Dscf::Core::Engine.routes.draw do
2
+ post "auth/login", to: "auth#login"
3
+ post "auth/logout", to: "auth#logout"
4
+ post "auth/signup", to: "auth#signup"
5
+ post "auth/refresh", to: "auth#refresh"
6
+ get "auth/me", to: "auth#me"
2
7
  end
@@ -5,7 +5,7 @@ class CreateDscfCoreUserProfiles < ActiveRecord::Migration[8.0]
5
5
  t.string :first_name, null: false
6
6
  t.string :middle_name
7
7
  t.string :last_name, null: false
8
- t.string :gender
8
+ t.integer :gender
9
9
  t.string :nationality
10
10
  t.date :date_of_birth
11
11
  t.string :place_of_birth
@@ -15,10 +15,10 @@ class CreateDscfCoreUserProfiles < ActiveRecord::Migration[8.0]
15
15
  t.string :employer_name
16
16
  t.decimal :avg_monthly_income, precision: 18, scale: 2
17
17
  t.decimal :avg_annual_income, precision: 18, scale: 2
18
- t.string :pep_status
18
+ t.integer :pep_status
19
19
  t.decimal :risk_score, precision: 5, scale: 2
20
20
  t.boolean :watchlist_hit, null: false
21
- t.string :verification_status, null: false
21
+ t.integer :verification_status, null: false, default: 0
22
22
 
23
23
  t.timestamps
24
24
  end
@@ -0,0 +1,15 @@
1
+ class CreateDscfCoreRefreshTokens < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :dscf_core_refresh_tokens do |t|
4
+ t.references :user, null: false, foreign_key: {to_table: :dscf_core_users}
5
+ t.string :refresh_token, null: false
6
+ t.datetime :expires_at, null: false
7
+ t.string :device
8
+ t.string :ip_address
9
+ t.string :user_agent
10
+
11
+ t.timestamps
12
+ end
13
+ add_index :dscf_core_refresh_tokens, :refresh_token, unique: true
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ class ChangePepStatusToIntegerInUserProfiles < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_column :dscf_core_user_profiles, :pep_status, :integer
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class MakeEmailAndPhoneOptionalForUsers < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_column_null :dscf_core_users, :email, true
4
+ change_column_null :dscf_core_users, :phone, true
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ class AddDefaultsToUserProfiles < ActiveRecord::Migration[8.0]
2
+ def change
3
+ change_column_default :dscf_core_user_profiles, :watchlist_hit, false
4
+ change_column_default :dscf_core_user_profiles, :verification_status, 0 # pending
5
+ end
6
+ end
@@ -1,5 +1,5 @@
1
1
  module Dscf
2
2
  module Core
3
- VERSION = "0.1.2".freeze
3
+ VERSION = "0.1.3".freeze
4
4
  end
5
5
  end
data/lib/dscf/core.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require "dscf/core/version"
2
2
  require "dscf/core/engine"
3
+ require "ransack"
4
+ require "active_model_serializers"
3
5
 
4
6
  module Dscf
5
7
  module Core
@@ -0,0 +1,25 @@
1
+ FactoryBot.define do
2
+ factory :refresh_token, class: "Dscf::Core::RefreshToken" do
3
+ association :user, factory: :user
4
+ refresh_token { SecureRandom.hex(32) }
5
+ expires_at { 30.days.from_now }
6
+ device { Faker::Device.model_name }
7
+ ip_address { Faker::Internet.ip_v4_address }
8
+ user_agent { Faker::Internet.user_agent }
9
+ end
10
+
11
+ # Factory for expired tokens
12
+ factory :expired_refresh_token, parent: :refresh_token do
13
+ expires_at { 1.day.ago }
14
+ end
15
+
16
+ # Factory for tokens without automatic generation
17
+ factory :manual_refresh_token, parent: :refresh_token do
18
+ refresh_token { nil }
19
+ expires_at { nil }
20
+
21
+ after(:build) do |token|
22
+ token.instance_variable_set(:@skip_before_validation, true)
23
+ end
24
+ end
25
+ end
@@ -4,7 +4,7 @@ FactoryBot.define do
4
4
  first_name { Faker::Name.first_name }
5
5
  middle_name { Faker::Name.middle_name }
6
6
  last_name { Faker::Name.last_name }
7
- gender { %w[Male Female Other].sample }
7
+ gender { %w[male female].sample }
8
8
  nationality { Faker::Address.country }
9
9
  date_of_birth { Faker::Date.birthday(min_age: 18, max_age: 65) }
10
10
  place_of_birth { Faker::Address.city }
@@ -14,9 +14,9 @@ FactoryBot.define do
14
14
  employer_name { Faker::Company.name }
15
15
  avg_monthly_income { Faker::Number.decimal(l_digits: 5, r_digits: 2) }
16
16
  avg_annual_income { Faker::Number.decimal(l_digits: 6, r_digits: 2) }
17
- pep_status { %w[None Low Medium High].sample }
17
+ pep_status { [0, 1, 2, 3].sample }
18
18
  risk_score { Faker::Number.decimal(l_digits: 1, r_digits: 2) }
19
- watchlist_hit { Faker::Boolean.boolean }
20
- verification_status { %w[Pending Verified Rejected].sample }
19
+ watchlist_hit { false }
20
+ verification_status { 0 }
21
21
  end
22
22
  end