From 33d23e6509367b268a117a5b9dda9039259c9ce9 Mon Sep 17 00:00:00 2001 From: Thomas Heinen <33926466+tecracer-theinen@users.noreply.github.com> Date: Tue, 6 Sep 2022 09:58:27 +0200 Subject: [PATCH] Implementation of HMAC authorizer (#20) * Implementation of HMAC authorizer * Add AWSv4 signature authorizer * Bugfixes; auth_handler error processing * Improve error handling * Small fixes --- .devcontainer/Dockerfile | 18 +++ .devcontainer/devcontainer.json | 32 +++++ .devcontainer/requirements.txt | 2 + CHANGELOG.md | 11 ++ README.md | 30 +++++ lib/train-rest.rb | 6 +- lib/train-rest/auth_handler.rb | 27 +++- lib/train-rest/auth_handler/awsv4.rb | 119 ++++++++++++++++++ lib/train-rest/auth_handler/hmac-signature.rb | 39 ++++++ lib/train-rest/connection.rb | 40 ++++-- lib/train-rest/errors.rb | 6 + lib/train-rest/version.rb | 2 +- train-rest.gemspec | 1 + 13 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/requirements.txt create mode 100644 lib/train-rest/auth_handler/awsv4.rb create mode 100644 lib/train-rest/auth_handler/hmac-signature.rb create mode 100644 lib/train-rest/errors.rb diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..40598f5 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,18 @@ +ARG VARIANT="3.0" +FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} + +ARG DIRENV_VERSION=2.32.1 + +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends jq vim direnv yamllint python3-pip python3-setuptools git less python3-dev + +COPY requirements.txt /tmp/ +RUN pip3 install --requirement /tmp/requirements.txt + +RUN curl --location-trusted https://github.com/direnv/direnv/releases/download/v${DIRENV_VERSION}/direnv.linux-amd64 --output /usr/local/bin/direnv --silent \ + && chmod +x /usr/local/bin/direnv + +RUN echo -e "setlocal noautoindent\nsetlocal nocindent\nsetlocal nosmartindent\nsetlocal indentexpr=\"\n" > /home/${USER}/.vimrc \ + && echo 'alias awsume="source awsume"' >> /home/${USER}/.bashrc \ + && echo 'eval "$(direnv hook bash)"' >> /home/${USER}/.bashrc \ + && mkdir /home/${USER}/.aws diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4754cd2 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Ruby", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "3", + "INSTALL_NODE": "false" + } + }, + "mounts": [ + "source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,readonly,type=bind", + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,readonly,type=bind", + "target=/home/vscode/.aws/cli/cache,type=tmpfs,tmpfs-mode=1777" + ], + "extensions": [ + "editorconfig.editorconfig", + "rebornix.ruby", + "redhat.vscode-yaml", + "VisualStudioExptTeam.vscodeintellicode", + "wingrunr21.vscode-ruby" + ], + "remoteUser": "vscode", + "remoteEnv": { + "PATH": "/home/vscode/bin:/home/vscode/.local/bin:${containerEnv:PATH}", + + "AWS_ACCESS_KEY_ID": "${localEnv:AWS_ACCESS_KEY_ID}", + "AWS_SECRET_ACCESS_KEY": "${localEnv:AWS_SECRET_ACCESS_KEY}", + "AWS_REGION": "${localEnv:AWS_REGION}", + "AWS_SESSION_TOKEN": "${localEnv:AWS_SESSION_TOKEN}" + }, + "postAttachCommand": "bundle config set --local with 'development' && awsume-configure && direnv allow ${containerWorkspaceFolder} || true" +} diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt new file mode 100644 index 0000000..c0df00a --- /dev/null +++ b/.devcontainer/requirements.txt @@ -0,0 +1,2 @@ +awscli +awsume diff --git a/CHANGELOG.md b/CHANGELOG.md index 436e759..4f9b6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Version 0.5.0 + +- Add proper processing of 400/401 errors +- Add auth_handler specific error processing +- Add new AWS v4 signature authorizer +- Add new HMAC signature authorizer (#19) +- Add VSCode Dev Container +- Fix merging of user-provided and auth_handler-derived headers +- Fix missing prefix accessing `AuthHandler` +- Fix naming of auth_handlers which involve abbreviations + ## Version 0.4.2 - Add Apache2 license file diff --git a/README.md b/README.md index f4a35fb..509226f 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Provides a transport to communicate easily with RESTful APIs. ## Requirements - Gem `rest-client` in Version 2.1 +- Gem `awssig-v4` ## Installation @@ -44,6 +45,22 @@ Identifier: `auth_type: :authtype_apikey` | -------------------- | --------------------------------------- | ----------- | | `apikey` | API Key for authentication | _required_ | +### AWS Signature v4 + +Identifier: `auth_type: :awsv4` + +| Option | Explanation | Default | +| ------------------- | ----------------------------- | ------------------------ | +| `credentials` | Type of credentials to use | `access_keys` | +| `access_key` | ID of the access key | ENV: `ACCESS_KEY_ID` | +| `secret_access_key` | Secret part of the access key | ENV: `SECRET_ACCESS_KEY` | + +Only `access_keys` are supported as a credential currently. Support for other types, +like EC2 roles, is planned. + +Access key and secret access key are pulled from the mentioned environment variables, +if they are not provided. + ### Basic (RFC 2617) Identifier: `auth_type: :basic` @@ -79,6 +96,19 @@ Identifier: `auth_type: :header` | `apikey` | API Key for authentication | _required_ | | `header` | Name of the HTTP header to include | `X-API-Key` | +### HMAC Signature + +Identifier: `auth_type: :hmac_signature` + +| Option | Explanation | Default | +| -------------------- | --------------------------------------- | ------------- | +| `hmac_secret` | Shared secret to use for signing on | _required_ | +| `header` | Name of header to add | `X-Signature` | +| `digest` | OpenSSL Digest type supported by Ruby | `SHA256` | + +This will use the request body (payload) and sign it using HMAC. For a full list of +supported digest, look at [the Ruby documentation](https://ruby-doc.org/stdlib-2.7.0/libdoc/openssl/rdoc/OpenSSL/Digest.html) + ### Redfish Identifier: `auth_type: :redfish` diff --git a/lib/train-rest.rb b/lib/train-rest.rb index 2535190..46fbc98 100644 --- a/lib/train-rest.rb +++ b/lib/train-rest.rb @@ -1,14 +1,18 @@ libdir = File.dirname(__FILE__) $LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir) +require "train-rest/errors" require "train-rest/version" require "train-rest/transport" require "train-rest/connection" +require "train-rest/auth_handler" +require "train-rest/auth_handler/awsv4" require "train-rest/auth_handler/anonymous" require "train-rest/auth_handler/authtype-apikey" -require "train-rest/auth_handler/header" require "train-rest/auth_handler/basic" require "train-rest/auth_handler/bearer" +require "train-rest/auth_handler/header" +require "train-rest/auth_handler/hmac-signature" require "train-rest/auth_handler/redfish" diff --git a/lib/train-rest/auth_handler.rb b/lib/train-rest/auth_handler.rb index 88b25c9..0ed8299 100644 --- a/lib/train-rest/auth_handler.rb +++ b/lib/train-rest/auth_handler.rb @@ -67,6 +67,30 @@ def auth_parameters { headers: auth_headers } end + # This Auth Handler will need payload, URI and headers, e.g. for signatures. + # + # @return [Boolean] + def signature_based? + false + end + + # Return headers based on payload signing. + # + # @param [Hash] data different types of data for processing + # @option data [String] :payload contents of the message body + # @option data [Hash] :headers existing headers to the request + # @option data [String] :url URL which will be requested + # @option data [Symbol] :method Method to execute + # @returns [Hash] + def process(payload: "", headers: {}, url: "", method: nil) + {} + end + + # Allow processing errors related to authentication. + # + # @param [RestClient::Exception] error raw error data + def process_error(_error); end + class << self private @@ -77,7 +101,8 @@ class << self # @see https://github.com/chef/chef/blob/main/lib/chef/mixin/convert_to_class_name.rb def convert_to_snake_case(str) str = str.dup - str.gsub!(/[A-Z]/) { |s| "_" + s } + str.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + str.gsub!(/([a-z\d])([A-Z])/, '\1_\2') str.downcase! str.sub!(/^\_/, "") str diff --git a/lib/train-rest/auth_handler/awsv4.rb b/lib/train-rest/auth_handler/awsv4.rb new file mode 100644 index 0000000..8624709 --- /dev/null +++ b/lib/train-rest/auth_handler/awsv4.rb @@ -0,0 +1,119 @@ +require 'aws-sigv4' +require 'json' + +require_relative "../auth_handler" + +module TrainPlugins + module Rest + class AWSV4 < AuthHandler + VALID_CREDENTIALS = %w[ + access_keys + ].freeze + + SIGNED_HEADERS = %w[ + content-type host x-amz-date x-amz-target + ].freeze + + def check_options + options[:credentials] ||= "access_keys" + + unless VALID_CREDENTIALS.include? credentials + raise ArgumentError.new("Invalid type of credentials: #{credentials}") + end + + if access_keys? + raise ArgumentError.new('Missing `access_key` credential') unless access_key + raise ArgumentError.new('Missing `secret_access_key` credential') unless secret_access_key + end + end + + def signature_based? + true + end + + def process(payload: "", headers: {}, url: "", method: nil) + headers.merge! ({ + 'Accept-Encoding' => 'identity', + 'User-Agent' => "train-rest/#{TrainPlugins::Rest::VERSION}", + 'Content-Type' => 'application/x-amz-json-1.0' + }) + + signed_headers = headers.select do |name, _value| + SIGNED_HEADERS.include? name.downcase + end + + @url = url + + signature = signer(url).sign_request( + http_method: method.to_s.upcase, + url: url, + headers: signed_headers, + body: payload.to_json + ) + + { + headers: headers.merge(signature.headers) + } + end + + def process_error(error) + raise AuthenticationError.new("Authentication failed: #{error.response.to_s.chop}") if error.response.code == 401 + raise BadRequest.new("Bad request: #{error.response.to_s.chop}") if error.response.code == 400 + + message = JSON.parse(error.response.to_s) + + raise AuthenticationError.new(message["message"] || message["__type"]) + rescue JSON::ParserError => e + raise AuthenticationError.new(error.response.to_s) + end + + def access_key + options[:access_key] || ENV['AWS_ACCESS_KEY_ID'] + end + + def region(url = default_url) + url.delete_prefix('https://').split('.').at(1) + end + + private + + def credentials + options[:credentials] + end + + def default_url + options[:endpoint] + end + + def access_keys? + credentials == 'access_keys' + end + + def secret_access_key + options[:secret_access_key] || ENV['AWS_SECRET_ACCESS_KEY'] + end + + def service(url) + url.delete_prefix('https://').split('.').at(0) + end + + def signer(url) + Aws::Sigv4::Signer.new( + service: service(url), + region: region(url), + + **signer_credentials + ) + end + + def signer_credentials + if access_keys? + { + access_key_id: access_key, + secret_access_key: secret_access_key + } + end + end + end + end +end diff --git a/lib/train-rest/auth_handler/hmac-signature.rb b/lib/train-rest/auth_handler/hmac-signature.rb new file mode 100644 index 0000000..833f9ce --- /dev/null +++ b/lib/train-rest/auth_handler/hmac-signature.rb @@ -0,0 +1,39 @@ +require_relative "../auth_handler" + +module TrainPlugins + module Rest + # Authentication via HMAC Signature. + class HmacSignature < AuthHandler + def check_options + raise ArgumentError.new("Need :hmac_secret for HMAC signatures") unless options[:hmac_secret] + + options[:header] ||= "X-Signature" + options[:digest] ||= "SHA256" + end + + def hmac_secret + options[:hmac_secret] + end + + def digest + options[:digest] + end + + def header + options[:header] + end + + def signature_based? + true + end + + def process(payload: "", headers: {}, url: "", method: nil) + { + headers: { + header => OpenSSL::HMAC.hexdigest(digest, hmac_secret, payload) + } + } + end + end + end +end diff --git a/lib/train-rest/connection.rb b/lib/train-rest/connection.rb index d9fa146..ec69d4a 100644 --- a/lib/train-rest/connection.rb +++ b/lib/train-rest/connection.rb @@ -114,6 +114,21 @@ def request(path, method = :get, request_parameters: {}, data: nil, headers: {}, # Merge override headers + request specific headers parameters[:headers].merge!(override_headers || {}) parameters[:headers].merge!(headers) + + # Merge payload based headers (e.g. signature-based auth) + if auth_handler.signature_based? + auth_signature = auth_handler.process( + payload: data, + headers: parameters[:headers], + url: parameters[:url], + method: method + ) + + parameters[:headers].merge! auth_signature[:headers] + else + parameters[:headers].merge! auth_parameters[:headers] + end + parameters.compact! logger.info format("[REST] => %s", parameters.to_s) if options[:debug_rest] @@ -121,6 +136,9 @@ def request(path, method = :get, request_parameters: {}, data: nil, headers: {}, logger.info format("[REST] <= %s", response.to_s) if options[:debug_rest] transform_response(response, json_processing) + + rescue RestClient::Exception => error + auth_handler.process_error(error) end # Allow switching generic handlers for an API-specific one. @@ -144,12 +162,21 @@ def active_auth_handler options[:auth_type] end + attr_writer :auth_handler + # Auth Handlers-faced API def auth_parameters auth_handler.auth_parameters end + def auth_handler + desired_handler = auth_handler_classes.detect { |handler| handler.name == auth_type.to_s } + raise NameError.new(format("Authentication handler %s not found", auth_type.to_s)) unless desired_handler + + @auth_handler ||= desired_handler.new(self) + end + private def global_parameters @@ -160,8 +187,6 @@ def global_parameters headers: options[:headers], } - params.merge!(auth_parameters) - params end @@ -184,23 +209,14 @@ def auth_type :basic if options[:username] && options[:password] end - attr_writer :auth_handler - def auth_handler_classes - AuthHandler.descendants + ::TrainPlugins::Rest::AuthHandler.descendants end def auth_handlers auth_handler_classes.map { |handler| handler.name.to_sym } end - def auth_handler - desired_handler = auth_handler_classes.detect { |handler| handler.name == auth_type.to_s } - raise NameError.new(format("Authentication handler %s not found", auth_type.to_s)) unless desired_handler - - @auth_handler ||= desired_handler.new(self) - end - def login logger.info format("REST Login via %s authentication handler", auth_type.to_s) unless %i{anonymous basic}.include? auth_type diff --git a/lib/train-rest/errors.rb b/lib/train-rest/errors.rb new file mode 100644 index 0000000..ee2625b --- /dev/null +++ b/lib/train-rest/errors.rb @@ -0,0 +1,6 @@ +module TrainPlugins + module Rest + class AuthenticationError < RuntimeError; end + class BadRequest < RuntimeError; end + end +end diff --git a/lib/train-rest/version.rb b/lib/train-rest/version.rb index ebc1a1d..b7e1d3a 100644 --- a/lib/train-rest/version.rb +++ b/lib/train-rest/version.rb @@ -1,5 +1,5 @@ module TrainPlugins module Rest - VERSION = "0.4.2".freeze + VERSION = "0.5.0".freeze end end diff --git a/train-rest.gemspec b/train-rest.gemspec index 503ade7..0f0c7ab 100644 --- a/train-rest.gemspec +++ b/train-rest.gemspec @@ -19,6 +19,7 @@ Gem::Specification.new do |spec| ).reject { |f| File.directory?(f) } spec.require_paths = ["lib"] + spec.add_dependency "aws-sigv4", "~> 1.5" spec.add_dependency "train-core", "~> 3.0" spec.add_dependency "rest-client", "~> 2.1"