Class: Escher::Auth

Inherits:
Object
  • Object
show all
Defined in:
lib/escher/auth.rb

Instance Method Summary collapse

Constructor Details

#initialize(credential_scope, options = {}) ⇒ Auth

Returns a new instance of Auth.



4
5
6
7
8
9
10
11
12
13
14
15
# File 'lib/escher/auth.rb', line 4

def initialize(credential_scope, options = {})
  @credential_scope = credential_scope
  @algo_prefix = options[:algo_prefix] || 'ESR'
  @vendor_key = options[:vendor_key] || 'Escher'
  @hash_algo = options[:hash_algo] || 'SHA256'
  @current_time = options[:current_time]
  @auth_header_name = options[:auth_header_name] || 'X-Escher-Auth'
  @date_header_name = options[:date_header_name] || 'X-Escher-Date'
  @clock_skew = options[:clock_skew] || 300
  @algo = create_algo
  @algo_id = @algo_prefix + '-HMAC-' + @hash_algo
end

Instance Method Details

#authenticate(req, key_db, mandatory_signed_headers = nil) ⇒ Object

Raises:



48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/escher/auth.rb', line 48

def authenticate(req, key_db, mandatory_signed_headers = nil)
  current_time = @current_time || Time.now
  request = wrap_request req
  method = request.method
  body = request.body
  headers = request.headers
  path = request.path
  query_parts = request.query_values

  signature_from_query = get_signing_param('Signature', query_parts)

  (['Host'] + (signature_from_query ? [] : [@auth_header_name, @date_header_name])).each do |header|
    raise EscherError, 'The ' + header.downcase + ' header is missing' unless request.header header
  end

  if method == 'GET' && signature_from_query
    raw_date = get_signing_param('Date', query_parts)
    algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires = get_auth_parts_from_query(query_parts)

    body = 'UNSIGNED-PAYLOAD'
    query_parts.delete [query_key_for('Signature'), signature]
    query_parts = query_parts.map { |k, v| [k, v] }
  else
    raw_date = request.header @date_header_name
    raise EscherError, 'The ' + @date_header_name + ' header is missing' unless raw_date
    auth_header = request.header @auth_header_name
    raise EscherError, 'The ' + @auth_header_name + ' header is missing' unless raw_date
    algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires = get_auth_parts_from_header(auth_header)
  end

  date = Time.parse(raw_date)
  api_secret = key_db[api_key_id]

  raise EscherError, 'Invalid Escher key' unless api_secret
  raise EscherError, 'Invalid hash algorithm, only SHA256 and SHA512 are allowed' unless %w(SHA256 SHA512).include?(algorithm)
  raise EscherError, 'The request method is invalid' unless valid_request_method?(method)
  raise EscherError, "The request url shouldn't contains http or https" if path.match /^https?:\/\//
  raise EscherError, 'Invalid date in authorization header, it should equal with date header' unless short_date(date) == short_date
  raise EscherError, 'The request date is not within the accepted time range' unless is_date_within_range?(date, expires, current_time)
  raise EscherError, 'Invalid Credential Scope' unless credential_scope == @credential_scope
  raise EscherError, 'The mandatorySignedHeaders parameter must be undefined or array of strings' unless mandatory_signed_headers_valid?(mandatory_signed_headers)
  raise EscherError, 'The host header is not signed' unless signed_headers.include? 'host'
  unless mandatory_signed_headers.nil?
    mandatory_signed_headers.each do |header|
      raise EscherError, "The #{header} header is not signed" unless signed_headers.include? header
    end
  end
  raise EscherError, 'Only the host header should be signed' if signature_from_query && signed_headers != ['host']
  raise EscherError, 'The date header is not signed' if !signature_from_query && !signed_headers.include?(@date_header_name.downcase)

  escher = reconfig(algorithm, credential_scope, date)
  expected_signature = escher.generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts, date)
  raise EscherError, 'The signatures do not match' unless signature == expected_signature
  api_key_id
end

#canonicalize(method, path, query_parts, body, headers, headers_to_sign) ⇒ Object



215
216
217
218
219
220
221
222
223
224
225
# File 'lib/escher/auth.rb', line 215

