api_engine_base 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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