api_signature 0.1.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: ec6eb5396b10ed59fb91d79b2163a7baab9f74c7
4
- data.tar.gz: 1faac38e423e0c420a6d5f4c46a1096341e270af
2
+ SHA256:
3
+ metadata.gz: f97c890250e1a784f8e590ec6fc803e23c15a77ad9d2cb374f2418d629aacb1f
4
+ data.tar.gz: a7ed8c86f5d82b7b5e8577df62a150cdbfd9ec9c63c831f70e9315c2e4cf2dc2
5
5
  SHA512:
6
- metadata.gz: ac924e7bebbfb969e93b2e1ac8f0616200b89bb1910faac04c3d820095ac5965eb56a01d2e525b071eaa74d9226dad31c03c0daeae532e19cd08a2c12de45e37
7
- data.tar.gz: c8f83d915efef5e558789aefb76aa5b27bf6da531a6e4ba6ad18e59c84c5a93ea93982cd586cdb44258f7fc241409b5943a6ff2bc49c02968c898a209679ba8e
6
+ metadata.gz: c1a20884bdb0b2c91bc6a6ecfc40354e0fa89ea11badcea7445b3b95bd309a13c35d2e301bee619cffbe431b32e77447d44b2f46ff3c3f5e645473b890999177
7
+ data.tar.gz: 021a30a6039154f1509235aa5d01cd666d101b1ee617a1e828ceb2de11b25bb5422ecfe44bec314062aaa947b73c32839487e776f75e2c48004a5726732d109a
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.4.4
2
+ TargetRubyVersion: 2.5.5
3
3
  Exclude:
4
4
  - "bin/*"
5
5
  - "Guardfile"
@@ -32,14 +32,3 @@ Metrics/BlockLength:
32
32
 
33
33
  Metrics/MethodLength:
34
34
  Max: 15
35
-
36
- ##################### Rails ##################################
37
-
38
- Rails:
39
- Enabled: true
40
-
41
- Rails/SkipsModelValidations:
42
- Enabled: false
43
-
44
- Rails/InverseOf:
45
- Enabled: false
@@ -0,0 +1 @@
1
+ 2.5.5
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # ApiSignature
6
6
 
7
- Simple HMAC-SHA1 authentication via headers
7
+ 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)
8
8
 
9
9
  This gem will generate signature for the client requests and verify that signature on the server side
10
10
 
@@ -16,101 +16,92 @@ Add this line to your application's Gemfile:
16
16
  gem 'api_signature'
17
17
  ```
18
18
 
19
- And then execute:
20
-
21
- $ bundle
22
-
23
19
  ## Usage
24
20
 
25
- ### Server side
26
-
27
- Implement warden strategy:
28
- ```ruby
29
- module MyApplication
30
- module API
31
- class ClientAuthenticatable < Warden::Strategies::Base
32
- delegate :valid?, to: :api_request
21
+ The usage is pretty simple. To sign a request use ApiSignature::Signer and for validation use ApiSignature::Validator.
33
22
 
34
- def authenticate!
35
- # Find client in database by public api_key
36
- resource = Client.find_for_token_authentication(api_request.access_key)
37
- return fail!(:not_found_in_database) unless resource
23
+ ### Create signature
38
24
 
39
- # Check request signature
40
- return unless api_request.correct?(resource.api_key, resource.api_secret)
25
+ Sign a request with 'authorization' header. You can change header name, see Configuration section.
41
26
 
42
- # Perform some after_authentication callbacks
43
- resource.after_authentication
27
+ ```ruby
28
+ api_access_key = 'access_key'
29
+ api_secret_key = 'secret_key'
30
+
31
+ request = {
32
+ http_method: 'POST',
33
+ url: 'https://example.com/posts',
34
+ headers: {
35
+ 'User-Agent' => 'Test agent'
36
+ },
37
+ body: 'body'
38
+ }
44
39
 
45
- # Tell warden that authentication was successful
46
- success!(resource)
47
- end
40
+ # Sign your request
41
+ signature = ApiSignature::Signer.new(api_access_key, api_secret_key).sign_request(request)
48
42
 
49
- private
43
+ # Now apply signed headers to your real request
44
+ signature.headers
50
45
 
51
- def api_request
52
- @api_request ||= ::ApiSignature::Request.new(env)
53
- end
54
- end
55
- end
56
- end
46
+ # signature.headers looks like:
47
+ {
48
+ "host"=>"example.com",
49
+ "x-datetime"=>"2020-01-02T10:24:59.837+0000",
50
+ "authorization"=>"API-HMAC-SHA256 Credential=access_key/20200102/web/api_request, SignedHeaders=host;user-agent;x-datetime, Signature=032fc0b7defd66d86ef43ced8e6c3ee351ede21deca6bf1f89b9145f7a9105c1"
51
+ }
57
52
  ```