def canonicalize(method, path, query_parts, body, headers, headers_to_sign)
  [
    method,
    canonicalize_path(path),
    canonicalize_query(query_parts),
    canonicalize_headers(headers, headers_to_sign).join("\n"),
    '',
    prepare_headers_to_sign(headers_to_sign),
    @algo.new.hexdigest(body)
  ].join "\n"
end

#canonicalize_headers(raw_headers, headers_to_sign) ⇒ Object



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/escher/auth.rb', line 321

def canonicalize_headers(raw_headers, headers_to_sign)
  headers = {}
  raw_headers.each do |raw_header|
    if raw_header[0].downcase != @auth_header_name.downcase
      if headers[raw_header[0].downcase]
        headers[raw_header[0].downcase] << raw_header[1]
      else
        headers[raw_header[0].downcase] = [raw_header[1]]
      end
    end
  end
  headers_to_sign.map!(&:downcase)
  headers
  .sort
  .select { |h| headers_to_sign.include?(h[0]) }
  .map { |k, v| k + ':' + v.map { |piece| normalize_white_spaces piece }.join(',') }
end

#canonicalize_path(path) ⇒ Object



313
314
315
316
317
# File 'lib/escher/auth.rb', line 313

def canonicalize_path(path)
  while path.gsub!(%r{([^/]+)/\.\./?}) { |match| $1 == '..' ? match : '' } do
  end
  path.gsub(%r{/\./}, '/').sub(%r{/\.\z}, '/').gsub(/\/+/, '/')
end

#canonicalize_query(query_parts) ⇒ Object



350
351
352
353
354
# File 'lib/escher/auth.rb', line 350

def canonicalize_query(query_parts)
  query_parts
  .map { |k, v| uri_encode(k.gsub('+', ' ')) + '=' + uri_encode(v || '') }
  .sort.join '&'
end

#create_algoObject



256
257
258
259
260
261
262
263
264
265
# File 'lib/escher/auth.rb', line 256

def create_algo
  case @hash_algo
    when 'SHA256'
      @algo = OpenSSL::Digest::SHA256.new
    when 'SHA512'
      @algo = OpenSSL::Digest::SHA512.new
    else
      raise EscherError, 'Unidentified hash algorithm'
  end
end

#format_date_for_header(current_time) ⇒ Object



209
210
211
# File 'lib/escher/auth.rb', line 209

def format_date_for_header(current_time)
  @date_header_name.downcase == 'date' ? current_time.utc.rfc2822.sub('-0000', 'GMT') : long_date(current_time)
end

#generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts, current_time) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
# File 'lib/escher/auth.rb', line 195

def generate_signature(api_secret, body, headers, method, signed_headers, path, query_parts, current_time)
  canonicalized_request = canonicalize(method, path, query_parts, body, headers, signed_headers.uniq)
  string_to_sign = get_string_to_sign(canonicalized_request, current_time)

  signing_key = OpenSSL::HMAC.digest(@algo, @algo_prefix + api_secret, short_date(current_time))
  @credential_scope.split('/').each { |data|
    signing_key = OpenSSL::HMAC.digest(@algo, signing_key, data)
  }

  OpenSSL::HMAC.hexdigest(@algo, signing_key, string_to_sign)
end

#generate_signed_url(url_to_sign, client, expires = 86400) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/escher/auth.rb', line 120

def generate_signed_url(url_to_sign, client, expires = 86400)
  current_time = @current_time || Time.now
  uri = Addressable::URI.parse(url_to_sign)

  if (not uri.port.nil?) && (uri.port != uri.default_port)
    host = "#{uri.host}:#{uri.port}"
  else
    host = uri.host
  end

  path = uri.path
  query_parts = (uri.query || '')
  .split('&', -1)
  .map { |pair| pair.split('=', -1) }
  .map { |k, v| (k.include? ' ') ? [k.str(/\S+/), ''] : [k, v] }
  .map { |k, v| [uri_decode(k), uri_decode(v)] }
  fragment = uri.fragment

  headers = [['host', host]]
  headers_to_sign = ['host']
  body = 'UNSIGNED-PAYLOAD'
  query_parts += [
    ['Algorithm', @algo_id],
    ['Credentials', "#{client[:api_key_id]}/#{short_date(current_time)}/#{@credential_scope}"],
    ['Date', long_date(current_time)],
    ['Expires', expires.to_s],
    ['SignedHeaders', headers_to_sign.join(';')],
  ].map { |k, v| query_pair(k, v) }

  signature = generate_signature(client[:api_secret], body, headers, 'GET', headers_to_sign, path, query_parts, current_time)
  query_parts_with_signature = (query_parts.map { |k, v| [uri_encode(k), uri_encode(v)] } << query_pair('Signature', signature))
  "#{uri.scheme}://#{host}#{path}?#{query_parts_with_signature.map { |k, v| k + '=' + v }.join('&')}#{(fragment === nil ? '' : '#' + fragment)}"
