spree_gladly 1.0.0

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