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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.rspec +3 -0
- data/.rubocop.yml +45 -0
- data/.travis.yml +69 -0
- data/Appraisals +63 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/Gemfile +7 -0
- data/LICENSE +11 -0
- data/README.md +473 -0
- data/Rakefile +23 -0
- data/app/concerns/customer/database_adapter.rb +21 -0
- data/app/controllers/application_controller.rb +2 -0
- data/app/controllers/spree/admin/gladly_settings_controller.rb +38 -0
- data/app/controllers/spree/api/v1/customers_controller.rb +60 -0
- data/app/finders/customer/base_lookup.rb +37 -0
- data/app/finders/customer/basic_lookup.rb +26 -0
- data/app/finders/customer/detailed_lookup.rb +19 -0
- data/app/finders/customer/guest/basic_finder.rb +37 -0
- data/app/finders/customer/guest/detailed_finder.rb +42 -0
- data/app/finders/customer/registered/basic_finder.rb +68 -0
- data/app/finders/customer/registered/detailed_finder.rb +43 -0
- data/app/models/spree_gladly/configuration.rb +25 -0
- data/app/overrides/add_gladly_admin_menu_links.rb +10 -0
- data/app/presenters/customer/address_presenter.rb +27 -0
- data/app/presenters/customer/basic_lookup_presenter.rb +29 -0
- data/app/presenters/customer/detailed_lookup_presenter.rb +30 -0
- data/app/presenters/customer/guest/basic_presenter.rb +53 -0
- data/app/presenters/customer/guest/detailed_presenter.rb +117 -0
- data/app/presenters/customer/registered/basic_presenter.rb +60 -0
- data/app/presenters/customer/registered/detailed_presenter.rb +137 -0
- data/app/services/auth/authorization_header.rb +35 -0
- data/app/services/auth/error.rb +4 -0
- data/app/services/auth/header_parse_error.rb +4 -0
- data/app/services/auth/invalid_signature_error.rb +4 -0
- data/app/services/auth/missing_key_error.rb +4 -0
- data/app/services/auth/request_normalizer.rb +37 -0
- data/app/services/auth/signature_validator.rb +68 -0
- data/app/services/auth/time_header.rb +25 -0
- data/app/validators/lookup_validator.rb +89 -0
- data/app/validators/validation_result.rb +25 -0
- data/app/views/spree/admin/gladly_settings/edit.html.erb +25 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/config/locales/en.yml +25 -0
- data/config/routes.rb +13 -0
- data/gemfiles/spree_3_0.gemfile +16 -0
- data/gemfiles/spree_3_1.gemfile +16 -0
- data/gemfiles/spree_3_7.gemfile +11 -0
- data/gemfiles/spree_4_0.gemfile +11 -0
- data/gemfiles/spree_4_1.gemfile +11 -0
- data/gemfiles/spree_4_2.gemfile +11 -0
- data/gemfiles/spree_master.gemfile +11 -0
- data/lib/generators/spree_gladly/install/install_generator.rb +17 -0
- data/lib/generators/spree_gladly/install/templates/config/initializers/spree_gladly.rb +18 -0
- data/lib/spree_gladly.rb +13 -0
- data/lib/spree_gladly/engine.rb +25 -0
- data/lib/spree_gladly/factories.rb +9 -0
- data/lib/spree_gladly/version.rb +5 -0
- data/spree.png +0 -0
- data/spree_gladly.gemspec +35 -0
- 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,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,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: "../"
|