end

#get_auth_parts_from_header(auth_header) ⇒ Object

Raises:



175
176
177
178
179
180
# File 'lib/escher/auth.rb', line 175

def get_auth_parts_from_header(auth_header)
  m = /#{@algo_prefix}-HMAC-(?<algo>[A-Z0-9\,]+) Credential=(?<api_key_id>[A-Za-z0-9\-_]+)\/(?<short_date>[0-9]{8})\/(?<credentials>[A-Za-z0-9\-_ \/]+), SignedHeaders=(?<signed_headers>[A-Za-z\-;]+), Signature=(?<signature>[0-9a-f]+)$/
  .match auth_header
  raise EscherError, 'Invalid auth header format' unless m && m['credentials']
  return m['algo'], m['api_key_id'], m['short_date'], m['credentials'], m['signed_headers'].split(';'), m['signature'], 0
end

#get_auth_parts_from_query(query_parts) ⇒ Object



184
185
186
187
188
189
190
191
# File 'lib/escher/auth.rb', line 184

def get_auth_parts_from_query(query_parts)
  expires = get_signing_param('Expires', query_parts).to_i
  api_key_id, short_date, credential_scope = get_signing_param('Credentials', query_parts).split('/', 3)
  signed_headers = get_signing_param('SignedHeaders', query_parts).split ';'
  algorithm = parse_algo(get_signing_param('Algorithm', query_parts))
  signature = get_signing_param('Signature', query_parts)
  return algorithm, api_key_id, short_date, credential_scope, signed_headers, signature, expires
end

#get_signing_param(key, query_parts) ⇒ Object



168
169
170
171
# File 'lib/escher/auth.rb', line 168

def get_signing_param(key, query_parts)
  the_param = (query_parts.detect { |param| param[0] === query_key_for(key) })
  the_param ? uri_decode(the_param[1]) : nil
end

#get_string_to_sign(canonicalized_request, current_time) ⇒ Object



245
246
247
248
249
250
251
252
# File 'lib/escher/auth.rb', line 245

def get_string_to_sign(canonicalized_request, current_time)
  [
    @algo_id,
    long_date(current_time),
    short_date(current_time) + '/' + @credential_scope,
    @algo.new.hexdigest(canonicalized_request)
  ].join("\n")
end

#is_date_within_range?(request_date, expires, current_time) ⇒ Boolean

Returns:

  • (Boolean)


281
282
283
# File 'lib/escher/auth.rb', line 281

def is_date_within_range?(request_date, expires, current_time)
  (request_date - @clock_skew .. request_date + expires + @clock_skew).cover? current_time
end

#is_valid?(*args) ⇒ Boolean

Returns:

  • (Boolean)


37
38
39
40
41
42
43
44
# File 'lib/escher/auth.rb', line 37

def is_valid?(*args)
  begin
    authenticate(*args)
    return true
  rescue
    return false
  end
end

#long_date(date) ⇒ Object



269
270
271
# File 'lib/escher/auth.rb', line 269

def long_date(date)
  date.utc.strftime('%Y%m%dT%H%M%SZ')
end

#mandatory_signed_headers_valid?(mandatory_signed_headers) ⇒ Boolean

Returns:

  • (Boolean)


293
294
295
296
297
298
299
300
301
302
# File 'lib/escher/auth.rb', line 293

def mandatory_signed_headers_valid?(mandatory_signed_headers)
  if mandatory_signed_headers.nil?
    return true
  else
    return false unless mandatory_signed_headers.is_a? Array
    return false unless mandatory_signed_headers.all? { |header| header.is_a? String }
  end

  true