58
53
 
59
- ```ruby
60
- module MyApplication
61
- module API
62
- module Authentication
63
- extend ActiveSupport::Concern
64
-
65
- protected
66
-
67
- def warden
68
- @warden ||= request.env['warden']
69
- end
70
-
71
- def current_client
72
- @current_client ||= warden.user(:client)
73
- end
54
+ ### Validate signature
74
55
 
75
- def authenticate_client!
76
- warden.authenticate!(:client_authenticatable, scope: :client)
77
- end
78
- end
79
- end
80
- end
81
- ```
56
+ Validate the request on the client-side. Note, that access_key can be extracted from the request.
82
57
 
83
58
  ```ruby
84
- class Api::BaseController < ActionController::API do
85
- abstract!
59
+ # the request to validate
60
+ request = {
61
+ :http_method=>"POST",
62
+ :url=>"https://example.com/posts",
63
+ :headers=>{
64
+ "User-Agent"=>"Test agent",
65
+ "host"=>"example.com",
66
+ "x-datetime"=>"2020-01-02T10:24:59.837+0000",
67
+ "authorization"=>"API-HMAC-SHA256 Credential=access_key/20200102/web/api_request, SignedHeaders=host;user-agent;x-datetime, Signature=032fc0b7defd66d86ef43ced8e6c3ee351ede21deca6bf1f89b9145f7a9105c1"
68
+ },
69
+ :body=>"body"
70
+ }
86
71
 
87
- include MyApplication::API::Authentication
72
+ # initialize validator with a request to validate
73
+ validator = ApiSignature::Validator.new(request)
88
74
 
89
- before_action :authenticate_client!
90
- end
91
- ```
75
+ # get access key from request headers (String)
76
+ validator.access_key
92
77
 
93
- ### On client side:
78
+ # validate the request (Boolean)
79
+ validator.valid?('your secret key here')
94
80
 
95
- ```ruby
96
- options = {
97
- request_method: 'GET',
98
- path: '/api/v1/some_path'
99
- access_key: 'client public api_key',
100
- timestamp: Time.now.utc.to_i
101
- }
102
-
103
- signature = ApiSignature::Generator.new(options).generate_signature('api_secret')
81
+ # get only signed headers (Hash)
82
+ validator.signed_headers
104
83
  ```
105
84
 
106
- By default, the generated signature will be valid for 2 hours
85
+ ## Configuration
86
+
87
+ By default, the generated signature will be valid for 5 minutes
107
88
  This could be changed via initializer:
108
89
 
109
90
  ```ruby
110
91
  # config/initializers/api_signature.rb
111
92
 
112
93
  ApiSignature.setup do |config|
113
- config.signature_ttl = 1.minute
94
+ # Time to live, by default 5 minutes
95
+ config.signature_ttl = 5 * 60
96
+
97
+ # Datetime format, by default iso8601
98
+ config.datetime_format = '%Y-%m-%dT%H:%M:%S.%L%z'
99
+
100
+ # Header name, by default authorization
101
+ config.signature_header = 'authorization'
102
+
103
+ # Service name, by default web
104
+ config.service = 'web'
114
105
  end
115
106
  ```
116
107
 
@@ -21,12 +21,8 @@ Gem::Specification.new do |spec|
21
21
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
22
  spec.require_paths = ['lib']
23
23
 
24
- spec.add_development_dependency 'bundler', '~> 1.15'
25
24
  spec.add_development_dependency 'guard-rspec', '~> 4.7', '>= 4.7.3'
26
25
  spec.add_development_dependency 'rake', '~> 10.0'
27
26
  spec.add_development_dependency 'rb-fsevent', '0.9.8'
28
27
  spec.add_development_dependency 'rspec', '~> 3.0'
