spree_gladly 1.0.0

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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +45 -0
  5. data/.travis.yml +69 -0
  6. data/Appraisals +63 -0
  7. data/CODE_OF_CONDUCT.md +128 -0
  8. data/Gemfile +7 -0
  9. data/LICENSE +11 -0
  10. data/README.md +473 -0
  11. data/Rakefile +23 -0
  12. data/app/concerns/customer/database_adapter.rb +21 -0
  13. data/app/controllers/application_controller.rb +2 -0
  14. data/app/controllers/spree/admin/gladly_settings_controller.rb +38 -0
  15. data/app/controllers/spree/api/v1/customers_controller.rb +60 -0
  16. data/app/finders/customer/base_lookup.rb +37 -0
  17. data/app/finders/customer/basic_lookup.rb +26 -0
  18. data/app/finders/customer/detailed_lookup.rb +19 -0
  19. data/app/finders/customer/guest/basic_finder.rb +37 -0
  20. data/app/finders/customer/guest/detailed_finder.rb +42 -0
  21. data/app/finders/customer/registered/basic_finder.rb +68 -0
  22. data/app/finders/customer/registered/detailed_finder.rb +43 -0
  23. data/app/models/spree_gladly/configuration.rb +25 -0
  24. data/app/overrides/add_gladly_admin_menu_links.rb +10 -0
  25. data/app/presenters/customer/address_presenter.rb +27 -0
  26. data/app/presenters/customer/basic_lookup_presenter.rb +29 -0
  27. data/app/presenters/customer/detailed_lookup_presenter.rb +30 -0
  28. data/app/presenters/customer/guest/basic_presenter.rb +53 -0
  29. data/app/presenters/customer/guest/detailed_presenter.rb +117 -0
  30. data/app/presenters/customer/registered/basic_presenter.rb +60 -0
  31. data/app/presenters/customer/registered/detailed_presenter.rb +137 -0
  32. data/app/services/auth/authorization_header.rb +35 -0
  33. data/app/services/auth/error.rb +4 -0
  34. data/app/services/auth/header_parse_error.rb +4 -0
  35. data/app/services/auth/invalid_signature_error.rb +4 -0
  36. data/app/services/auth/missing_key_error.rb +4 -0
  37. data/app/services/auth/request_normalizer.rb +37 -0
  38. data/app/services/auth/signature_validator.rb +68 -0
  39. data/app/services/auth/time_header.rb +25 -0
  40. data/app/validators/lookup_validator.rb +89 -0
  41. data/app/validators/validation_result.rb +25 -0
  42. data/app/views/spree/admin/gladly_settings/edit.html.erb +25 -0
  43. data/bin/console +15 -0
  44. data/bin/setup +8 -0
  45. data/config/locales/en.yml +25 -0
  46. data/config/routes.rb +13 -0
  47. data/gemfiles/spree_3_0.gemfile +16 -0
  48. data/gemfiles/spree_3_1.gemfile +16 -0
  49. data/gemfiles/spree_3_7.gemfile +11 -0
  50. data/gemfiles/spree_4_0.gemfile +11 -0
  51. data/gemfiles/spree_4_1.gemfile +11 -0
  52. data/gemfiles/spree_4_2.gemfile +11 -0
  53. data/gemfiles/spree_master.gemfile +11 -0
  54. data/lib/generators/spree_gladly/install/install_generator.rb +17 -0
  55. data/lib/generators/spree_gladly/install/templates/config/initializers/spree_gladly.rb +18 -0
  56. data/lib/spree_gladly.rb +13 -0
  57. data/lib/spree_gladly/engine.rb +25 -0
  58. data/lib/spree_gladly/factories.rb +9 -0
  59. data/lib/spree_gladly/version.rb +5 -0
  60. data/spree.png +0 -0
  61. data/spree_gladly.gemspec +35 -0
  62. metadata +201 -0
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auth
4
+ class AuthorizationHeader
5
+ AUTHORIZATION_HEADER_FORMAT =
6
+ /\ASigningAlgorithm=([[-a-z0-9]]+), SignedHeaders=([[-a-z0-9;]]+), Signature=([[a-f0-9]]+)\Z/.freeze
7
+
8
+ attr_reader :signing_algorithm_name, :signing_algorithm, :signed_headers, :signature
9
+
10
+ def initialize(header)
11
+ match = AUTHORIZATION_HEADER_FORMAT.match(header)
12
+ raise HeaderParseError, 'Unsupported Gladly-Authorization header format' if match.nil?
13
+
14
+ @signing_algorithm_name = match[1]
15
+ @signing_algorithm = parse_signing_algorithm!(@signing_algorithm_name.gsub('hmac-', ''))
16
+ @signed_headers = parse_headers!(match[2])
17
+ @signature = match[3]
18
+ end
19
+
20
+ private
21
+
22
+ def parse_signing_algorithm!(value)
23
+ OpenSSL::Digest.new(value)
24
+ rescue RuntimeError
25
+ raise HeaderParseError, "Unsupported signing algorithm (#{value})"
26
+ end
27
+
28
+ def parse_headers!(value)
29
+ headers = value.split(';')
30
+ raise HeaderParseError, 'Signed headers should be sorted' unless headers == headers.sort
31
+
32
+ headers
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,4 @@
1
+ module Auth
2
+ class Error < StandardError
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Auth
2
+ class HeaderParseError < Auth::Error
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Auth
2
+ class InvalidSignatureError < Auth::Error
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Auth
2
+ class MissingKeyError < Auth::Error
3
+ end
4
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auth
4
+ class RequestNormalizer
5
+ def initialize(signed_headers)
6
+ @signed_headers = signed_headers
7
+ end
8
+
9
+ def normalize(request)
10
+ <<~NORMALIZED.chomp
11
+ #{request.method}
12
+ #{request.original_fullpath}
13
+
14
+ #{normalize_headers(request.headers)}
15
+
16
+ #{normalize_signed_headers}
17
+ #{normalize_body(request.body.read)}
18
+ NORMALIZED
19
+ end
20
+
21
+ private
22
+
23
+ def normalize_headers(headers)
24
+ @signed_headers
25
+ .map { |header| "#{header}:#{headers.fetch(header, '')}" }
26
+ .join("\n")
27
+ end
28
+
29
+ def normalize_signed_headers
30
+ @signed_headers.join(';')
31
+ end
32
+
33
+ def normalize_body(body)
34
+ OpenSSL::Digest.new('sha256').hexdigest(body)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auth
4
+ class SignatureValidator
5
+ def initialize(key = nil, threshold = nil)
6
+ @key = key
7
+ @threshold = threshold
8
+ end
9
+
10
+ def validate(request)
11
+ raise Auth::MissingKeyError, 'Signing Key is not set' unless @key.present?
12
+
13
+ authorization_header = authorization_header(request)
14
+ time_header = time_header(request)
15
+
16
+ validate_time!(time_header)
17
+
18
+ validate_signature!(request, authorization_header, time_header)
19
+ end
20
+
21
+ private
22
+
23
+ def validate_time!(time_header)
24
+ return true unless @threshold.positive?
25
+
26
+ return true if time_header.time + @threshold >= Time.now.utc
27
+
28
+ raise Auth::InvalidSignatureError, 'Signature is too old'
29
+ end
30
+
31
+ def validate_signature!(request, authorization_header, time_header)
32
+ string_to_be_signed = string_to_be_signed(authorization_header, time_header, request)
33
+ salted_key = OpenSSL::HMAC.digest(authorization_header.signing_algorithm, @key, time_header.date)
34
+ signature = OpenSSL::HMAC.hexdigest(authorization_header.signing_algorithm, salted_key, string_to_be_signed)
35
+
36
+ return true if ActiveSupport::SecurityUtils.secure_compare(signature, authorization_header.signature)
37
+
38
+ raise Auth::InvalidSignatureError, 'Signature is incorrect'
39
+ end
40
+
41
+ def string_to_be_signed(authorization_header, time_header, request)
42
+ [
43
+ authorization_header.signing_algorithm_name,
44
+ time_header.timestamp,
45
+ normalized_request_hash(authorization_header, request)
46
+ ].join("\n")
47
+ end
48
+
49
+ def authorization_header(request)
50
+ AuthorizationHeader.new(fetch_header(request, 'Gladly-Authorization'))
51
+ end
52
+
53
+ def time_header(request)
54
+ TimeHeader.new(fetch_header(request, 'Gladly-Time'))
55
+ end
56
+
57
+ def fetch_header(request, header)
58
+ request.headers.fetch(header)
59
+ rescue KeyError
60
+ raise HeaderParseError, "#{header} header is missing"
61
+ end
62
+
63
+ def normalized_request_hash(authorization_header, request)
64
+ normalized_request = RequestNormalizer.new(authorization_header.signed_headers).normalize(request)
65
+ OpenSSL::Digest.new('sha256').hexdigest(normalized_request)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auth
4
+ class TimeHeader
5
+ TIME_HEADER_FORMAT = /\A(\d{8})T\d{6}Z\Z/.freeze
6
+ TIME_STRPTIME_FORMAT = '%Y%m%dT%H%M%SZ%Z'
7
+
8
+ attr_reader :timestamp, :date, :time
9
+
10
+ def initialize(header)
11
+ match = TIME_HEADER_FORMAT.match(header)
12
+ raise HeaderParseError, 'Unsupported Gladly-Time header format' if match.nil?
13
+
14
+ @timestamp = match[0]
15
+ @date = match[1]
16
+ @time = Time.strptime(utc_timestamp, TIME_STRPTIME_FORMAT)
17
+ end
18
+
19
+ private
20
+
21
+ def utc_timestamp
22
+ "#{@timestamp}+0000"
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LookupValidator
4
+ def call(params)
5
+ errors = []
6
+
7
+ errors += validate_lookup_level(params)
8
+ errors += validate_unique_match_required(params)
9
+ errors += validate_query_emails(params)
10
+ errors += validate_query_phones(params)
11
+ errors += validate_query_name(params)
12
+ errors += validate_query_external_customer_id(params)
13
+
14
+ ValidationResult.new(errors)
15
+ end
16
+
17
+ private
18
+
19
+ def validate_lookup_level(params)
20
+ lookup_level = params[:lookupLevel]
21
+
22
+ return [%i[lookupLevel missing]] if lookup_level.nil?
23
+ return [%i[lookupLevel invalid]] unless lookup_level =~ /\A(?:basic|detailed)\Z/i
24
+
25
+ []
26
+ end
27
+
28
+ def validate_unique_match_required(params)
29
+ unique_match_required = params[:uniqueMatchRequired]
30
+
31
+ return [%i[uniqueMatchRequired missing]] if unique_match_required.nil?
32
+
33
+ if detailed_lookup?(params)
34
+ return [%i[uniqueMatchRequired not_true]] unless unique_match_required.in?([true, 'true'])
35
+ else
36
+ return [%i[uniqueMatchRequired invalid]] unless unique_match_required.in?([true, false, 'true', 'false'])
37
+ end
38
+
39
+ []
40
+ end
41
+
42
+ def validate_query_emails(params)
43
+ emails = params.dig(:query, :emails)
44
+
45
+ return [] if emails.nil? || string_or_array_of_strings?(emails)
46
+
47
+ [%i[query_emails invalid]]
48
+ end
49
+
50
+ def validate_query_phones(params)
51
+ phones = params.dig(:query, :phones)
52
+
53
+ return [] if phones.nil? || string_or_array_of_strings?(phones)
54
+
55
+ [%i[query_phones invalid]]
56
+ end
57
+
58
+ def validate_query_name(params)
59
+ name = params.dig(:query, :name)
60
+
61
+ return [] if name.nil? || nonempty_string?(name)
62
+
63
+ [%i[query_name invalid]]
64
+ end
65
+
66
+ def validate_query_external_customer_id(params)
67
+ id = params.dig(:query, :externalCustomerId)
68
+
69
+ return [%i[query_externalCustomerId missing]] if detailed_lookup?(params) && id.nil?
70
+ return [%i[query_externalCustomerId invalid]] if id.present? && !nonempty_string?(id)
71
+
72
+ []
73
+ end
74
+
75
+ def detailed_lookup?(params)
76
+ params[:lookupLevel] =~ /\Adetailed\Z/i
77
+ end
78
+
79
+ def nonempty_string?(value)
80
+ value.is_a?(String) && !value.empty?
81
+ end
82
+
83
+ def string_or_array_of_strings?(value)
84
+ return true if nonempty_string?(value)
85
+ return true if value.is_a?(Array) && value.all? { |v| nonempty_string?(v) }
86
+
87
+ false
88
+ end
89
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ValidationResult
4
+ attr_reader :errors
5
+
6
+ def initialize(errors)
7
+ @errors = errors
8
+ end
9
+
10
+ def success?
11
+ errors.empty?
12
+ end
13
+
14
+ def format_errors
15
+ @errors.map do |error|
16
+ attr, code = error
17
+
18
+ {
19
+ attr: attr.to_s,
20
+ code: code.to_s,
21
+ detail: Spree.t("spree_gladly.errors.#{attr}.#{code}")
22
+ }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ <% content_for :page_title do %>
2
+ <%= Spree.t('spree_gladly.settings') %>
3
+ <% end %>
4
+
5
+ <h1><%= Spree.t('spree_gladly.settings') %></h1>
6
+
7
+ <%= form_tag(admin_gladly_settings_path, method: :put, id: :gladly_settings_form) do %>
8
+ <div class="yui-g">
9
+ <div class="yui-u first">
10
+ <fieldset>
11
+ <p>
12
+ <label><%= Spree.t('spree_gladly.signing_key') %></label><br />
13
+ <%= text_field_tag('signing_key', @signing_key, size: 46, maxlength: 256, class: 'form-control') %>
14
+ </p>
15
+
16
+ <p>
17
+ <label><%= Spree.t('spree_gladly.signing_threshold') %></label><br />
18
+ <%= number_field_tag('signing_threshold', @signing_threshold, min: 1, class: 'form-control') %>
19
+ </p>
20
+ </fieldset>
21
+ </div>
22
+ </div>
23
+
24
+ <p class="form-buttons"><%= button Spree.t('actions.update'), 'save.svg', 'submit', {class: 'btn-success'} %></p>
25
+ <% end %>
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "spree_gladly"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,25 @@
1
+ en:
2
+ spree:
3
+ spree_gladly:
4
+ settings: Gladly Settings
5
+ signing_key: Signing Key
6
+ signing_threshold: Signing Threshold
7
+ signing_threshold_error: Signing Threshold should be empty, 0 or positive
8
+ save_success: Updated Gladly configuration
9
+ errors:
10
+ lookupLevel:
11
+ missing: lookupLevel must be present
12
+ invalid: lookupLevel must be BASIC or DETAILED
13
+ uniqueMatchRequired:
14
+ missing: uniqueMatchRequired must be present
15
+ invalid: uniqueMatchRequired must be true or false
16
+ not_true: uniqueMatchRequired must be true for Detailed Lookup
17
+ query_emails:
18
+ invalid: query emails must be a nonempty string or an array of nonempty strings
19
+ query_phones:
20
+ invalid: query phones must be a nonempty string or an array of nonempty strings
21
+ query_name:
22
+ invalid: query name must be a nonempty string
23
+ query_externalCustomerId:
24
+ missing: query externalCustomerId must be present for Detailed Lookup
25
+ invalid: query externalCustomerId must be a nonempty string
data/config/routes.rb ADDED
@@ -0,0 +1,13 @@
1
+ Spree::Core::Engine.add_routes do
2
+ namespace :admin do
3
+ resource :gladly_settings, only: %i[edit update]
4
+ end
5
+
6
+ namespace :api, defaults: { format: 'json' } do
7
+ namespace :v1 do
8
+ resource :customers, only: [] do
9
+ post :lookup
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails-controller-testing"
6
+ gem "sqlite3", "~> 1.3.6"
7
+ gem "sprockets", "~> 3.7.2"
8
+ gem "spree_core", "~> 3.0.0"
9
+ gem "spree_backend", "~> 3.0.0"
10
+ gem "pg", "~> 0.18"
11
+ gem "factory_girl"
12
+ gem "rails_test_params_backport"
13
+ gem "rubocop"
14
+ gem "sass-rails"
15
+
16
+ gemspec path: "../"
@@ -0,0 +1,16 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails-controller-testing"
6
+ gem "sqlite3", "~> 1.3.6"
7
+ gem "sprockets", "~> 3.7.2"
8
+ gem "spree_core", "~> 3.1.0"
9
+ gem "spree_backend", "~> 3.1.0"
10
+ gem "pg", "~> 0.18"
11
+ gem "factory_girl"
12
+ gem "rails_test_params_backport"
13
+ gem "sass-rails"
14
+ gem "rubocop"
15
+
16
+ gemspec path: "../"