api_signature 0.1.5 → 1.0.0

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