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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/dscf/core/authenticatable.rb +81 -0
- data/app/controllers/concerns/dscf/core/common.rb +200 -0
- data/app/controllers/concerns/dscf/core/filterable.rb +12 -0
- data/app/controllers/concerns/dscf/core/json_response.rb +66 -0
- data/app/controllers/concerns/dscf/core/pagination.rb +71 -0
- data/app/controllers/concerns/dscf/core/token_authenticatable.rb +53 -0
- data/app/controllers/dscf/core/application_controller.rb +19 -0
- data/app/controllers/dscf/core/auth_controller.rb +120 -0
- data/app/errors/dscf/core/authentication_error.rb +19 -0
- data/app/models/concerns/dscf/core/user_authenticatable.rb +58 -0
- data/app/models/dscf/core/refresh_token.rb +36 -0
- data/app/models/dscf/core/user.rb +11 -4
- data/app/models/dscf/core/user_profile.rb +19 -0
- data/app/services/dscf/core/auth_service.rb +75 -0
- data/app/services/dscf/core/token_service.rb +55 -0
- data/config/initializers/jwt.rb +23 -0
- data/config/initializers/ransack.rb +4 -0
- data/config/locales/en.yml +23 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20250822092031_create_dscf_core_user_profiles.rb +3 -3
- data/db/migrate/20250824114725_create_dscf_core_refresh_tokens.rb +15 -0
- data/db/migrate/20250824191957_change_pep_status_to_integer_in_user_profiles.rb +5 -0
- data/db/migrate/20250824200927_make_email_and_phone_optional_for_users.rb +6 -0
- data/db/migrate/20250825192113_add_defaults_to_user_profiles.rb +6 -0
- data/lib/dscf/core/version.rb +1 -1
- data/lib/dscf/core.rb +2 -0
- data/spec/factories/dscf/core/refresh_tokens.rb +25 -0
- data/spec/factories/dscf/core/user_profiles.rb +4 -4
- 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
|
-
|
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,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
@@ -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.
|
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.
|
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.
|
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
|
data/lib/dscf/core/version.rb
CHANGED
data/lib/dscf/core.rb
CHANGED
@@ -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[
|
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 {
|
17
|
+
pep_status { [0, 1, 2, 3].sample }
|
18
18
|
risk_score { Faker::Number.decimal(l_digits: 1, r_digits: 2) }
|
19
|
-
watchlist_hit {
|
20
|
-
verification_status {
|
19
|
+
watchlist_hit { false }
|
20
|
+
verification_status { 0 }
|
21
21
|
end
|
22
22
|
end
|