29
-
30
- spec.add_dependency 'activesupport', '>= 4.0'
31
- spec.add_dependency 'rack', '>= 1.6'
32
28
  end
@@ -1,19 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'api_signature/version'
4
- require 'active_support/time'
5
- require 'active_support/core_ext/class'
6
- require 'active_support/core_ext/object/try'
4
+ require 'api_signature/configuration'
7
5
 
8
6
  module ApiSignature
9
7
  autoload :Builder, 'api_signature/builder'
10
8
  autoload :Validator, 'api_signature/validator'
11
- autoload :Generator, 'api_signature/generator'
12
- autoload :Request, 'api_signature/request'
9
+ autoload :Signer, 'api_signature/signer'
10
+ autoload :Signature, 'api_signature/signature'
11
+ autoload :AuthHeader, 'api_signature/auth_header'
12
+ autoload :Utils, 'api_signature/utils'
13
13
 
14
- # Time to live for generated signature
15
- mattr_accessor :signature_ttl
16
- self.signature_ttl = 2.hours
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
17
25
 
18
26
  # @example
19
27
  # ApiSignature.setup do |config|
@@ -21,6 +29,6 @@ module ApiSignature
21
29
  # end
22
30
  #
23
31
  def self.setup
24
- yield self
32
+ yield configuration
25
33
  end
26
34
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSignature
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
@@ -1,51 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'active_support/hash_with_indifferent_access'
4
- require 'ostruct'
3
+ require 'uri'
5
4
 
6
5
  module ApiSignature
7
6
  class Builder
8
- OPTIONS_KEYS = [
9
- :access_key, :secret, :request_method, :path, :timestamp
10
- ].freeze
7
+ attr_reader :settings
11
8
 
12
- delegate(*OPTIONS_KEYS, to: :@settings)
13
- delegate :expired?, to: :signature_generator
9
+ SPLITTER = "\n"
14
10
 
15
- def initialize(settings = {})
16
- settings = HashWithIndifferentAccess.new(settings)
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
17
19
 
18
- settings['timestamp'] ||= Time.now.utc.to_i.to_s
19
- settings['request_method'] = (settings['request_method'] || settings['method']).upcase
20
+ def uri
21
+ @uri ||= extract_uri
22
+ end
20
23
 
21
- @settings = OpenStruct.new(settings.select { |k, _v| OPTIONS_KEYS.include?(k.to_sym) })
24
+ def host
25
+ @host ||= extract_host_from_uri
22
26
  end
23
27
 
24
28
  def headers
25
- {
26
- 'X-Access-Key' => options[:access_key],
27
- 'X-Timestamp' => options[:timestamp],
28
- 'X-Signature' => signature
29
- }
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] || '')
30
46
  end
31
47
 
32
- def options
33
- {
34
- timestamp: timestamp,
35
- request_method: request_method,
36
- path: path,
37
- access_key: access_key
48
+ def build_sign_headers(apply_checksum_header = false)
49
+ @sign_headers = {
50
+ 'host' => host,
51
+ 'x-datetime' => datetime
38
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(';')
39
67
  end
40
68
 
41
- def signature
42
- @signature ||= signature_generator.generate_signature(secret)
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)
43
78
  end
44
79
 
45
80
  private
46
81
 
47
- def signature_generator
48
- @signature_generator ||= Generator.new(options)
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(ApiSignature.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)
49
117
  end
50
118
  end
51
119
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiSignature
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 ApiSignature
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 ApiSignature
6
+ # The signer requires secret key.
7
+ #
8
+ # signer = ApiSignature::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] || ApiSignature.configuration.signature_header
91
+ end
92
+
93
+ def service
94
+ @options[:service] || ApiSignature.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
@@ -33,11 +33,16 @@ module ApiSignature
33
33
  private
34
34
 
35
35
  def with_signature(http_method, api_key, secret, action_name, params = {})
36
+ custom_headers = (params.delete(:headers) || {})
36
37
  path = PathBuilder.new(controller, action_name, params).path
37
- headers = HeadersBuilder.new(api_key, secret, http_method, path).headers
38
- custom_headers = params.delete(:headers) || {}
39
38
 
40
- send(http_method, path, params, headers.merge(custom_headers))
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)
41
46
  end
42
47
  end
