From f3f636f35b6b2ae859ed693e4a8cd009527d0eac Mon Sep 17 00:00:00 2001 From: Jason Vanderhoof Date: Mon, 25 Jun 2018 13:58:54 -0600 Subject: [PATCH] Adds Authentication using IAM role (#586) This PR adds IAM authentication to the core. This allows an organization to leverage a host's existing IAM roles to authenticate with Conjur. --- .../authentication/authn_iam/authenticator.rb | 78 +++++++++++++ dev/files/authn-iam/entitlements.yml | 7 ++ dev/files/authn-iam/myapp.yml | 40 +++++++ dev/files/authn-iam/policy.yml | 12 ++ dev/start | 7 ++ .../authn_iam/authenticator_spec.rb | 106 ++++++++++++++++++ 6 files changed, 250 insertions(+) create mode 100755 app/domain/authentication/authn_iam/authenticator.rb create mode 100644 dev/files/authn-iam/entitlements.yml create mode 100644 dev/files/authn-iam/myapp.yml create mode 100644 dev/files/authn-iam/policy.yml create mode 100644 spec/app/domain/authentication/authn_iam/authenticator_spec.rb diff --git a/app/domain/authentication/authn_iam/authenticator.rb b/app/domain/authentication/authn_iam/authenticator.rb new file mode 100755 index 0000000000..4ed3eeab4b --- /dev/null +++ b/app/domain/authentication/authn_iam/authenticator.rb @@ -0,0 +1,78 @@ +require 'json' + +module Authentication + module AuthnIam + class Authenticator + + InvalidAWSHeaders = ::Util::ErrorClass.new( + "'Invalid or Expired AWS Headers: {0}") + + def initialize(env:) + @env = env + end + + def valid?(input) + + signed_aws_headers = JSON.parse input.password # input.password is JSON holding the AWS signed headers + + response_hash = identity_hash(response_from_signed_request(signed_aws_headers)) + trusted = response_hash != false + + trusted && iam_role_matches?(input.username, response_hash) + + end + + def identity_hash(response) + + Rails.logger.debug("AWS IAM get_caller_identity body\n#{response.body} ") + + if response.code < 300 + Hash.from_xml(response.body) + else + Rails.logger.error("Verification of IAM identity failed with HTTP code: #{response.code}") + false + end + + end + + def iam_role_matches?(login, response_hash) + + is_allowed_role = false + + split_assumed_role = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Arn"].split(":") + + # removes the last 2 parts of login to be substituted by the info from getCallerIdentity + host_prefix = (login.split("/")[0..-3]).join("/") + aws_role_name = split_assumed_role[5].split("/")[1] + aws_account_id = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["Account"] + aws_user_id = response_hash["GetCallerIdentityResponse"]["GetCallerIdentityResult"]["UserId"] + host_to_match = "#{host_prefix}/#{aws_account_id}/#{aws_role_name}" + + Rails.logger.debug("IAM Role authentication attempt by AWS user #{aws_user_id} with host to match = #{host_to_match}") + + login.eql? host_to_match + + end + + def aws_signed_url + return 'https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15' + end + + def response_from_signed_request(aws_headers) + + Rails.logger.debug("Retrieving IAM identity") + RestClient.log = Rails.logger + begin + RestClient.get(aws_signed_url, headers = aws_headers) + rescue RestClient::ExceptionWithResponse => e + Rails.logger.error("Verification of IAM identity Exception #{e.to_s}") + raise InvalidAWSHeaders, e.to_s + end + + end + + end + + end +end + diff --git a/dev/files/authn-iam/entitlements.yml b/dev/files/authn-iam/entitlements.yml new file mode 100644 index 0000000000..c23a1fc0de --- /dev/null +++ b/dev/files/authn-iam/entitlements.yml @@ -0,0 +1,7 @@ +- !grant + role: !group conjur/authn-iam/prod/clients + member: !host /myapp/011915987442/MyApp + +- !grant + role: !group conjur/authn-iam/prod/clients + member: !host /myapp/011915987442/lambda-function-role diff --git a/dev/files/authn-iam/myapp.yml b/dev/files/authn-iam/myapp.yml new file mode 100644 index 0000000000..f7e4a17a1d --- /dev/null +++ b/dev/files/authn-iam/myapp.yml @@ -0,0 +1,40 @@ + +--- +- !policy + id: myapp + body: + - &variables + - !variable database/username + - !variable database/password + + # Create a group that will have permission to retrieve variables + - !group secrets-users + + # Give the `secrets-users` group permission to retrieve variables + - !permit + role: !group secrets-users + privilege: [ read, execute ] + resource: *variables + + # Create a layer to hold this application's hosts + - !layer + + # Create a host using the namespace `aws` to identify this as an AWS resource. + # The host ID needs to match the AWS ARN of the role we wish to to authenticate. + - !host 011915987442/MyApp + - !host 011915987442/lambda-function-role + + # Add our host into our layer + - !grant + role: !layer + member: !host 011915987442/MyApp + + # Add our host into our layer + - !grant + role: !layer + member: !host 011915987442/lambda-function-role + + # Give the host in our layer permission to retrieve variables + - !grant + member: !layer + role: !group secrets-users diff --git a/dev/files/authn-iam/policy.yml b/dev/files/authn-iam/policy.yml new file mode 100644 index 0000000000..eac116af45 --- /dev/null +++ b/dev/files/authn-iam/policy.yml @@ -0,0 +1,12 @@ + +- !policy + id: conjur/authn-iam/prod + body: + - !webservice + + - !group clients + + - !permit + role: !group clients + privilege: [ read, authenticate ] + resource: !webservice diff --git a/dev/start b/dev/start index 1b6d5c7774..47879b868c 100755 --- a/dev/start +++ b/dev/start @@ -12,6 +12,7 @@ Usage: start [options] Drops you into the cucumber container. You then manually start `conjurctl server` in another tab. + --authn-iam Starts with authn-iam/prod as authenticator -h, --help Shows this help message. EOF exit @@ -21,9 +22,11 @@ unset COMPOSE_PROJECT_NAME # Determine which extra services should be loaded when working with authenticators ENABLE_AUTHN_LDAP=false +ENABLE_AUTHN_IAM=false ENABLE_ROTATORS=false while true ; do case "$1" in + --authn-iam ) ENABLE_AUTHN_IAM=true ; shift ;; --authn-ldap ) ENABLE_AUTHN_LDAP=true ; shift ;; --rotators ) ENABLE_ROTATORS=true ; shift ;; -h | --help ) print_help ; shift ;; @@ -61,6 +64,10 @@ fi if [[ $ENABLE_ROTATORS = true ]]; then services="$services testdb cucumber" fi +if [[ $ENABLE_AUTHN_LDAP = true ]]; then + env_args="$env_args -e CONJUR_AUTHENTICATORS=authn-iam/prod" + docker-compose exec conjur conjurctl policy load cucumber /src/conjur-server/dev/files/authn-iam/policy.yml +fi docker-compose up -d --no-deps $services diff --git a/spec/app/domain/authentication/authn_iam/authenticator_spec.rb b/spec/app/domain/authentication/authn_iam/authenticator_spec.rb new file mode 100644 index 0000000000..75798d44f0 --- /dev/null +++ b/spec/app/domain/authentication/authn_iam/authenticator_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +RSpec.describe Authentication::AuthnIam::Authenticator do + + def expired_aws_headers + "{\"host\":\"sts.amazonaws.com\",\"x-amz-date\":\"20180620T025910Z\","\ + "\"x-amz-security-token\":\"FQoDYXdzEPv//////////wEaDHwvkDqh5pHmZNe5hSK3AzevmnHjzweG6m1in"\ + "/CQ8NB7PCY0nTtWsCXLU5FsHmOoXs6KgVOu8ucghebak4b/iaDCpSprH3GPjLcNatywkUEQqX8rQKy2DoKMy7ZMHNT1ivhEn "\ + "vE3HR0GPkkGGWYhLTTrQDdI5fBcb3yJ /TyrcmUuBTKXwQJmvcnDe505SPpuSZm7tdrDX5SpItMngqGcrRhCjuprpk5nPVwSQ"\ + "q6usp7hJYPmu/6u9eVP3rQ TFPldhRvRxu5rcssURdrIwbjMugZQff/8XERxyxPrTkJQekcqMvvV6gexDZcBOS1JtIsKfJEXU"\ + "mK3kwV4liQsUevxyanWMc4jT0tiBkDj2 nvXUFt6dejppdTTRdEtBXg5xZUrGDCQDUU9eBgydoTLGav9rWiM7bWtpP4A1m0E9"\ + "LoX47FScSDkqk0Hy6Dr9jzhb4HOodlgaldTs8BNlgN9xXgACdacdPqnhaYLCgAWsaUZPKuZmdyH96F59rcrVscf456ivXTrXp"\ + "t6pL1ZQyRCc04hkovErvv1L2CwEaGAc k0bvbq0pbzTftTh7 9xY3pFxbL AALoR0t2/CfhyomvoG72Cl/nvAo7 "\ + "m2QU\",\"x-amz-content-sha256\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b"\ + "855\",\"authorization\":\"AWS4-HMAC-SHA256 Credential=ASIAJJTVXJS5KDKXKNPQ/20180620/us-east-1/sts/aw"\ + "s4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=230fa"\ + "38a232969747b77e82f6c845f63941ebde89eb2cc20ed1c6f2dbabc92b6\"}" + end + + def valid_response + double('HTTPResponse', + code: 200, + body: %( + + + arn:aws:sts::011915987442:assumed-role/MyApp/i-0a5702a5a078e1a00 + AROAIYXQMEFIAVEOFMW5Y:i-0a5702a5a078e1a00 + 011915987442 + + + f555066a-7417-11e8-8ded-8daed431985e + + + ) + ) + end + + def invalid_response + double('HTTPResponse', + code: 404, + body: "Error" + ) + end + + def valid_login + "host/myapp/011915987442/MyApp" + end + + def invalid_login + "host/myapp/InvalidAccount/InvalidRole" + end + + let (:authenticator_instance) do + Authentication::AuthnIam::Authenticator.new(env:[]) + end + + it "valid? with expired AWS headers" do + subject = authenticator_instance + parameters = double('AuthenticationParameters', password: expired_aws_headers) + expect{subject.valid?(parameters)}.to( + raise_error(Authentication::AuthnIam::Authenticator::InvalidAWSHeaders) + ) + end + + it "validates identity_hash with valid response" do + subject = authenticator_instance + expect { subject.identity_hash(valid_response) }.to_not raise_error + + expect(subject.identity_hash(valid_response)).to have_key("GetCallerIdentityResponse") + + expected = { + "GetCallerIdentityResponse" => a_hash_including( + "GetCallerIdentityResult" => a_hash_including( + "Arn" => "arn:aws:sts::011915987442:assumed-role/MyApp/i-0a5702a5a078e1a00", + "UserId" => anything, + "Account" => anything + ) + ) + } + + expect(subject.identity_hash(valid_response)).to include(expected) + expect(subject.identity_hash(valid_response)) + + end + + it "validates identity_hash with invalid response" do + subject = authenticator_instance + expect(subject.identity_hash(invalid_response)).to eq(false) + end + + it "matches valid login to AWS IAM role (based on AWS response)" do + subject = authenticator_instance + identity_hash = subject.identity_hash(valid_response) + + expect(subject.iam_role_matches?(valid_login, identity_hash)).to eq(true) + expect(subject.iam_role_matches?(invalid_login, identity_hash)).to eq(false) + end + + it "fails invalid login with AWS IAM role (based on AWS response)" do + subject = authenticator_instance + identity_hash = subject.identity_hash(valid_response) + + expect(subject.iam_role_matches?(invalid_login, identity_hash)).to eq(false) + end + +end