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] || Time.now
  @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:



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

def authenticate(req, key_db, mandatory_signed_headers = nil)
  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 body shouldn't be empty if the request method is POST" if (method.upcase == 'POST' && body.empty?)
  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)
  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)
  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



212
213
214
215
216
217
218
219
220
221
222
# File 'lib/escher/auth.rb', line 212

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



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

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



310
311
312
313
314
# File 'lib/escher/auth.rb', line 310

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



347
348
349
350
351
# File 'lib/escher/auth.rb', line 347

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

#create_algoObject



253
254
255
256
257
258
259
260
261
262
# File 'lib/escher/auth.rb', line 253

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

#format_date_for_headerObject



206
207
208
# File 'lib/escher/auth.rb', line 206

def format_date_for_header
  @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) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
# File 'lib/escher/auth.rb', line 192

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

  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



118
119
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
# File 'lib/escher/auth.rb', line 118

def generate_signed_url(url_to_sign, client, expires = 86400)
  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)
  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:



172
173
174
175
176
177
# File 'lib/escher/auth.rb', line 172

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



181
182
183
184
185
186
187
188
# File 'lib/escher/auth.rb', line 181

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



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

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) ⇒ Object



242
243
244
245
246
247
248
249
# File 'lib/escher/auth.rb', line 242

def get_string_to_sign(canonicalized_request)
  [
    @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) ⇒ Boolean

Returns:

  • (Boolean)


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

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

#is_valid?(*args) ⇒ Boolean

Returns:

  • (Boolean)


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

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

#long_date(date) ⇒ Object



266
267
268
# File 'lib/escher/auth.rb', line 266

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

#mandatory_signed_headers_valid?(mandatory_signed_headers) ⇒ Boolean

Returns:

  • (Boolean)


290
291
292
293
294
295
296
297
298
299
# File 'lib/escher/auth.rb', line 290

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



338
339
340
341
342
343
# File 'lib/escher/auth.rb', line 338

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



303
304
305
306
# File 'lib/escher/auth.rb', line 303

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

#parse_uri(request_uri) ⇒ Object



232
233
234
235
236
237
238
# File 'lib/escher/auth.rb', line 232

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



226
227
228
# File 'lib/escher/auth.rb', line 226

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

#query_key_for(key) ⇒ Object



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

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

#query_pair(k, v) ⇒ Object



153
154
155
# File 'lib/escher/auth.rb', line 153

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

#reconfig(algorithm, credential_scope, date) ⇒ Object



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

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



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

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

def sign!(req, client, headers_to_sign = [])
  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) 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)
  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



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

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

#uri_encode(component) ⇒ Object



355
356
357
# File 'lib/escher/auth.rb', line 355

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

#valid_request_method?(method) ⇒ Boolean

Returns:

  • (Boolean)


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

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