api_engine_base 0.1.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +32 -0
- data/app/controllers/api_engine_base/application_controller.rb +47 -0
- data/app/controllers/api_engine_base/auth/plain_text_controller.rb +132 -0
- data/app/controllers/api_engine_base/username_controller.rb +26 -0
- data/app/controllers/concerns/api_engine_base/schematizable.rb +5 -0
- data/app/helpers/api_engine_base/application_helper.rb +4 -0
- data/app/helpers/api_engine_base/schema_helper.rb +29 -0
- data/app/jobs/api_engine_base/application_job.rb +4 -0
- data/app/mailers/api_engine_base/application_mailer.rb +8 -0
- data/app/mailers/api_engine_base/email_verification_mailer.rb +12 -0
- data/app/models/api_engine_base/application_record.rb +7 -0
- data/app/models/user.rb +50 -0
- data/app/models/user_secret.rb +72 -0
- data/app/services/api_engine_base/argument_validation/class_methods.rb +179 -0
- data/app/services/api_engine_base/argument_validation/instance_methods.rb +136 -0
- data/app/services/api_engine_base/argument_validation.rb +11 -0
- data/app/services/api_engine_base/jwt/authenticate_user.rb +71 -0
- data/app/services/api_engine_base/jwt/decode.rb +21 -0
- data/app/services/api_engine_base/jwt/encode.rb +15 -0
- data/app/services/api_engine_base/jwt/login_create.rb +21 -0
- data/app/services/api_engine_base/jwt/time_delay_token.rb +17 -0
- data/app/services/api_engine_base/login_strategy/plain_text/create.rb +42 -0
- data/app/services/api_engine_base/login_strategy/plain_text/email_verification/generate.rb +29 -0
- data/app/services/api_engine_base/login_strategy/plain_text/email_verification/required.rb +20 -0
- data/app/services/api_engine_base/login_strategy/plain_text/email_verification/send.rb +23 -0
- data/app/services/api_engine_base/login_strategy/plain_text/email_verification/verify.rb +24 -0
- data/app/services/api_engine_base/login_strategy/plain_text/login.rb +50 -0
- data/app/services/api_engine_base/secrets/cleanse.rb +14 -0
- data/app/services/api_engine_base/secrets/generate.rb +62 -0
- data/app/services/api_engine_base/secrets/verify.rb +27 -0
- data/app/services/api_engine_base/secrets.rb +15 -0
- data/app/services/api_engine_base/service_base.rb +90 -0
- data/app/services/api_engine_base/service_logging.rb +41 -0
- data/app/services/api_engine_base/username/available.rb +64 -0
- data/app/views/api_engine_base/email_verification_mailer/verify_email.html.erb +26 -0
- data/config/routes.rb +23 -0
- data/db/migrate/20241117043720_create_api_engine_base_users.rb +33 -0
- data/db/migrate/20241204065708_create_api_engine_base_user_secrets.rb +16 -0
- data/lib/api_engine_base/configuration/application/config.rb +40 -0
- data/lib/api_engine_base/configuration/base.rb +11 -0
- data/lib/api_engine_base/configuration/config.rb +59 -0
- data/lib/api_engine_base/configuration/email/config.rb +87 -0
- data/lib/api_engine_base/configuration/jwt/config.rb +22 -0
- data/lib/api_engine_base/configuration/login/config.rb +18 -0
- data/lib/api_engine_base/configuration/login/strategy/plain_text/config.rb +57 -0
- data/lib/api_engine_base/configuration/login/strategy/plain_text/email_verify.rb +50 -0
- data/lib/api_engine_base/configuration/login/strategy/plain_text/lockable.rb +27 -0
- data/lib/api_engine_base/configuration/otp/config.rb +54 -0
- data/lib/api_engine_base/configuration/username/check.rb +31 -0
- data/lib/api_engine_base/configuration/username/config.rb +41 -0
- data/lib/api_engine_base/engine.rb +21 -0
- data/lib/api_engine_base/schema/error/base.rb +15 -0
- data/lib/api_engine_base/schema/error/invalid_argument.rb +15 -0
- data/lib/api_engine_base/schema/error/invalid_argument_response.rb +17 -0
- data/lib/api_engine_base/schema/plain_text/create_user_request.rb +18 -0
- data/lib/api_engine_base/schema/plain_text/create_user_response.rb +17 -0
- data/lib/api_engine_base/schema/plain_text/email_verify_request.rb +11 -0
- data/lib/api_engine_base/schema/plain_text/email_verify_response.rb +11 -0
- data/lib/api_engine_base/schema/plain_text/email_verify_send_request.rb +9 -0
- data/lib/api_engine_base/schema/plain_text/email_verify_send_response.rb +11 -0
- data/lib/api_engine_base/schema/plain_text/login_request.rb +15 -0
- data/lib/api_engine_base/schema/plain_text/login_response.rb +13 -0
- data/lib/api_engine_base/schema.rb +25 -0
- data/lib/api_engine_base/spec_helper.rb +18 -0
- data/lib/api_engine_base/version.rb +5 -0
- data/lib/api_engine_base.rb +33 -0
- data/lib/generators/api_engine_base/configure/USAGE +8 -0
- data/lib/generators/api_engine_base/configure/configure_generator.rb +12 -0
- data/lib/tasks/auto_annotate_models.rake +60 -0
- metadata +216 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::ArgumentValidation
|
|
4
|
+
module InstanceMethods
|
|
5
|
+
def run_validations!
|
|
6
|
+
context.valid_arguments = true
|
|
7
|
+
|
|
8
|
+
validate_param!
|
|
9
|
+
validate_compositions!
|
|
10
|
+
continue_with_logical_code!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def continue_with_logical_code!
|
|
14
|
+
return if @context_validation_failures.nil?
|
|
15
|
+
|
|
16
|
+
invalid_argument_keys = @context_validation_failures.keys
|
|
17
|
+
msg = @context_validation_failures.map { |_k, obj| obj[:msg] }.join(", ")
|
|
18
|
+
context.fail!(msg:, invalid_argument_hash: @context_validation_failures, invalid_argument_keys:, invalid_arguments: true)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def inline_argument_failure!(errors:)
|
|
22
|
+
errors = errors.to_hash
|
|
23
|
+
invalid_argument_keys = errors.keys
|
|
24
|
+
invalid_argument_hash = {}
|
|
25
|
+
human_readable = []
|
|
26
|
+
|
|
27
|
+
errors.each do |k, v|
|
|
28
|
+
error_message = Array(v).join(", ")
|
|
29
|
+
invalid_argument_hash[k] = { msg: error_message }
|
|
30
|
+
human_readable << "#{k}: #{error_message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
msg = "Invalid arguments: #{human_readable.join(", ")}"
|
|
34
|
+
context.fail!(msg:, invalid_argument_hash:, invalid_argument_keys:, invalid_arguments: true)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_param!
|
|
38
|
+
self.class.validate_params.each do |metadata|
|
|
39
|
+
value = context.public_send(metadata[:name])
|
|
40
|
+
use_length = metadata[:length]
|
|
41
|
+
if metadata[:required] && value.nil?
|
|
42
|
+
__failed_argument_validation(msg: "Parameter [#{metadata[:name]}] is required but not present", argument: metadata[:name], metadata:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if value.nil? && metadata[:default]
|
|
46
|
+
context.public_send(:"#{metadata[:name]}=", metadata[:default])
|
|
47
|
+
next
|
|
48
|
+
elsif value.nil?
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if is_a = metadata[:is_a]
|
|
53
|
+
if Array(is_a).none? { _1 === value }
|
|
54
|
+
__failed_argument_validation(msg: "Parameter [#{metadata[:name]}] must be of type #{is_a}. Given #{value.class} [#{value}]", argument: metadata[:name], metadata:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if metadata[:is_one]
|
|
59
|
+
if Array(metadata[:is_one]).none? { _1 == value }
|
|
60
|
+
__failed_argument_validation(msg: "Parameter [#{metadata[:name]}] must be one of #{Array(metadata[:is_one])}. Given #{value}", argument: metadata[:name], metadata:)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
validate_sign!(name: metadata[:name], value:, sign: "lt", validation: metadata[:lte], use_length:, metadata:) { (use_length ? value.length : value) <= _1 }
|
|
65
|
+
validate_sign!(name: metadata[:name], value:, sign: "lte", validation: metadata[:lt], use_length:, metadata:) { (use_length ? value.length : value) < _1 }
|
|
66
|
+
validate_sign!(name: metadata[:name], value:, sign: "eq", validation: metadata[:eq], use_length:, metadata:) { (use_length ? value.length : value) == _1 }
|
|
67
|
+
validate_sign!(name: metadata[:name], value:, sign: "gte", validation: metadata[:gte], use_length:, metadata:) { (use_length ? value.length : value) >= _1 }
|
|
68
|
+
validate_sign!(name: metadata[:name], value:, sign: "gt", validation: metadata[:gt], use_length:, metadata:) { (use_length ? value.length : value) > _1 }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_compositions!
|
|
73
|
+
self.class.compositions.each do |type, metadata|
|
|
74
|
+
value_list = {}
|
|
75
|
+
|
|
76
|
+
metadata[:keys].each do |argument|
|
|
77
|
+
value = context.public_send(argument)
|
|
78
|
+
next if value.nil?
|
|
79
|
+
|
|
80
|
+
value_list[argument] = value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
composition_result = metadata[:validation_proc].(value_list.count, value_list.keys)
|
|
84
|
+
|
|
85
|
+
next if value_list.count == 0 && !metadata[:required]
|
|
86
|
+
|
|
87
|
+
if !composition_result[:is_valid]
|
|
88
|
+
composition_result[:message]
|
|
89
|
+
context.client_composite_error = composition_result[:requirement]
|
|
90
|
+
msg = "Composite Key failure for #{type} [#{metadata[:name]}]. #{composition_result[:message]}. Provided values for the following keys: #{value_list.keys}. Available keys #{metadata[:keys]}"
|
|
91
|
+
__failed_argument_validation(msg:, argument: metadata[:name], metadata: ,error: ApiEngineBase::ServiceBase::CompositionValidationError)
|
|
92
|
+
next
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if metadata[:delegation]
|
|
96
|
+
context.public_send("#{metadata[:name]}=", value_list.first[1])
|
|
97
|
+
context.public_send("#{metadata[:name]}_key=", value_list.first[0])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_sign!(name:, value:, sign:, validation:, use_length:, metadata:)
|
|
103
|
+
return if validation.nil?
|
|
104
|
+
return if yield(validation)
|
|
105
|
+
|
|
106
|
+
__failed_argument_validation(metadata:, msg: "Parameter [#{name}]#{ " lengths" if use_length} must be #{sign} to #{validation}. Given #{value}", argument: name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def __failed_argument_validation(msg:, argument:, metadata:, error: ApiEngineBase::ServiceBase::ArgumentValidationError)
|
|
110
|
+
case self.class.on_argument_validation_assigned
|
|
111
|
+
when :raise
|
|
112
|
+
raise error, msg
|
|
113
|
+
when :fail_early
|
|
114
|
+
@context_validation_failures ||= {}
|
|
115
|
+
@context_validation_failures[argument] = {
|
|
116
|
+
msg: msg,
|
|
117
|
+
required: metadata[:requirement],
|
|
118
|
+
is_a: metadata[:is_a],
|
|
119
|
+
}
|
|
120
|
+
# When gracefully failing, it will find all failures first before setting appropriate
|
|
121
|
+
# context variables -- Check out continue_with_logical_code!
|
|
122
|
+
else
|
|
123
|
+
context.invalid_arguments = true
|
|
124
|
+
log_warn(msg)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sanitize_params
|
|
129
|
+
self.class.sensitive_params.each do |param|
|
|
130
|
+
next if context.send(param).nil?
|
|
131
|
+
|
|
132
|
+
context.send("#{param}=","[FILTERED]")
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::ArgumentValidation
|
|
4
|
+
class NameConflictError < ApiEngineBase::ServiceBase::ConfigurationError; end
|
|
5
|
+
class NestedDuplicateTypeError < ApiEngineBase::ServiceBase::ConfigurationError; end
|
|
6
|
+
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ApiEngineBase::ArgumentValidation::ClassMethods)
|
|
9
|
+
base.include(ApiEngineBase::ArgumentValidation::InstanceMethods)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Jwt
|
|
4
|
+
class AuthenticateUser < ApiEngineBase::ServiceBase
|
|
5
|
+
|
|
6
|
+
validate :token, is_a: String, required: true, sensitive: true
|
|
7
|
+
validate :bypass_email_validation, is_one: [true, false], default: false
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
result = Decode.(token:)
|
|
11
|
+
|
|
12
|
+
if result.failure?
|
|
13
|
+
context.fail!(msg: "Unauthorized Access. Invalid Authorization token")
|
|
14
|
+
end
|
|
15
|
+
payload = result.payload
|
|
16
|
+
|
|
17
|
+
validate_expires_at!(expires_at: payload[:expires_at])
|
|
18
|
+
|
|
19
|
+
user = User.find(payload[:user_id]) rescue nil
|
|
20
|
+
if user.nil?
|
|
21
|
+
log_warn("user_id [#{payload[:user_id]}] was not found. Cannot Continue")
|
|
22
|
+
context.fail!(msg: "Unauthorized Access. Invalid Authorization token")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if user.verifier_token == payload[:verifier_token]
|
|
26
|
+
context.user = user
|
|
27
|
+
else
|
|
28
|
+
context.fail!(msg: "Unauthorized Access. Token is no longer valid")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
email_validation_required!(user:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_expires_at!(expires_at:)
|
|
35
|
+
if expires_at.nil?
|
|
36
|
+
log_warn("expires_at payload is missing from the JWT token. Cannot continue")
|
|
37
|
+
context.fail!(msg: "Unauthorized Access. Invalid Authorization token")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
expires_time = Time.at(expires_at) rescue nil
|
|
41
|
+
|
|
42
|
+
if expires_time.nil?
|
|
43
|
+
log_warn("expires_at payload cannot be parsed. Cannot continue")
|
|
44
|
+
context.fail!(msg: "Unauthorized Access. Invalid Authorization token")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if expires_time < Time.now
|
|
48
|
+
log_warn("expires_at is no longer valid. Must request new token")
|
|
49
|
+
context.fail!(msg: "Unauthorized Access. Invalid Authorization token")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def email_validation_required!(user:)
|
|
54
|
+
return unless ApiEngineBase.config.login.plain_text.email_verify?
|
|
55
|
+
|
|
56
|
+
if bypass_email_validation
|
|
57
|
+
log_info("Bypassing email validation without checking if user should be able to continue")
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
return if user.email_validated
|
|
62
|
+
|
|
63
|
+
log_info("User's email is not yet validated.")
|
|
64
|
+
result = ApiEngineBase::LoginStrategy::PlainText::EmailVerification::Required.(user:)
|
|
65
|
+
|
|
66
|
+
if result.required
|
|
67
|
+
context.fail!(msg: "User's Email must be validated before they can continue")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module ApiEngineBase::Jwt
|
|
6
|
+
class Decode < ApiEngineBase::ServiceBase
|
|
7
|
+
|
|
8
|
+
validate :token, is_a: String, required: true, sensitive: true
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
data = JWT.decode(token, ApiEngineBase.config.jwt.hmac_secret, true, { algorithm: "HS256" })
|
|
12
|
+
|
|
13
|
+
context.payload = data[0].with_indifferent_access
|
|
14
|
+
context.headers = data[1].with_indifferent_access
|
|
15
|
+
rescue JWT::DecodeError => e
|
|
16
|
+
log_error(e)
|
|
17
|
+
|
|
18
|
+
context.fail!(msg: "Invalid Token")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "jwt"
|
|
4
|
+
|
|
5
|
+
module ApiEngineBase::Jwt
|
|
6
|
+
class Encode < ApiEngineBase::ServiceBase
|
|
7
|
+
|
|
8
|
+
validate :payload, is_a: Hash, required: true
|
|
9
|
+
validate :header, is_a: Hash, required: false
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
context.token = JWT.encode(payload, ApiEngineBase.config.jwt.hmac_secret, "HS256", header || {})
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Jwt
|
|
4
|
+
class LoginCreate < ApiEngineBase::ServiceBase
|
|
5
|
+
on_argument_validation :fail_early
|
|
6
|
+
|
|
7
|
+
validate :user, is_a: User, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
context.token = Encode.(payload:).token
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def payload
|
|
14
|
+
{
|
|
15
|
+
expires_at: ApiEngineBase.config.jwt.ttl.from_now.to_i,
|
|
16
|
+
user_id: user.id,
|
|
17
|
+
verifier_token: user.retreive_verifier_token!,
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Jwt
|
|
4
|
+
class TimeDelayToken < ApiEngineBase::ServiceBase
|
|
5
|
+
on_argument_validation :fail_early
|
|
6
|
+
|
|
7
|
+
validate :expires_in, is_a: ActiveSupport::Duration, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
context.token = Encode.(payload:).token
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def payload
|
|
14
|
+
{ expires_in: expires_in }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::LoginStrategy::PlainText
|
|
4
|
+
class Create < ApiEngineBase::ServiceBase
|
|
5
|
+
on_argument_validation :fail_early
|
|
6
|
+
|
|
7
|
+
EASY_GETTER = ApiEngineBase.config.login.plain_text
|
|
8
|
+
|
|
9
|
+
validate :first_name, is_a: String, required: true
|
|
10
|
+
validate :last_name, is_a: String, required: true
|
|
11
|
+
validate :username, is_a: String, required: true
|
|
12
|
+
validate :email, length: true, lt: EASY_GETTER.email_length_max, gt: EASY_GETTER.email_length_min, is_a: String, required: true, sensitive: true
|
|
13
|
+
validate :password, length: true, lt: EASY_GETTER.password_length_max, gt: EASY_GETTER.password_length_min, is_a: String, required: true, sensitive: true
|
|
14
|
+
validate :password_confirmation, is_a: String, required: true, sensitive: true
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
unless email =~ URI::MailTo::EMAIL_REGEXP
|
|
18
|
+
inline_argument_failure!(errors: { email: "Invalid email address" })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
username_validity = ApiEngineBase::Username::Available.(username:)
|
|
22
|
+
if !username_validity.valid
|
|
23
|
+
inline_argument_failure!(errors: { username: "Username is invalid. #{ApiEngineBase.config.username.username_failure_message}" })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
user = User.new(
|
|
27
|
+
first_name:,
|
|
28
|
+
last_name:,
|
|
29
|
+
username:,
|
|
30
|
+
email:,
|
|
31
|
+
password:,
|
|
32
|
+
password_confirmation:,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if user.save
|
|
36
|
+
context.user = user
|
|
37
|
+
else
|
|
38
|
+
inline_argument_failure!(errors: user.errors)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::LoginStrategy::PlainText::EmailVerification
|
|
4
|
+
class Generate < ApiEngineBase::ServiceBase
|
|
5
|
+
validate :user, is_a: User, required: true
|
|
6
|
+
|
|
7
|
+
def call
|
|
8
|
+
result = ApiEngineBase::Secrets::Generate.(
|
|
9
|
+
user:,
|
|
10
|
+
secret_length: email_verify.verify_code_length,
|
|
11
|
+
reason: ApiEngineBase::Secrets::EMAIL_VERIFICIATION,
|
|
12
|
+
use_count_max: 1,
|
|
13
|
+
death_time: email_verify.verify_code_link_valid_for,
|
|
14
|
+
type: ApiEngineBase::Secrets::NUMERIC,
|
|
15
|
+
cleanse: true,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if result.failure?
|
|
19
|
+
context.fail!(msg: "Secret Generation is not available at this time")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
context.secret = result.secret
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def email_verify
|
|
26
|
+
ApiEngineBase.config.login.plain_text.email_verify
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::LoginStrategy::PlainText::EmailVerification
|
|
4
|
+
class Required < ApiEngineBase::ServiceBase
|
|
5
|
+
validate :user, is_a: User, required: true
|
|
6
|
+
|
|
7
|
+
def call
|
|
8
|
+
context.reqired_after_time = reqired_after_time
|
|
9
|
+
context.required = Time.now > reqired_after_time
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def reqired_after_time
|
|
13
|
+
user.created_at + email_verify.verify_email_required_within
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def email_verify
|
|
17
|
+
ApiEngineBase.config.login.plain_text.email_verify
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::LoginStrategy::PlainText::EmailVerification
|
|
4
|
+
class Send < ApiEngineBase::ServiceBase
|
|
5
|
+
on_argument_validation :fail_early
|
|
6
|
+
|
|
7
|
+
validate :user, is_a: User, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
result = Generate.(user:)
|
|
11
|
+
if result.failure?
|
|
12
|
+
context.fail!(msg: result.msg)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
ApiEngineBase::EmailVerificationMailer.verify_email(user.email, user, result.secret).deliver
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
log_error("Failed to send message to [#{user.id}]: #{e.message}")
|
|
19
|
+
context.fail!(msg: "Unable to send email. Please try again later", status: 500)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::LoginStrategy::PlainText::EmailVerification
|
|
4
|
+
class Verify < ApiEngineBase::ServiceBase
|
|
5
|
+
on_argument_validation :fail_early
|
|
6
|
+
|
|
7
|
+
validate :user, is_a: User, required: true
|
|
8
|
+
validate :code, is_a: String, required: true
|
|
9
|
+
|
|
10
|
+
def call
|
|
11
|
+
result = ApiEngineBase::Secrets::Verify.(secret: code, reason: ApiEngineBase::Secrets::EMAIL_VERIFICIATION)
|
|
12
|
+
if result.failure?
|
|
13
|
+
inline_argument_failure!(errors: { code: "Incorrect verification code provided" })
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if result.user != user
|
|
17
|
+
log_warn("Yikes! The logged in user does not match the correct code. Kick them back out and do not verify")
|
|
18
|
+
inline_argument_failure!(errors: { code: "Incorrect verification code provided" })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
user.update(email_validated: true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::LoginStrategy::PlainText
|
|
4
|
+
class Login < ApiEngineBase::ServiceBase
|
|
5
|
+
on_argument_validation :fail_early
|
|
6
|
+
|
|
7
|
+
one_of(:login_key, required: true) do
|
|
8
|
+
validate :username, is_a: String, sensitive: true
|
|
9
|
+
validate :email, is_a: String, sensitive: true
|
|
10
|
+
end
|
|
11
|
+
validate :password, is_a: String, required: true, sensitive: true
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
if user.nil?
|
|
15
|
+
log_warn("Login attempted with [#{login_key_key}] => [#{login_key}]. Resource not found")
|
|
16
|
+
credential_mismatch!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if user.authenticate(password)
|
|
20
|
+
user.successful_login += 1
|
|
21
|
+
user.password_consecutive_fail = 0
|
|
22
|
+
user.save
|
|
23
|
+
else
|
|
24
|
+
user.password_consecutive_fail += 1
|
|
25
|
+
user.save
|
|
26
|
+
log_warn("Valid #{login_key_key}. Incorrect password. Consecutive Password failures: #{user.password_consecutive_fail}")
|
|
27
|
+
credential_mismatch!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
context.user = user
|
|
31
|
+
|
|
32
|
+
result = ApiEngineBase::Jwt::LoginCreate.(user:)
|
|
33
|
+
if result.failure?
|
|
34
|
+
context.fail!(msg: "Failed to generate Authorization. Please Try again")
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
context.token = result.token
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def credential_mismatch!
|
|
42
|
+
msg = "Unauthorized Access. Incorrect Credentials"
|
|
43
|
+
inline_argument_failure!(errors: { login_key_key => msg, password: msg })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def user
|
|
47
|
+
@user ||= User.where(login_key_key => login_key).first
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Secrets
|
|
4
|
+
class Cleanse < ApiEngineBase::ServiceBase
|
|
5
|
+
validate :user, is_a: User, required: true
|
|
6
|
+
validate :reason, is_one: ALLOWED_SECRET_REASONS, required: true
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
secrets = UserSecret.where(user:, reason:)
|
|
10
|
+
count = secrets.delete_all
|
|
11
|
+
log_info("Cleansed #{count} #{reason} secret(s) from the store for user [#{user.id}]")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Secrets
|
|
4
|
+
class Generate < ApiEngineBase::ServiceBase
|
|
5
|
+
MAX_RETRY = 10
|
|
6
|
+
|
|
7
|
+
validate :user, is_a: User, required: true
|
|
8
|
+
validate :secret_length, is_a: Integer, gte: 4, lte: 64, required: true
|
|
9
|
+
validate :reason, is_one: ALLOWED_SECRET_REASONS, required: true
|
|
10
|
+
validate :type, is_one: ALLOWED_SECRET_TYPES, default: DEFAULT_SECRET_TYPE
|
|
11
|
+
validate :extra, is_a: String, length: true, lt: 256
|
|
12
|
+
validate :cleanse, is_one: [true, false], default: false
|
|
13
|
+
at_least_one(:death, required: true) do
|
|
14
|
+
validate :death_time, is_a: ActiveSupport::Duration, gte: 10.seconds
|
|
15
|
+
validate :use_count_max, is_a: Integer, gte: 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
if cleanse && @attempts.nil?
|
|
20
|
+
# if this fails ... so be it
|
|
21
|
+
Cleanse.(user:, reason:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@attempts ||= 1
|
|
25
|
+
record = UserSecret.create!(**db_params)
|
|
26
|
+
|
|
27
|
+
context.record = record
|
|
28
|
+
context.secret = record.secret
|
|
29
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
30
|
+
if @attempts < MAX_RETRY
|
|
31
|
+
@attempts += 1
|
|
32
|
+
log_warn("Duplicate Secret was generated. Attempting to retry: #{@attempts} of #{MAX_RETRY}")
|
|
33
|
+
retry
|
|
34
|
+
else
|
|
35
|
+
log_error("Duplicate Secret was generated. Exhausted Max attempts of #{MAX_RETRY}.")
|
|
36
|
+
context.fail!(msg: "Failed to generate Secret. Cannot Continue")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def db_params
|
|
41
|
+
{
|
|
42
|
+
death_time: death_time&.from_now,
|
|
43
|
+
use_count_max:,
|
|
44
|
+
extra:,
|
|
45
|
+
reason:,
|
|
46
|
+
secret: generate_secret,
|
|
47
|
+
user:,
|
|
48
|
+
}.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def generate_secret
|
|
52
|
+
case type
|
|
53
|
+
when :numeric
|
|
54
|
+
secret_length.times.map { SecureRandom.rand(0...10) }.join
|
|
55
|
+
when :alphanumeric, :hex
|
|
56
|
+
SecureRandom.public_send(type, secret_length)
|
|
57
|
+
when :uuid
|
|
58
|
+
SecureRandom.public_send(type)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Secrets
|
|
4
|
+
class Verify < ApiEngineBase::ServiceBase
|
|
5
|
+
validate :secret, is_a: String, required: true, sensitive: true
|
|
6
|
+
validate :reason, is_one: ALLOWED_SECRET_REASONS, required: true
|
|
7
|
+
validate :access_count, is_one: [true, false], default: false
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
record = UserSecret.find_record(secret:, reason:, access_count:)
|
|
11
|
+
|
|
12
|
+
if record[:found] == false
|
|
13
|
+
context.fail!(record:, msg: "Secret not found")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if record[:valid] == false
|
|
17
|
+
if ApiEngineBase.config.delete_secret_after_invalid
|
|
18
|
+
record[:record].destroy
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
context.fail!(record:, msg: "Secret is invalid. #{record[:record].invalid_reason.join(" ")}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
context.user = record[:user]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ApiEngineBase::Secrets
|
|
4
|
+
ALLOWED_SECRET_REASONS = [
|
|
5
|
+
EMAIL_VERIFICIATION = :email_verification,
|
|
6
|
+
SSO = :sso,
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
ALLOWED_SECRET_TYPES = [
|
|
10
|
+
DEFAULT_SECRET_TYPE = ALPHANUMERIC = :alphanumeric,
|
|
11
|
+
HEX = :hex,
|
|
12
|
+
NUMERIC = :numeric,
|
|
13
|
+
UUID = :uuid,
|
|
14
|
+
]
|
|
15
|
+
end
|