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:



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
103
104
# File 'lib/escher/auth.rb', line 50

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



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

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



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

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



315
316
317
318
319
# File 'lib/escher/auth.rb', line 315

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



352
353
354
355
356
# File 'lib/escher/auth.rb', line 352

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

#create_algoObject



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

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



211
212
213
# File 'lib/escher/auth.rb', line 211

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



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

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



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
153
154
# File 'lib/escher/auth.rb', line 122

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:



177
178
179
180
181
182
# File 'lib/escher/auth.rb', line 177

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



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

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



170
171
172
173
# File 'lib/escher/auth.rb', line 170

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



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

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)


283
284
285
# File 'lib/escher/auth.rb', line 283

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
45
46
# File 'lib/escher/auth.rb', line 37

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

#long_date(date) ⇒ Object



271
272
273
# File 'lib/escher/auth.rb', line 271

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

#mandatory_signed_headers_valid?(mandatory_signed_headers) ⇒ Boolean

Returns:

  • (Boolean)


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

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



343
344
345
346
347
348
# File 'lib/escher/auth.rb', line 343

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



308
309
310
311
# File 'lib/escher/auth.rb', line 308

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

#parse_uri(request_uri) ⇒ Object



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

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



231
232
233
# File 'lib/escher/auth.rb', line 231

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

#query_key_for(key) ⇒ Object



164
165
166
# File 'lib/escher/auth.rb', line 164

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

#query_pair(k, v) ⇒ Object



158
159
160
# File 'lib/escher/auth.rb', line 158

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

#reconfig(algorithm, credential_scope, date) ⇒ Object



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

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



277
278
279
# File 'lib/escher/auth.rb', line 277

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



366
367
368
# File 'lib/escher/auth.rb', line 366

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

#uri_encode(component) ⇒ Object



360
361
362
# File 'lib/escher/auth.rb', line 360

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

#valid_request_method?(method) ⇒ Boolean

Returns:

  • (Boolean)


289
290
291
# File 'lib/escher/auth.rb', line 289

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