galetahub-api-sigv2 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 52becac90c26c1d17bd80aaf90908b1fc2e0b04f5c8b47cc51d2a8ce519f7c98
4
+ data.tar.gz: 8b8d28cc1f3c97d2e5a0dbc093b48563f7a03c99ad8477a768333fb5a8fc3996
5
+ SHA512:
6
+ metadata.gz: 32085ab3aa761531c2e85676a4346a63eddbd33b1fdfc05300dbb06cc79c671eb61b03c5e58a38f48791c524096240d5696d5046bbef46d491e2881a60d6ebb0
7
+ data.tar.gz: abf526249bab793a86bfd26203a24d4f389d4a12b57655512652860ab8ced50bc50155f0c436261fb5f4d726614d1e39e851919cbbac9e29832f74b086e3bec3
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,34 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.5.5
3
+ Exclude:
4
+ - "bin/*"
5
+ - "Guardfile"
6
+
7
+ ##################### Styles ##################################
8
+
9
+ Style/Documentation:
10
+ Enabled: false
11
+
12
+ Style/SymbolArray:
13
+ Enabled: false
14
+
15
+ ##################### Metrics ##################################
16
+
17
+ Metrics/LineLength:
18
+ Max: 110
19
+
20
+ Metrics/ClassLength:
21
+ Max: 200
22
+
23
+ Metrics/ModuleLength:
24
+ Max: 200
25
+ Exclude:
26
+ - "**/*_spec.rb"
27
+
28
+ Metrics/BlockLength:
29
+ Max: 50
30
+ Exclude:
31
+ - "**/*_spec.rb"
32
+
33
+ Metrics/MethodLength:
34
+ Max: 15
@@ -0,0 +1 @@
1
+ 2.5.5
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.4
5
+ before_install: gem install bundler -v 1.15.4
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in api_sigv2.gemspec
6
+ gemspec
@@ -0,0 +1,70 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec features) \
6
+ # .select{|d| Dir.exists?(d) ? d : UI.warning("Directory #{d} does not exist")}
7
+
8
+ ## Note: if you are using the `directories` clause above and you are not
9
+ ## watching the project directory ('.'), then you will want to move
10
+ ## the Guardfile to a watched dir and symlink it back, e.g.
11
+ #
12
+ # $ mkdir config
13
+ # $ mv Guardfile config/
14
+ # $ ln -s config/Guardfile .
15
+ #
16
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
17
+
18
+ # Note: The cmd option is now required due to the increasing number of ways
19
+ # rspec may be run, below are examples of the most common uses.
20
+ # * bundler: 'bundle exec rspec'
21
+ # * bundler binstubs: 'bin/rspec'
22
+ # * spring: 'bin/rspec' (This will use spring if running and you have
23
+ # installed the spring binstubs per the docs)
24
+ # * zeus: 'zeus rspec' (requires the server to be started separately)
25
+ # * 'just' rspec: 'rspec'
26
+
27
+ guard :rspec, cmd: "bundle exec rspec" do
28
+ require "guard/rspec/dsl"
29
+ dsl = Guard::RSpec::Dsl.new(self)
30
+
31
+ # Feel free to open issues for suggestions and improvements
32
+
33
+ # RSpec files
34
+ rspec = dsl.rspec
35
+ watch(rspec.spec_helper) { rspec.spec_dir }
36
+ watch(rspec.spec_support) { rspec.spec_dir }
37
+ watch(rspec.spec_files)
38
+
39
+ # Ruby files
40
+ ruby = dsl.ruby
41
+ dsl.watch_spec_files_for(ruby.lib_files)
42
+
43
+ # Rails files
44
+ rails = dsl.rails(view_extensions: %w(erb haml slim))
45
+ dsl.watch_spec_files_for(rails.app_files)
46
+ dsl.watch_spec_files_for(rails.views)
47
+
48
+ watch(rails.controllers) do |m|
49
+ [
50
+ rspec.spec.call("routing/#{m[1]}_routing"),
51
+ rspec.spec.call("controllers/#{m[1]}_controller"),
52
+ rspec.spec.call("acceptance/#{m[1]}")
53
+ ]
54
+ end
55
+
56
+ # Rails config changes
57
+ watch(rails.spec_helper) { rspec.spec_dir }
58
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
59
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
60
+
61
+ # Capybara features specs
62
+ watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") }
63
+ watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") }
64
+
65
+ # Turnip features and steps
66
+ watch(%r{^spec/acceptance/(.+)\.feature$})
67
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
68
+ Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
69
+ end
70
+ end
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Igor Malinovskiy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,177 @@
1
+ # ApiSigv2
2
+
3
+ Simple HMAC-SHA1 authentication via headers. Impressed by [AWS Requests with Signature Version 4](https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html)
4
+
5
+ This gem will generate signature for the client requests and verify that signature on the server side
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'api_sigv2'
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ The usage is pretty simple. To sign a request use ApiSigv2::Signer and for validation use ApiSigv2::Validator.
18
+
19
+ ### Create signature
20
+
21
+ Sign a request with 'authorization' header. You can change header name, see Configuration section.
22
+
23
+ ```ruby
24
+ api_access_key = 'access_key'
25
+ api_secret_key = 'secret_key'
26
+
27
+ request = {
28
+ http_method: 'POST',
29
+ url: 'https://example.com/posts',
30
+ headers: {
31
+ 'User-Agent' => 'Test agent'
32
+ },
33
+ body: 'body'
34
+ }
35
+
36
+ # Sign your request
37
+ signature = ApiSigv2::Signer.new(api_access_key, api_secret_key).sign_request(request)
38
+
39
+ # Now apply signed headers to your real request
40
+ signature.headers
41
+
42
+ # signature.headers looks like:
43
+ {
44
+ "host"=>"example.com",
45
+ "x-datetime"=>"2020-01-02T10:24:59.837+0000",
46
+ "authorization"=>"API-HMAC-SHA256 Credential=access_key/20200102/web/api_request, SignedHeaders=host;user-agent;x-datetime, Signature=032fc0b7defd66d86ef43ced8e6c3ee351ede21deca6bf1f89b9145f7a9105c1"
47
+ }
48
+ ```
49
+
50
+ ### Validate signature
51
+
52
+ Validate the request on the client-side. Note, that access_key can be extracted from the request.
53
+
54
+ ```ruby
55
+ # the request to validate
56
+ request = {
57
+ :http_method=>"POST",
58
+ :url=>"https://example.com/posts",
59
+ :headers=>{
60
+ "User-Agent"=>"Test agent",
61
+ "host"=>"example.com",
62
+ "x-datetime"=>"2020-01-02T10:24:59.837+0000",
63
+ "authorization"=>"API-HMAC-SHA256 Credential=access_key/20200102/web/api_request, SignedHeaders=host;user-agent;x-datetime, Signature=032fc0b7defd66d86ef43ced8e6c3ee351ede21deca6bf1f89b9145f7a9105c1"
64
+ },
65
+ :body=>"body"
66
+ }
67
+
68
+ # initialize validator with a request to validate
69
+ validator = ApiSigv2::Validator.new(request)
70
+
71
+ # get access key from request headers (String)
72
+ validator.access_key
73
+
74
+ # validate the request (Boolean)
75
+ validator.valid?('your secret key here')
76
+
77
+ # get only signed headers (Hash)
78
+ validator.signed_headers
79
+ ```
80
+
81
+ ## Configuration
82
+
83
+ By default, the generated signature will be valid for 5 minutes
84
+ This could be changed via initializer:
85
+
86
+ ```ruby
87
+ # config/initializers/api_sigv2.rb
88
+
89
+ ApiSigv2.setup do |config|
90
+ # Time to live, by default 5 minutes
91
+ config.signature_ttl = 5 * 60
92
+
93
+ # Datetime format, by default iso8601
94
+ config.datetime_format = '%Y-%m-%dT%H:%M:%S.%L%z'
95
+
96
+ # Header name, by default authorization
97
+ config.signature_header = 'authorization'
98
+
99
+ # Service name, by default web
100
+ config.service = 'web'
101
+ end
102
+ ```
103
+
104
+ ## Testing
105
+
106
+ In your `rails_helper.rb`:
107
+
108
+ ```ruby
109
+ require 'api_sigv2/spec_support/helper'
110
+
111
+ RSpec.configure do |config|
112
+ config.include ApiSigv2::SpecSupport::Helper, type: :controller
113
+ end
114
+ ```
115
+
116
+ This will enable the following methods in controller tests:
117
+
118
+ * get_with_signature(client, action_name, params = {})
119
+ * post_with_signature(client, action_name, params = {})
120
+ * put_with_signature(client, action_name, params = {})
121
+ * patch_with_signature(client, action_name, params = {})
122
+ * delete_with_signature(client, action_name, params = {})
123
+
124
+ `client` object should respond to `#api_key` and `#api_secret`
125
+
126
+ Example usage:
127
+
128
+ ```ruby
129
+ RSpec.describe Api::V1::OrdersController do
130
+ let(:client) { FactoryBot.create(:client) }
131
+ # or any object, that responds to #api_key and #api_secret
132
+ # let(:client) { OpenStruct.new(api_key: 'some_key', api_secret: 'some_api_secret') }
133
+
134
+ it 'should filter orders by state' do
135
+ get_with_signature client, :index, state: :paid
136
+
137
+ expect(last_response.status).to eq 200
138
+ expect(last_response.body).to have_node(:orders)
139
+ expect(last_response.body).to have_node(:state).with('paid')
140
+ end
141
+
142
+ let(:order_attributes) { FactoryBot.attributes_for(:order) }
143
+
144
+ it 'should create new order' do
145
+ post_with_signature client, :create, order: order_attributes
146
+ end
147
+ end
148
+ ```
149
+
150
+ For nested resources path can be specified explicitly using `path` parameter:
151
+
152
+ ```ruby
153
+ # path: /api/v1/orders/:order_id/comments
154
+
155
+ RSpec.describe Api::V1::CommentsController do
156
+ let(:client) { OpenStruct.new(api_key: 'some_key', api_secret: 'some_api_secret') }
157
+ let(:order) { FactoryBot.create(:order) }
158
+
159
+ it 'should update comment for order' do
160
+ put_with_signature client, :update, path: { order_id: order.id }, comment: { content: 'Some value' }
161
+ end
162
+ end
163
+ ```
164
+
165
+ ## Development
166
+
167
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
168
+
169
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
170
+
171
+ ## Contributing
172
+
173
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/api_sigv2.
174
+
175
+ ## License
176
+
177
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'api_sigv2/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'galetahub-api-sigv2'
9
+ spec.version = ApiSigv2::VERSION
10
+ spec.authors = ['Igor Galeta']
11
+ spec.email = ['galeta.igor@gmail.com']
12
+
13
+ spec.summary = 'Sign API requests with HMAC signature'
14
+ spec.homepage = 'https://github.com/galetahub/api_signature'
15
+ spec.license = 'MIT'
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
+ f.match(%r{^(test|spec|features)/})
19
+ end
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+
24
+ spec.add_development_dependency 'guard-rspec', '~> 4.7', '>= 4.7.3'
25
+ spec.add_development_dependency 'rake', '~> 10.0'
26
+ spec.add_development_dependency 'rb-fsevent', '0.9.8'
27
+ spec.add_development_dependency 'rspec', '~> 3.0'
28
+ end
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "api_sigv2"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'api_sigv2/version'
4
+ require 'api_sigv2/configuration'
5
+
6
+ module ApiSigv2
7
+ autoload :Builder, 'api_sigv2/builder'
8
+ autoload :Validator, 'api_sigv2/validator'
9
+ autoload :Signer, 'api_sigv2/signer'
10
+ autoload :Signature, 'api_sigv2/signature'
11
+ autoload :AuthHeader, 'api_sigv2/auth_header'
12
+ autoload :Utils, 'api_sigv2/utils'
13
+
14
+ class << self
15
+ attr_writer :configuration
16
+ end
17
+
18
+ def self.configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ def self.reset
23
+ @configuration = Configuration.new
24
+ end
25
+
26
+ # @example
27
+ # ApiSigv2.setup do |config|
28
+ # config.signature_ttl = 2.minutes
29
+ # end
30
+ #
31
+ def self.setup
32
+ yield configuration
33
+ end
34
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSigv2
4
+ class AuthHeader
5
+ attr_reader :authorization
6
+
7
+ TOKEN_REGEX = /^(API-HMAC-SHA256)\s+/.freeze
8
+ AUTHN_PAIR_DELIMITERS = /(?:,|\t+)/.freeze
9
+
10
+ def initialize(authorization)
11
+ @authorization = authorization.to_s
12
+ end
13
+
14
+ def credential
15
+ data[0]
16
+ end
17
+
18
+ def signature
19
+ options['Signature']
20
+ end
21
+
22
+ def signed_headers
23
+ return [] unless options['SignedHeaders']
24
+
25
+ @signed_headers ||= options['SignedHeaders'].split(/;\s?/).map(&:strip)
26
+ end
27
+
28
+ def options
29
+ @options ||= (data[1] || {})
30
+ end
31
+
32
+ private
33
+
34
+ def data
35
+ @data ||= (parse_token_with_options || [])
36
+ end
37
+
38
+ def parse_token_with_options
39
+ return unless authorization[TOKEN_REGEX]
40
+
41
+ params = token_params_from authorization
42
+ [params.shift[1], Hash[params]]
43
+ end
44
+
45
+ def token_params_from(auth)
46
+ rewrite_param_values params_array_from raw_params(auth)
47
+ end
48
+
49
+ def raw_params(auth)
50
+ auth.sub(TOKEN_REGEX, '').split(/\s*#{AUTHN_PAIR_DELIMITERS}\s*/)
51
+ end
52
+
53
+ def params_array_from(raw_params)
54
+ raw_params.map { |param| param.split(/=(.+)?/) }
55
+ end
56
+
57
+ def rewrite_param_values(array_params)
58
+ array_params.each { |param| (param[1] || +'').gsub!(/^"|"$/, '') }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module ApiSigv2
6
+ class Builder
7
+ attr_reader :settings
8
+
9
+ SPLITTER = "\n"
10
+
11
+ def initialize(settings = {}, unsigned_headers = [])
12
+ @settings = settings
13
+ @unsigned_headers = unsigned_headers
14
+ end
15
+
16
+ def http_method
17
+ @http_method ||= extract_http_method
18
+ end
19
+
20
+ def uri
21
+ @uri ||= extract_uri
22
+ end
23
+
24
+ def host
25
+ @host ||= extract_host_from_uri
26
+ end
27
+
28
+ def headers
29
+ @headers ||= Utils.normalize_keys(settings[:headers])
30
+ end
31
+
32
+ def datetime
33
+ @datetime ||= extract_datetime
34
+ end
35
+
36
+ def date
37
+ @date ||= datetime.to_s.scan(/\d/).take(8).join
38
+ end
39
+
40
+ def content_sha256
41
+ @content_sha256 ||= Utils.sha256_hexdigest(body)
42
+ end
43
+
44
+ def body
45
+ @body ||= (settings[:body] || '')
46
+ end
47
+
48
+ def build_sign_headers(apply_checksum_header = false)
49
+ @sign_headers = {
50
+ 'host' => host,
51
+ 'x-datetime' => datetime
52
+ }
53
+ @sign_headers['x-content-sha256'] = content_sha256 if apply_checksum_header
54
+ @sign_headers
55
+ end
56
+
57
+ def full_headers
58
+ @full_headers ||= merge_sign_with_origin_headers
59
+ end
60
+
61
+ def signed_headers
62
+ @signed_headers ||= full_headers.reject { |key, _value| @unsigned_headers.include?(key) }
63
+ end
64
+
65
+ def signed_headers_names
66
+ @signed_headers_names ||= signed_headers.keys.sort.join(';')
67
+ end
68
+
69
+ def canonical_request(path)
70
+ [
71
+ http_method,
72
+ path,
73
+ Utils.normalized_querystring(uri.query),
74
+ canonical_headers + SPLITTER,
75
+ signed_headers_names,
76
+ content_sha256
77
+ ].join(SPLITTER)
78
+ end
79
+
80
+ private
81
+
82
+ def extract_http_method
83
+ raise ArgumentError, 'missing required option :http_method' unless settings[:http_method]
84
+
85
+ settings[:http_method].to_s.upcase
86
+ end
87
+
88
+ def extract_uri
89
+ raise ArgumentError, 'missing required option :url' unless settings[:url]
90
+
91
+ URI.parse(settings[:url].to_s)
92
+ end
93
+
94
+ def extract_host_from_uri
95
+ if Utils.standard_port?(uri)
96
+ uri.host
97
+ else
98
+ "#{uri.host}:#{uri.port}"
99
+ end
100
+ end
101
+
102
+ def extract_datetime
103
+ headers['x-datetime'] || Time.now.utc.strftime(ApiSigv2.configuration.datetime_format)
104
+ end
105
+
106
+ def merge_sign_with_origin_headers
107
+ raise ArgumentError, 'missing required variable sign_headers' unless @sign_headers
108
+
109
+ # merge so we do not modify given headers hash
110
+ headers.merge(@sign_headers)
111
+ end
112
+
113
+ def canonical_headers
114
+ signed_headers.sort_by(&:first)
115
+ .map { |k, v| "#{k}:#{Utils.canonical_header_value(v.to_s)}" }
116
+ .join(SPLITTER)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSigv2
4
+ class Configuration
5
+ attr_accessor :signature_ttl, :signature_header, :datetime_format, :service
6
+
7
+ def initialize
8
+ @signature_ttl = 5 * 60
9
+ @datetime_format = '%Y-%m-%dT%H:%M:%S.%L%z'
10
+ @signature_header = 'authorization'
11
+ @service = 'web'
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSigv2
4
+ class Signature
5
+ # @return [Hash<String,String>] A hash of headers that should
6
+ # be applied to the HTTP request. Header keys are lower
7
+ # cased strings and may include the following:
8
+ #
9
+ # * 'host'
10
+ # * 'x-date'
11
+ # * 'x-content-sha256'
12
+ # * 'authorization'
13
+ #
14
+ attr_reader :headers
15
+
16
+ # @return [String] For debugging purposes.
17
+ attr_reader :canonical_request
18
+
19
+ # @return [String] For debugging purposes.
20
+ attr_reader :string_to_sign
21
+
22
+ # @return [String] For debugging purposes.
23
+ attr_reader :content_sha256
24
+
25
+ # @return [String] For debugging purposes.
26
+ attr_reader :signature
27
+
28
+ def initialize(attributes)
29
+ attributes.each do |key, value|
30
+ instance_variable_set("@#{key}", value)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module ApiSigv2
6
+ # The signer requires secret key.
7
+ #
8
+ # signer = ApiSigv2::Signer.new('access key', 'secret key', uri_escape_path: true)
9
+ #
10
+ class Signer
11
+ NAME = 'API-HMAC-SHA256'
12
+
13
+ # Options:
14
+ # @option options [Array<String>] :unsigned_headers ([]) A list of
15
+ # headers that should not be signed. This is useful when a proxy
16
+ # modifies headers, such as 'User-Agent', invalidating a signature.
17
+ #
18
+ # @option options [Boolean] :uri_escape_path (true) When `true`,
19
+ # the request URI path is uri-escaped as part of computing the canonical
20
+ # request string.
21
+ #
22
+ # @option options [Boolean] :apply_checksum_header (false) When `true`,
23
+ # the computed content checksum is returned in the hash of signature
24
+ # headers.
25
+ #
26
+ # @option options [String] :signature_header (authorization) Header name
27
+ # for signature
28
+ #
29
+ # @option options [String] :service (web) Service name
30
+ #
31
+ def initialize(access_key, secret_key, options = {})
32
+ @access_key = access_key
33
+ @secret_key = secret_key
34
+ @options = options
35
+ end
36
+
37
+ # Computes a signature. Returns the resultant
38
+ # signature as a hash of headers to apply to your HTTP request. The given
39
+ # request is not modified.
40
+ #
41
+ # signature = signer.sign_request(
42
+ # http_method: 'PUT',
43
+ # url: 'https://domain.com',
44
+ # headers: {
45
+ # 'Abc' => 'xyz',
46
+ # },
47
+ # body: 'body' # String or IO object
48
+ # )
49
+ # @param [Hash] request
50
+ #
51
+ # @option request [required, String] :http_method One of
52
+ # 'GET', 'HEAD', 'PUT', 'POST', 'PATCH', or 'DELETE'
53
+ #
54
+ # @option request [required, String, URI::HTTPS, URI::HTTP] :url
55
+ # The request URI. Must be a valid HTTP or HTTPS URI.
56
+ #
57
+ # @option request [optional, Hash] :headers ({}) A hash of headers
58
+ # to sign. If the 'X-Amz-Content-Sha256' header is set, the `:body`
59
+ # is optional and will not be read.
60
+ #
61
+ # @option request [optional, String, IO] :body ('') The HTTP request body.
62
+ # A sha256 checksum is computed of the body unless the
63
+ # 'X-Amz-Content-Sha256' header is set.
64
+ #
65
+ # @return [Signature] Return an instance of {Signature} that has
66
+ # a `#headers` method. The headers must be applied to your request.
67
+ def sign_request(request)
68
+ builder = Builder.new(request, unsigned_headers)
69
+ sig_headers = builder.build_sign_headers(apply_checksum_header?)
70
+ data = build_signature(builder)
71
+
72
+ # apply signature
73
+ sig_headers[signature_header_name] = data[:header]
74
+
75
+ # Returning the signature components.
76
+ Signature.new(data.merge!(headers: sig_headers))
77
+ end
78
+
79
+ private
80
+
81
+ def uri_escape_path?
82
+ @options[:uri_escape_path] == true || !@options.key?(:uri_escape_path)
83
+ end
84
+
85
+ def apply_checksum_header?
86
+ @options[:apply_checksum_header] == true
87
+ end
88
+
89
+ def signature_header_name
90
+ @options[:signature_header] || ApiSigv2.configuration.signature_header
91
+ end
92
+
93
+ def service
94
+ @options[:service] || ApiSigv2.configuration.service
95
+ end
96
+
97
+ def unsigned_headers
98
+ @unsigned_headers ||= build_unsigned_headers
99
+ end
100
+
101
+ def build_unsigned_headers
102
+ Set.new(@options.fetch(:unsigned_headers, []).map(&:downcase)) << signature_header_name
103
+ end
104
+
105
+ def build_signature(builder)
106
+ path = Utils.url_path(builder.uri.path, uri_escape_path?)
107
+
108
+ # compute signature parts
109
+ creq = builder.canonical_request(path)
110
+ sts = string_to_sign(builder.datetime, creq)
111
+ sig = signature(builder.date, sts)
112
+
113
+ {
114
+ header: build_signature_header(builder, sig),
115
+ content_sha256: builder.content_sha256,
116
+ string_to_sign: sts,
117
+ canonical_request: creq,
118
+ signature: sig
119
+ }
120
+ end
121
+
122
+ def build_signature_header(builder, signature)
123
+ [
124
+ "#{NAME} Credential=#{credential(builder.date)}",
125
+ "SignedHeaders=#{builder.signed_headers_names}",
126
+ "Signature=#{signature}"
127
+ ].join(', ')
128
+ end
129
+
130
+ def string_to_sign(datetime, canonical_request)
131
+ [
132
+ NAME,
133
+ datetime,
134
+ Utils.sha256_hexdigest(canonical_request)
135
+ ].join(Builder::SPLITTER)
136
+ end
137
+
138
+ def signature(date, string_to_sign)
139
+ k_date = Utils.hmac("API#{@secret_key}", date)
140
+ k_service = Utils.hmac(k_date, service)
141
+ k_credentials = Utils.hmac(k_service, 'api_request')
142
+
143
+ Utils.hexhmac(k_credentials, string_to_sign)
144
+ end
145
+
146
+ def credential(date)
147
+ "#{@access_key}/#{date}/#{service}/api_request"
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'api_sigv2/spec_support/path_builder'
4
+ require 'api_sigv2/spec_support/headers_builder'
5
+
6
+ module ApiSigv2
7
+ module SpecSupport
8
+ module Helper
9
+ include Rack::Test::Methods
10
+
11
+ def app
12
+ Rails.app_class
13
+ end
14
+
15
+ def get_with_signature(client, *args)
16
+ with_signature(:get, client.api_key, client.api_secret, *args)
17
+ end
18
+
19
+ def post_with_signature(client, *args)
20
+ with_signature(:post, client.api_key, client.api_secret, *args)
21
+ end
22
+
23
+ def put_with_signature(client, *args)
24
+ with_signature(:put, client.api_key, client.api_secret, *args)
25
+ end
26
+
27
+ alias patch_with_signature put_with_signature
28
+
29
+ def delete_with_signature(client, *args)
30
+ with_signature(:delete, client.api_key, client.api_secret, *args)
31
+ end
32
+
33
+ private
34
+
35
+ def with_signature(http_method, api_key, secret, action_name, params = {})
36
+ custom_headers = (params.delete(:headers) || {})
37
+ path = PathBuilder.new(controller, action_name, params).path
38
+
39
+ signature = Signer.new(api_key, secret).sign_request(
40
+ http_method: http_method.to_s.upcase,
41
+ url: path,
42
+ headers: custom_headers
43
+ )
44
+
45
+ send(http_method, path, params, signature.headers)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSigv2
4
+ module SpecSupport
5
+ class PathBuilder
6
+ attr_reader :controller, :action_name, :params
7
+
8
+ PRIMARY_KEYS = [:id, :token].freeze
9
+
10
+ def initialize(controller, action_name, params = {})
11
+ @controller = controller
12
+ @action_name = action_name
13
+ @params = params
14
+ end
15
+
16
+ def path
17
+ if params[:path].present?
18
+ hash = params.delete(:path)
19
+ url_options.merge!(hash)
20
+ params.merge!(hash)
21
+ end
22
+
23
+ controller.url_for(url_options)
24
+ end
25
+
26
+ private
27
+
28
+ def url_options
29
+ @url_options ||= {
30
+ action: action_name,
31
+ controller: controller.controller_path,
32
+ only_path: true
33
+ }.merge(key_options || {})
34
+ end
35
+
36
+ def key_options
37
+ key = (params.keys.map(&:to_sym) & PRIMARY_KEYS).first
38
+ { key => params[key] } if params[key].present?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'digest/sha1'
5
+ require 'tempfile'
6
+ require 'date'
7
+
8
+ module ApiSigv2
9
+ module Utils
10
+ # @param [File, Tempfile, IO#read, String] value
11
+ # @return [String<SHA256 Hexdigest>]
12
+ #
13
+ def self.sha256_hexdigest(value)
14
+ if (File === value || Tempfile === value) && !value.path.nil? && File.exist?(value.path)
15
+ OpenSSL::Digest::SHA256.file(value).hexdigest
16
+ elsif value.respond_to?(:read)
17
+ sha256 = OpenSSL::Digest::SHA256.new
18
+
19
+ while chunk = value.read(1024 * 1024, buffer ||= '') # 1MB
20
+ sha256.update(chunk)
21
+ end
22
+
23
+ value.rewind
24
+ sha256.hexdigest
25
+ else
26
+ OpenSSL::Digest::SHA256.hexdigest(value)
27
+ end
28
+ end
29
+
30
+ # @param [URI] uri
31
+ # @return [true/false]
32
+ #
33
+ def self.standard_port?(uri)
34
+ (uri.scheme == 'http' && uri.port == 80) ||
35
+ (uri.scheme == 'https' && uri.port == 443)
36
+ end
37
+
38
+ def self.url_path(path, uri_escape_path = false)
39
+ path = '/' if path == ''
40
+
41
+ if uri_escape_path
42
+ uri_escape_path(path)
43
+ else
44
+ path
45
+ end
46
+ end
47
+
48
+ def self.uri_escape_path(path)
49
+ path.gsub(/[^\/]+/) { |part| uri_escape(part) }
50
+ end
51
+
52
+ # @api private
53
+ def self.uri_escape(string)
54
+ if string.nil?
55
+ nil
56
+ else
57
+ CGI.escape(string.encode('UTF-8')).gsub('+', '%20').gsub('%7E', '~')
58
+ end
59
+ end
60
+
61
+ def self.normalized_querystring(querystring)
62
+ return unless querystring
63
+
64
+ params = querystring.split('&')
65
+ params = params.map { |p| p.match(/=/) ? p : p + '=' }
66
+ # We have to sort by param name and preserve order of params that
67
+ # have the same name. Default sort <=> in JRuby will swap members
68
+ # occasionally when <=> is 0 (considered still sorted), but this
69
+ # causes our normalized query string to not match the sent querystring.
70
+ # When names match, we then sort by their original order
71
+ params.each.with_index.sort do |a, b|
72
+ a, a_offset = a
73
+ a_name = a.split('=')[0]
74
+ b, b_offset = b
75
+ b_name = b.split('=')[0]
76
+ if a_name == b_name
77
+ a_offset <=> b_offset
78
+ else
79
+ a_name <=> b_name
80
+ end
81
+ end.map(&:first).join('&')
82
+ end
83
+
84
+ def self.canonical_header_value(value)
85
+ value.match(/^".*"$/) ? value : value.gsub(/\s+/, ' ').strip
86
+ end
87
+
88
+ def self.hmac(key, value)
89
+ OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), key, value)
90
+ end
91
+
92
+ def self.hexhmac(key, value)
93
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), key, value)
94
+ end
95
+
96
+ def self.normalize_keys(hash)
97
+ return {} unless hash
98
+
99
+ hash.transform_keys { |key| key.to_s.downcase }
100
+ end
101
+
102
+ # constant-time comparison algorithm to prevent timing attacks
103
+ def self.secure_compare(string_a, string_b)
104
+ return false if string_a.nil? || string_b.nil? || string_a.bytesize != string_b.bytesize
105
+
106
+ l = string_a.unpack "C#{string_a.bytesize}"
107
+
108
+ res = 0
109
+ string_b.each_byte { |byte| res |= byte ^ l.shift }
110
+ res == 0
111
+ end
112
+
113
+ def self.safe_parse_datetime(value, format = nil)
114
+ format ||= ApiSigv2.configuration.datetime_format
115
+ DateTime.strptime(value, format)
116
+ rescue ArgumentError => _e
117
+ nil
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSigv2
4
+ # Validate a request
5
+ #
6
+ # request = {
7
+ # http_method: 'PUT',
8
+ # url: 'https://domain.com',
9
+ # headers: {
10
+ # 'Authorization' => 'API-HMAC-SHA256 Credential=access_key/20191227/api_request...',
11
+ # 'Host' => 'example.com,
12
+ # 'X-Content-Sha256' => '...',
13
+ # 'X-Datetime' => '2019-12-27T09:13:14.873+0000'
14
+ # },
15
+ # body: 'body'
16
+ # }
17
+ # validator = ApiSigv2::Validator.new(request, uri_escape_path: true)
18
+ # validator.access_key # get key from request headers
19
+ # validator.valid?('secret_key')
20
+ #
21
+ class Validator
22
+ attr_reader :request
23
+
24
+ def initialize(request, options = {})
25
+ @request = request
26
+ @options = options
27
+ end
28
+
29
+ def access_key
30
+ return unless valid_credential?
31
+
32
+ @access_key ||= auth_header.credential.split('/')[0]
33
+ end
34
+
35
+ def signed_headers
36
+ @signed_headers ||= headers.slice(*auth_header.signed_headers)
37
+ end
38
+
39
+ # Validate a signature. Returns boolean
40
+ #
41
+ # validator.valid?('secret_key_here')
42
+ #
43
+ # @param [String] secret key
44
+ #
45
+ def valid?(secret_key)
46
+ valid_authorization? && valid_timestamp? && valid_signature?(secret_key)
47
+ end
48
+
49
+ def valid_authorization?
50
+ valid_credential? && !auth_header.signature.nil?
51
+ end
52
+
53
+ def valid_credential?
54
+ !auth_header.credential.nil?
55
+ end
56
+
57
+ def valid_timestamp?
58
+ timestamp && ttl_range.cover?(timestamp.to_time)
59
+ end
60
+
61
+ def valid_signature?(secret_key)
62
+ return false unless secret_key
63
+
64
+ signer = Signer.new(access_key, secret_key, @options)
65
+ data = signer.sign_request(request)
66
+
67
+ Utils.secure_compare(
68
+ auth_header.signature,
69
+ data.signature
70
+ )
71
+ end
72
+
73
+ private
74
+
75
+ def auth_header
76
+ @auth_header ||= AuthHeader.new(headers[signature_header_name])
77
+ end
78
+
79
+ def signature_header_name
80
+ @options[:signature_header] || ApiSigv2.configuration.signature_header
81
+ end
82
+
83
+ def timestamp
84
+ @timestamp ||= Utils.safe_parse_datetime(headers['x-datetime'])
85
+ end
86
+
87
+ def headers
88
+ @headers ||= Utils.normalize_keys(request[:headers])
89
+ end
90
+
91
+ def ttl_range
92
+ to = Time.now.utc
93
+ from = to - ApiSigv2.configuration.signature_ttl
94
+
95
+ from..to
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSigv2
4
+ VERSION = '1.0.0'
5
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: galetahub-api-sigv2
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Galeta
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2020-01-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: guard-rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.7'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 4.7.3
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '4.7'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 4.7.3
33
+ - !ruby/object:Gem::Dependency
34
+ name: rake
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '10.0'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '10.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rb-fsevent
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - '='
52
+ - !ruby/object:Gem::Version
53
+ version: 0.9.8
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - '='
59
+ - !ruby/object:Gem::Version
60
+ version: 0.9.8
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ description:
76
+ email:
77
+ - galeta.igor@gmail.com
78
+ executables: []
79
+ extensions: []
80
+ extra_rdoc_files: []
81
+ files:
82
+ - ".gitignore"
83
+ - ".rspec"
84
+ - ".rubocop.yml"
85
+ - ".ruby-version"
86
+ - ".travis.yml"
87
+ - Gemfile
88
+ - Guardfile
89
+ - LICENSE.txt
90
+ - README.md
91
+ - Rakefile
92
+ - api_sigv2.gemspec
93
+ - bin/console
94
+ - bin/setup
95
+ - lib/api_sigv2.rb
96
+ - lib/api_sigv2/auth_header.rb
97
+ - lib/api_sigv2/builder.rb
98
+ - lib/api_sigv2/configuration.rb
99
+ - lib/api_sigv2/signature.rb
100
+ - lib/api_sigv2/signer.rb
101
+ - lib/api_sigv2/spec_support/helper.rb
102
+ - lib/api_sigv2/spec_support/path_builder.rb
103
+ - lib/api_sigv2/utils.rb
104
+ - lib/api_sigv2/validator.rb
105
+ - lib/api_sigv2/version.rb
106
+ homepage: https://github.com/galetahub/api_signature
107
+ licenses:
108
+ - MIT
109
+ metadata: {}
110
+ post_install_message:
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubyforge_project:
126
+ rubygems_version: 2.7.6.2
127
+ signing_key:
128
+ specification_version: 4
129
+ summary: Sign API requests with HMAC signature
130
+ test_files: []