43
48
  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 ApiSignature
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 ||= ApiSignature.configuration.datetime_format
115
+ DateTime.strptime(value, format)
116
+ rescue ArgumentError => _e
117
+ nil
118
+ end
119
+ end
120
+ end
@@ -1,39 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiSignature
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 = ApiSignature::Validator.new(request, uri_escape_path: true)
18
+ # validator.access_key # get key from request headers
19
+ # validator.valid?('secret_key')
20
+ #
4
21
  class Validator
5
- attr_reader :timestamp
22
+ attr_reader :request
6
23
 
7
- def initialize(options)
24
+ def initialize(request, options = {})
25
+ @request = request
8
26
  @options = options
9
- @timestamp = Time.at(@options[:timestamp].to_i).utc
10
27
  end
11
28
 
12
- def valid?(signature, secret)
13
- return false if signature.blank? || secret.blank? || expired?
14
- generator.generate_signature(secret) == signature
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)
15
59
  end
16
60
 
17
- def expired?
18
- !alive?
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
+ )
19
71
  end
20
72
 
21
73
  private
22
74
 
23
- def generator
24
- @generator ||= Generator.new(@options)
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] || ApiSignature.configuration.signature_header
25
81
  end
26
82
 
27
- def alive?
28
- alive_timerange.cover?(timestamp)
83
+ def timestamp
84
+ @timestamp ||= Utils.safe_parse_datetime(headers['x-datetime'])
29
85
  end
30
86
 
31
- def alive_timerange
32
- @alive_timerange ||= (ttl.ago..ttl.from_now)
87
+ def headers
88
+ @headers ||= Utils.normalize_keys(request[:headers])
33
89
  end
34
90
 
35
- def ttl
36
- ApiSignature.signature_ttl
91
+ def ttl_range
92
+ to = Time.now.utc
93
+ from = to - ApiSignature.configuration.signature_ttl
94
+
95
+ from..to
37
96
  end
38
97
  end
39
98
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiSignature
4
- VERSION = '0.1.5'
4
+ VERSION = '1.0.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_signature
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Galeta
@@ -9,22 +9,8 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2019-05-27 00:00:00.000000000 Z
12
+ date: 2020-01-07 00:00:00.000000000 Z
13
13
  dependencies:
14
- - !ruby/object:Gem::Dependency
15
- name: bundler
16
- requirement: !ruby/object:Gem::Requirement
17
- requirements:
18
- - - "~>"
19
- - !ruby/object:Gem::Version
20
- version: '1.15'
21
- type: :development
22
- prerelease: false
23
- version_requirements: !ruby/object:Gem::Requirement
24
- requirements:
25
- - - "~>"
26
- - !ruby/object:Gem::Version
27
- version: '1.15'
28
14
  - !ruby/object:Gem::Dependency
29
15
  name: guard-rspec
30
16
  requirement: !ruby/object:Gem::Requirement
@@ -87,34 +73,6 @@ dependencies:
87
73
  - - "~>"
88
74
  - !ruby/object:Gem::Version
89
75
  version: '3.0'
90
- - !ruby/object:Gem::Dependency
91
- name: activesupport
92
- requirement: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '4.0'
97
- type: :runtime
98
- prerelease: false
99
- version_requirements: !ruby/object:Gem::Requirement
100
- requirements:
101
- - - ">="
102
- - !ruby/object:Gem::Version
103
- version: '4.0'
104
- - !ruby/object:Gem::Dependency
105
- name: rack
106
- requirement: !ruby/object:Gem::Requirement
107
- requirements:
108
- - - ">="
109
- - !ruby/object:Gem::Version
110
- version: '1.6'
111
- type: :runtime
112
- prerelease: false
113
- version_requirements: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - ">="
116
- - !ruby/object:Gem::Version
117
- version: '1.6'
118
76
  description:
119
77
  email:
120
78
  - igor.malinovskiy@netfix.xyz
@@ -125,6 +83,7 @@ files:
125
83
  - ".gitignore"
126
84
  - ".rspec"
127
85
  - ".rubocop.yml"
86
+ - ".ruby-version"
128
87
  - ".travis.yml"
129
88
  - Gemfile
130
89
  - Guardfile
@@ -135,12 +94,14 @@ files:
135
94
  - bin/console
136
95
  - bin/setup
137
96
  - lib/api_signature.rb
