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