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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +32 -0
  5. data/app/controllers/api_engine_base/application_controller.rb +47 -0
  6. data/app/controllers/api_engine_base/auth/plain_text_controller.rb +132 -0
  7. data/app/controllers/api_engine_base/username_controller.rb +26 -0
  8. data/app/controllers/concerns/api_engine_base/schematizable.rb +5 -0
  9. data/app/helpers/api_engine_base/application_helper.rb +4 -0
  10. data/app/helpers/api_engine_base/schema_helper.rb +29 -0
  11. data/app/jobs/api_engine_base/application_job.rb +4 -0
  12. data/app/mailers/api_engine_base/application_mailer.rb +8 -0
  13. data/app/mailers/api_engine_base/email_verification_mailer.rb +12 -0
  14. data/app/models/api_engine_base/application_record.rb +7 -0
  15. data/app/models/user.rb +50 -0
  16. data/app/models/user_secret.rb +72 -0
  17. data/app/services/api_engine_base/argument_validation/class_methods.rb +179 -0
  18. data/app/services/api_engine_base/argument_validation/instance_methods.rb +136 -0
  19. data/app/services/api_engine_base/argument_validation.rb +11 -0
  20. data/app/services/api_engine_base/jwt/authenticate_user.rb +71 -0
  21. data/app/services/api_engine_base/jwt/decode.rb +21 -0
  22. data/app/services/api_engine_base/jwt/encode.rb +15 -0
  23. data/app/services/api_engine_base/jwt/login_create.rb +21 -0
  24. data/app/services/api_engine_base/jwt/time_delay_token.rb +17 -0
  25. data/app/services/api_engine_base/login_strategy/plain_text/create.rb +42 -0
  26. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/generate.rb +29 -0
  27. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/required.rb +20 -0
  28. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/send.rb +23 -0
  29. data/app/services/api_engine_base/login_strategy/plain_text/email_verification/verify.rb +24 -0
  30. data/app/services/api_engine_base/login_strategy/plain_text/login.rb +50 -0
  31. data/app/services/api_engine_base/secrets/cleanse.rb +14 -0
  32. data/app/services/api_engine_base/secrets/generate.rb +62 -0
  33. data/app/services/api_engine_base/secrets/verify.rb +27 -0
  34. data/app/services/api_engine_base/secrets.rb +15 -0
  35. data/app/services/api_engine_base/service_base.rb +90 -0
  36. data/app/services/api_engine_base/service_logging.rb +41 -0
  37. data/app/services/api_engine_base/username/available.rb +64 -0
  38. data/app/views/api_engine_base/email_verification_mailer/verify_email.html.erb +26 -0
  39. data/config/routes.rb +23 -0
  40. data/db/migrate/20241117043720_create_api_engine_base_users.rb +33 -0
  41. data/db/migrate/20241204065708_create_api_engine_base_user_secrets.rb +16 -0
  42. data/lib/api_engine_base/configuration/application/config.rb +40 -0
  43. data/lib/api_engine_base/configuration/base.rb +11 -0
  44. data/lib/api_engine_base/configuration/config.rb +59 -0
  45. data/lib/api_engine_base/configuration/email/config.rb +87 -0
  46. data/lib/api_engine_base/configuration/jwt/config.rb +22 -0
  47. data/lib/api_engine_base/configuration/login/config.rb +18 -0
  48. data/lib/api_engine_base/configuration/login/strategy/plain_text/config.rb +57 -0
  49. data/lib/api_engine_base/configuration/login/strategy/plain_text/email_verify.rb +50 -0
  50. data/lib/api_engine_base/configuration/login/strategy/plain_text/lockable.rb +27 -0
  51. data/lib/api_engine_base/configuration/otp/config.rb +54 -0
  52. data/lib/api_engine_base/configuration/username/check.rb +31 -0
  53. data/lib/api_engine_base/configuration/username/config.rb +41 -0
  54. data/lib/api_engine_base/engine.rb +21 -0
  55. data/lib/api_engine_base/schema/error/base.rb +15 -0
  56. data/lib/api_engine_base/schema/error/invalid_argument.rb +15 -0
  57. data/lib/api_engine_base/schema/error/invalid_argument_response.rb +17 -0
  58. data/lib/api_engine_base/schema/plain_text/create_user_request.rb +18 -0
  59. data/lib/api_engine_base/schema/plain_text/create_user_response.rb +17 -0
  60. data/lib/api_engine_base/schema/plain_text/email_verify_request.rb +11 -0
  61. data/lib/api_engine_base/schema/plain_text/email_verify_response.rb +11 -0
  62. data/lib/api_engine_base/schema/plain_text/email_verify_send_request.rb +9 -0
  63. data/lib/api_engine_base/schema/plain_text/email_verify_send_response.rb +11 -0
  64. data/lib/api_engine_base/schema/plain_text/login_request.rb +15 -0
  65. data/lib/api_engine_base/schema/plain_text/login_response.rb +13 -0
  66. data/lib/api_engine_base/schema.rb +25 -0
  67. data/lib/api_engine_base/spec_helper.rb +18 -0
  68. data/lib/api_engine_base/version.rb +5 -0
  69. data/lib/api_engine_base.rb +33 -0
  70. data/lib/generators/api_engine_base/configure/USAGE +8 -0
  71. data/lib/generators/api_engine_base/configure/configure_generator.rb +12 -0
  72. data/lib/tasks/auto_annotate_models.rake +60 -0
  73. 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