class Escher::Auth

Public Class Methods

new(credential_scope, options = {}) click to toggle source
# 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

Public Instance Methods

authenticate(req, key_db, mandatory_signed_headers = nil) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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_algo() click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# File lib/escher/auth.rb, line 164
def query_key_for(key)
  "X-#{@vendor_key}-#{key}"
end
query_pair(k, v) click to toggle source
# File lib/escher/auth.rb, line 158
def query_pair(k, v)
  [query_key_for(k), v]
end
reconfig(algorithm, credential_scope, date) click to toggle source
# 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) click to toggle source
# File lib/escher/auth.rb, line 277
def short_date(date)
  date.utc.strftime('%Y%m%d')
end
sign!(req, client, headers_to_sign = []) click to toggle source
# 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) click to toggle source
# File lib/escher/auth.rb, line 366
def uri_decode(component)
  Addressable::URI.unencode_component(component)
end
uri_encode(component) click to toggle source
# 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) click to toggle source
# 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

Private Instance Methods

wrap_request(request) click to toggle source
# File lib/escher/auth.rb, line 374
def wrap_request(request)
  Escher::Request::Factory.from_request request
end