end

#normalize_white_spaces(value) ⇒ Object



341
342
343
344
345
346
# File 'lib/escher/auth.rb', line 341

def normalize_white_spaces(value)
  value.strip.split('"', -1).map.with_index { |piece, index|
    is_inside_of_quotes = (index % 2 == 1)
    is_inside_of_quotes ? piece : piece.gsub(/\s+/, ' ')
  }.join '"'
end

#parse_algo(algorithm) ⇒ Object



306
307
308
309
# File 'lib/escher/auth.rb', line 306

def parse_algo(algorithm)
  m = /^#{@algo_prefix}-HMAC-(?<algo>[A-Z0-9\,]+)$/.match(algorithm)
  m && m['algo']
end

#parse_uri(request_uri) ⇒ Object



235
236
237
238
239
240
241
# File 'lib/escher/auth.rb', line 235

def parse_uri(request_uri)
  path, query = request_uri.split '?', 2
  return path, (query || '')
  .split('&', -1)
  .map { |pair| pair.split('=', -1) }
  .map { |k, v| (k.include? ' ') ? [k.str(/\S+/), ''] : [k, v] }
end

#prepare_headers_to_sign(headers_to_sign) ⇒ Object



229
230
231
# File 'lib/escher/auth.rb', line 229

def prepare_headers_to_sign(headers_to_sign)
  headers_to_sign.map(&:downcase).sort.uniq.join(';')
end

#query_key_for(key) ⇒ Object



162
163
164
# File 'lib/escher/auth.rb', line 162

def query_key_for(key)
  "X-#{@vendor_key}-#{key}"
end

#query_pair(k, v) ⇒ Object



156
157
158
# File 'lib/escher/auth.rb', line 156

def query_pair(k, v)
  [query_key_for(k), v]
end

#reconfig(algorithm, credential_scope, date) ⇒ Object



106
107
108
109
110
111
112
113
114
115
116
# File 'lib/escher/auth.rb', line 106

def reconfig(algorithm, credential_scope, date)
  self.class.new(
    credential_scope,
    algo_prefix: @algo_prefix,
    vendor_key: @vendor_key,
    hash_algo: algorithm,
    auth_header_name: @auth_header_name,
    date_header_name: @date_header_name,
    current_time: date
  )
end

#short_date(date) ⇒ Object



275
276
277
# File 'lib/escher/auth.rb', line 275

def short_date(date)
  date.utc.strftime('%Y%m%d')
end

#sign!(req, client, headers_to_sign = []) ⇒ Object

Raises:



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/escher/auth.rb', line 19

def sign!(req, client, headers_to_sign = [])
  current_time = @current_time || Time.now

  headers_to_sign |= [@date_header_name.downcase, 'host']

  request = wrap_request req
  raise EscherError, 'The host header is missing' unless request.has_header? 'host'

  request.set_header(@date_header_name.downcase, format_date_for_header(current_time)) unless request.has_header? @date_header_name

  signature = generate_signature(client[:api_secret], request.body, request.headers, request.method, headers_to_sign, request.path, request.query_values, current_time)
  request.set_header(@auth_header_name, "#{@algo_id} Credential=#{client[:api_key_id]}/#{short_date(current_time)}/#{@credential_scope}, SignedHeaders=#{prepare_headers_to_sign headers_to_sign}, Signature=#{signature}")

  request.request
end

#uri_decode(component) ⇒ Object



364
365
366
# File 'lib/escher/auth.rb', line 364

def uri_decode(component)
  Addressable::URI.unencode_component(component)
end

#uri_encode(component) ⇒ Object



358
359
360
# File 'lib/escher/auth.rb', line 358

def uri_encode(component)
  Addressable::URI.encode_component(component, Addressable::URI::CharacterClasses::UNRESERVED)
end

#valid_request_method?(method) ⇒ Boolean

Returns:

  • (Boolean)


287
288
289
# File 'lib/escher/auth.rb', line 287

def valid_request_method?(method)
  %w(OPTIONS GET HEAD POST PUT DELETE TRACE PATCH CONNECT).include? method.upcase
end