97
+ - lib/api_signature/auth_header.rb
138
98
  - lib/api_signature/builder.rb
139
- - lib/api_signature/generator.rb
140
- - lib/api_signature/request.rb
141
- - lib/api_signature/spec_support/headers_builder.rb
99
+ - lib/api_signature/configuration.rb
100
+ - lib/api_signature/signature.rb
101
+ - lib/api_signature/signer.rb
142
102
  - lib/api_signature/spec_support/helper.rb
143
103
  - lib/api_signature/spec_support/path_builder.rb
104
+ - lib/api_signature/utils.rb
144
105
  - lib/api_signature/validator.rb
145
106
  - lib/api_signature/version.rb
146
107
  homepage: https://github.com/psyipm/api_signature
@@ -163,7 +124,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
124
  version: '0'
164
125
  requirements: []
165
126
  rubyforge_project:
166
- rubygems_version: 2.6.14.1
127
+ rubygems_version: 2.7.6.2
167
128
  signing_key:
168
129
  specification_version: 4
169
130
  summary: Sign API requests with HMAC signature
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openssl'
4
- require 'digest/sha1'
5
-
6
- module ApiSignature
7
- class Generator
8
- SPLITTER = '|'
9
-
10
- delegate :valid?, :expired?, :timestamp, to: :validator
11
-
12
- attr_reader :options
13
-
14
- def initialize(options = {})
15
- @options = options
16
- end
17
-
18
- def generate_signature(secret)
19
- hmac = OpenSSL::HMAC.digest(digest, secret, string_to_sign)
20
- Base64.encode64(hmac).chomp
21
- end
22
-
23
- private
24
-
25
- def validator
26
- Validator.new(options)
27
- end
28
-
29
- def digest
30
- OpenSSL::Digest::SHA256.new
31
- end
32
-
33
- def string_to_sign
34
- [
35
- options[:request_method],
36
- options[:path],
37
- options[:access_key],
38
- timestamp.to_i
39
- ].map(&:to_s).join(SPLITTER)
40
- end
41
- end
42
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rack/request'
4
-
5
- module ApiSignature
6
- class Request < ::Rack::Request
7
- HEADER_KEYS = {
8
- access_key: 'HTTP_X_ACCESS_KEY',
9
- signature: 'HTTP_X_SIGNATURE',
10
- timestamp: 'HTTP_X_TIMESTAMP'
11
- }.freeze
12
-
13
- def correct?(token, secret)
14
- access_key == token && validator.valid?(signature, secret)
15
- end
16
-
17
- def valid?
18
- timestamp.present? && signature.present? && access_key.present?
19
- end
20
-
21
- def timestamp
22
- @timestamp ||= @env[HEADER_KEYS[:timestamp]]
23
- end
24
-
25
- def signature
26
- @signature ||= @env[HEADER_KEYS[:signature]]
27
- end
28
-
29
- def access_key
30
- @access_key ||= @env[HEADER_KEYS[:access_key]]
31
- end
32
-
33
- private
34
-
35
- def validator
36
- @validator ||= Validator.new(validator_params)
37
- end
38
-
39
- def validator_params
40
- {
41
- request_method: request_method,
42
- path: path,
43
- access_key: access_key,
44
- timestamp: timestamp
45
- }
46
- end
47
- end
48
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ApiSignature
4
- module SpecSupport
5
- class HeadersBuilder
6
- attr_reader :access_key, :secret, :http_method, :path
7
-
8
- def initialize(access_key, secret, http_method, path)
9
- @access_key = access_key
10
- @secret = secret
11
- @http_method = http_method
12
- @path = path
13
- end
14
-
15
- def headers
16
- {
17
- 'HTTP_X_ACCESS_KEY' => access_key,
18
- 'HTTP_X_TIMESTAMP' => options[:timestamp],
19
- 'HTTP_X_SIGNATURE' => generator.generate_signature(secret)
20
- }
21
- end
22
-
23
- private
24
-
25
- def generator
26
- @generator ||= ::ApiSignature::Generator.new(options)
27
- end
28
-
29
- def options
30
- @options ||= {
31
- request_method: http_method.to_s.upcase,
32
- path: path,
33
- access_key: access_key,
34
- timestamp: Time.now.utc.to_i
35
- }
36
- end
37
- end
38
- end
39
- end