diff --git a/Gemfile b/Gemfile index 78e6dc78cb..d4b2f323e1 100644 --- a/Gemfile +++ b/Gemfile @@ -55,6 +55,7 @@ gem 'rack-rewrite' gem 'dry-struct' gem 'dry-types' +gem 'dry-validation' gem 'net-ldap' # for AWS rotator diff --git a/Gemfile.lock b/Gemfile.lock index be6c74648c..48910f6d95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -185,14 +185,26 @@ GEM multi_json domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) + dry-configurable (1.1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) dry-core (1.0.0) concurrent-ruby (~> 1.0) zeitwerk (~> 2.6) dry-inflector (1.0.0) + dry-initializer (3.1.1) dry-logic (1.5.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) + dry-schema (1.13.3) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.0, < 2) + dry-initializer (~> 3.0) + dry-logic (>= 1.4, < 2) + dry-types (>= 1.7, < 2) + zeitwerk (~> 2.6) dry-struct (1.6.0) dry-core (~> 1.0, < 2) dry-types (>= 1.7, < 2) @@ -204,6 +216,12 @@ GEM dry-inflector (~> 1.0) dry-logic (~> 1.4) zeitwerk (~> 2.6) + dry-validation (1.10.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + dry-initializer (~> 3.0) + dry-schema (>= 1.12, < 2) + zeitwerk (~> 2.6) erubi (1.12.0) event_emitter (0.2.6) eventmachine (1.2.7) @@ -542,6 +560,7 @@ DEPENDENCIES debase-ruby_core_source (~> 3.2.1) dry-struct dry-types + dry-validation event_emitter faye-websocket ffi (>= 1.9.24) diff --git a/app/controllers/authenticate_controller.rb b/app/controllers/authenticate_controller.rb index 12e8b73c2b..7391c63ba2 100644 --- a/app/controllers/authenticate_controller.rb +++ b/app/controllers/authenticate_controller.rb @@ -4,6 +4,53 @@ class AuthenticateController < ApplicationController include BasicAuthenticator include AuthorizeResource + def authenticate_via_get + handler = Authentication::Handler::AuthenticationHandler.new( + authenticator_type: params[:authenticator] + ) + + # Allow an authenticator to define the params it's expecting + response = handler.call( + parameters: params.permit(handler.params_allowed).to_h.symbolize_keys, + request_ip: request.ip + ).bind do |auth_token| + return render_authn_token(auth_token) + end + + error_response(response) + rescue => e + log_backtrace(e) + raise e + end + + def authenticate_via_post + response = Authentication::Handler::AuthenticationHandler.new( + authenticator_type: params[:authenticator] + ).call( + parameters: params, + request_body: request.body.read, + request_ip: request.ip + ).bind do |auth_token| + return render_authn_token(auth_token) + end + + error_response(response) + rescue => e + log_backtrace(e) + raise e + end + + def error_response(response) + logger.info(LogMessages::Authentication::AuthenticationError.new(response.exception)) + logger.info("Exception: #{response.exception.class.name}: #{response.exception.message}") + [*response.exception.backtrace].each { |line| logger.info(line) } + + render( + json: { error: response.exception.message }, + status: response.status + ) + end + def oidc_authenticate_code_redirect # TODO: need a mechanism for an authenticator strategy to define the required # params. This will likely need to be done via the Handler. diff --git a/app/controllers/providers_controller.rb b/app/controllers/providers_controller.rb index 7e0be9597d..2090c6766c 100644 --- a/app/controllers/providers_controller.rb +++ b/app/controllers/providers_controller.rb @@ -5,14 +5,28 @@ def index namespace = Authentication::Util::NamespaceSelector.select( authenticator_type: params[:authenticator] ) + authenticator_klass = "#{namespace}::DataObjects::Authenticator".constantize + validations = "#{namespace}::Validations::AuthenticatorConfiguration".constantize.new( + utils: ::Util::ContractUtils + ) + validator = DB::Validation.new(validations: validations) + + authenticators = DB::Repository::AuthenticatorRepository.new.find_all( + account: params[:account], + type: params[:authenticator] + ).bind do |authenticators_data| + authenticators_data.map do |authenticator_data| + # perform validation on each record + verified_data = validator.validate(data: authenticator_data) + if verified_data.success? + authenticator_klass.new(**verified_data.result) + end + end.compact + end + render( json: "#{namespace}::Views::ProviderContext".constantize.new.call( - authenticators: DB::Repository::AuthenticatorRepository.new( - data_object: "#{namespace}::DataObjects::Authenticator".constantize - ).find_all( - account: params[:account], - type: params[:authenticator] - ) + authenticators: authenticators ) ) end diff --git a/app/db/repository/authenticator_repository.rb b/app/db/repository/authenticator_repository.rb index d546c01474..ff111309b0 100644 --- a/app/db/repository/authenticator_repository.rb +++ b/app/db/repository/authenticator_repository.rb @@ -1,85 +1,118 @@ +# frozen_string_literal: true + module DB module Repository + # This class is responsible for loading the variables associated with a + # particular type of authenticator. Each authenticator requires a Data + # Object and Data Object Contract (for validation). Data Objects that + # fail validation are not returned. + # + # This class includes two public methods: + # - `find_all` - returns all available authenticators of a specified type + # from an account + # - `find` - returns a single authenticator based on the provided type, + # account, and service identifier. + # class AuthenticatorRepository def initialize( - data_object:, resource_repository: ::Resource, + available_authenticators: Authentication::InstalledAuthenticators, logger: Rails.logger ) @resource_repository = resource_repository - @data_object = data_object + @available_authenticators = available_authenticators @logger = logger + + @success = ::SuccessResponse + @failure = ::FailureResponse end def find_all(type:, account:) - @resource_repository.where( - Sequel.like( - :resource_id, - "#{account}:webservice:conjur/#{type}/%" - ) - ).all.map do |webservice| + authenticators = authenticator_webservices(type: type, account: account).map do |webservice| service_id = service_id_from_resource_id(webservice.id) - - # Querying for the authenticator webservice above includes the webservices - # for the authenticator status. The filter below removes webservices that - # don't match the authenticator policy. - next unless webservice.id.split(':').last == "conjur/#{type}/#{service_id}" - - load_authenticator(account: account, service_id: service_id, type: type) + begin + load_authenticator_variables(account: account, service_id: service_id, type: type) + rescue => e + @logger.info("failed to load #{type} authenticator '#{service_id}' do to validation failure: #{e.message}") + nil + end end.compact + @success.new(authenticators) end - def find(type:, account:, service_id:) - webservice = @resource_repository.where( + def find(type:, account:, service_id: nil, &block) + identifier = [type, service_id].compact.join('/') + + webservice = @resource_repository.where( Sequel.like( :resource_id, - "#{account}:webservice:conjur/#{type}/#{service_id}" + "#{account}:webservice:conjur/#{identifier}" ) ).first - return unless webservice - - load_authenticator(account: account, service_id: service_id, type: type) - end + unless webservice + return @failure.new( + "Failed to find a webservice: '#{account}:webservice:conjur/#{identifier}'", + exception: Errors::Authentication::Security::WebserviceNotFound.new(identifier, account) + ) + end - def exists?(type:, account:, service_id:) - @resource_repository.with_pk("#{account}:webservice:conjur/#{type}/#{service_id}") != nil + begin + @success.new( + load_authenticator_variables( + account: account, + service_id: service_id, + type: type + ) + ) + rescue => e + @failure.new( + e.message, + exception: e, + level: :debug + ) + end end private + def authenticator_webservices(type:, account:) + @resource_repository.where( + Sequel.like( + :resource_id, + "#{account}:webservice:conjur/#{type}/%" + ) + ).all.select do |webservice| + # Querying for the authenticator webservice above includes the webservices + # for the authenticator status. The filter below removes webservices that + # don't match the authenticator policy. + webservice.id.split(':').last.match?(%r{^conjur/#{type}/[\w\-_]+$}) + end + end + def service_id_from_resource_id(id) full_id = id.split(':').last full_id.split('/')[2] end - def load_authenticator(type:, account:, service_id:) + def load_authenticator_variables(type:, account:, service_id:) + identifier = [type, service_id].compact.join('/') variables = @resource_repository.where( Sequel.like( :resource_id, - "#{account}:variable:conjur/#{type}/#{service_id}/%" + "#{account}:variable:conjur/#{identifier}/%" ) ).eager(:secrets).all - - args_list = {}.tap do |args| + {}.tap do |args| args[:account] = account args[:service_id] = service_id variables.each do |variable| - next unless variable.secret - - args[variable.resource_id.split('/')[-1].underscore.to_sym] = variable.secret.value + # If variable exists but does not have a secret, set the value to an empty string. + # This is used downstream for validating if a variable has been set or not, and thus, + # what error to raise. + value = variable.secret ? variable.secret.value : '' + args[variable.resource_id.split('/')[-1].underscore.to_sym] = value end end - - begin - allowed_args = %i[account service_id] + - @data_object.const_get(:REQUIRED_VARIABLES) + - @data_object.const_get(:OPTIONAL_VARIABLES) - args_list = args_list.select { |key, value| allowed_args.include?(key) && value.present? } - @data_object.new(**args_list) - rescue ArgumentError => e - @logger.debug("DB::Repository::AuthenticatorRepository.load_authenticator - exception: #{e}") - nil - end end end end diff --git a/app/db/repository/authenticator_role_repository.rb b/app/db/repository/authenticator_role_repository.rb new file mode 100644 index 0000000000..06414bc2c4 --- /dev/null +++ b/app/db/repository/authenticator_role_repository.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module DB + module Repository + # This class is responsible for loading the variables associated with a + # particular type of authenticator. Each authenticator requires a Data + # Object and Data Object Contract (for validation). Data Objects that + # fail validation are not returned. + # + # This class includes one public methods: + # - `find` - returns a single authenticator based on the provided type, + # account, and service identifier. + # + class AuthenticatorRoleRepository + def initialize(authenticator:, role_contract: nil, role: Role, logger: Rails.logger) + @authenticator = authenticator + @role = role + @logger = logger + @role_contract = role_contract + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def find(role_identifier:) + role = @role[role_identifier.identifier] + unless role.present? + return @failure.new( + "Failed to find role for: '#{role_identifier.identifier}'", + exception: Errors::Authentication::Security::RoleNotFound.new(role_identifier.role_for_error), + status: :bad_request + ) + end + + return @success.new(role) unless role.resource? + + relevant_annotations( + annotations: {}.tap { |h| role.resource.annotations.each {|a| h[a.name] = a.value }} + ).bind do |relevant_annotations| + validate_role_annotations_against_contract( + annotations: relevant_annotations + ).bind do + annotations_match?( + role_annotations: relevant_annotations, + target_annotations: role_identifier.annotations + ).bind do + @success.new(role) + end + end + end + end + + private + + def validate_role_annotations_against_contract(annotations:) + # If authenticator requires annotations, verify some are present + if @authenticator.annotations_required && annotations.empty? + return @failure.new( + 'This authenticator requires a role to include authenticator annotations', + exception: Errors::Authentication::Constraints::RoleMissingAnyRestrictions, + status: :unauthorized + ) + end + + # Only run contract validations if they are present + # This isn't functional, but how best to return something of use...? + return @success.new(annotations) if @role_contract.nil? + + annotations.each do |annotation, value| + annotation_valid = @role_contract.new(authenticator: @authenticator, utils: ::Util::ContractUtils).call( + annotation: annotation, + annotation_value: value, + annotations: annotations + ) + next if annotation_valid.success? + + return @failure.new( + annotation_valid.errors.first, + exception: annotation_valid.errors.first.meta[:exception], + status: :unauthorized + ) + end + @success.new(annotations) + end + + + # Need to account for the following two options: + # Annotations relevant to specific authenticator + # - !host + # id: myapp + # annotations: + # authn-jwt/raw/ref: valid + + # Annotations relevant to type of authenticator + # - !host + # id: myapp + # annotations: + # authn-jwt/project_id: myproject + # authn-jwt/aud: myaud + + def relevant_annotations(annotations:) + # Verify that at least one service specific auth token is present + if annotations.keys.any? { |k, _| k.include?(@authenticator.type.to_s) } && + !annotations.keys.any? { |k, _| k.include?("#{@authenticator.type}/#{@authenticator.service_id}") } + return @failure.new( + 'Role mush include some restrications', + exception: Errors::Authentication::Constraints::RoleMissingAnyRestrictions, + status: :unauthorized + ) + end + + generic = annotations + .select{|k, _| k.count('/') == 1 } + .select{|k, _| k.match?(%r{^authn-jwt/})} + .reject{|k, _| k.match?(%r{^authn-jwt/#{@authenticator.service_id}})} + .transform_keys{|k| k.split('/').last} + + specific = annotations + .select{|k, _| k.count('/') > 1 } + .select{|k, _| k.match?(%r{^authn-jwt/#{@authenticator.service_id}/})} + .transform_keys{|k| k.split('/').last} + + @success.new(generic.merge(specific)) + end + + def annotations_match?(role_annotations:, target_annotations:) + # If there are no annotations to match, just return + return @success.new(role_annotations) if target_annotations.empty? + + role_annotations.each do |annotation, value| + next unless annotation.present? + + @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatingResourceRestrictionOnRequest.new(annotation)) + if target_annotations.key?(annotation) && target_annotations[annotation] == value + @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictionsValues.new(annotation)) + next + end + + unless target_annotations.key?(annotation) + return @failure.new( + "Role annotation: '#{annotation}' is not present in the authorization payload", + exception: Errors::Authentication::AuthnJwt::JwtTokenClaimIsMissing.new(annotation), + status: :unauthorized + ) + end + + return @failure.new( + "Role annotation: '#{annotation}' is invalid", + Errors::Authentication::ResourceRestrictions::InvalidResourceRestrictions.new(annotation), + status: :unauthorized + ) + end + + @logger.debug(LogMessages::Authentication::ResourceRestrictions::ValidatedResourceRestrictions.new) + @logger.debug(LogMessages::Authentication::AuthnJwt::ValidateRestrictionsPassed.new) + + @success.new(role_annotations) + end + end + end +end diff --git a/app/db/validation.rb b/app/db/validation.rb new file mode 100644 index 0000000000..13794ac83e --- /dev/null +++ b/app/db/validation.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module DB + # This class provides a generic mechanism for running Dry-RB contracts + # against provided data. + class Validation + def initialize(validations:, logger: Rails.logger) + @validations = validations + @logger = logger + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def validate(data:) + return @success.new(data) if @validations.blank? + + result = @validations.call(**data) + if result.success? + @success.new(result.to_h) + else + errors = result.errors + # Print all errors + @logger.info(errors.to_h.inspect) + + # If contract fails, return the first defined exception... + error = errors.first + @failure.new(error, exception: error.meta[:exception]) + end + end + end +end diff --git a/app/domain/authentication/authn_api_key/v2/data_objects/authenticator.rb b/app/domain/authentication/authn_api_key/v2/data_objects/authenticator.rb new file mode 100644 index 0000000000..481d6ffa26 --- /dev/null +++ b/app/domain/authentication/authn_api_key/v2/data_objects/authenticator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Authentication + module AuthnApiKey + module V2 + module DataObjects + + # This DataObject encapsulates the data required for an Authn-IAM + # authenticator. + # + class Authenticator < Authentication::Base::DataObject + + REQUIRES_ROLE_ANNOTIONS = false + + attr_reader(:account) + + # Authn API Key has no variables. + # Service ID is ignored as + # + # rubocop:disable Lint/MissingSuper + def initialize(account:) + @account = account + end + # rubocop:enable Lint/MissingSuper + + # Override type as this class's name does not match the expected + # value of 'authn'. This is because API key is the default mechanism + # for logging into Conjur. + def type + 'authn' + end + end + end + end + end +end diff --git a/app/domain/authentication/authn_api_key/v2/strategy.rb b/app/domain/authentication/authn_api_key/v2/strategy.rb new file mode 100644 index 0000000000..b1cf7a75ce --- /dev/null +++ b/app/domain/authentication/authn_api_key/v2/strategy.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Conjur API authenticator +module Authentication + module AuthnApiKey + module V2 + class Strategy + + # This authenticator is a bit different because it validates based on the + # information stored in the Conjur database. As such, Role and Credential + # are made available. Longer term, they should probably become part of this + # authenticator. + def initialize(authenticator:, logger: Rails.logger, credentials: ::Credentials, role: ::Role) + @authenticator = authenticator + @logger = logger + @credentials = credentials + @role = role + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + # Parameter `id` is guaranteed to be present based on the + # upstream routes file. + def callback(request_body:, parameters:) + role_id = parameters['id'] + api_key = request_body + + full_role_id = if role_id.match?(/^host\/./) + id = role_id.split('/')[1..-1].join + "#{@authenticator.account}:host:#{id}" + else + "#{@authenticator.account}:user:#{role_id}" + end + + role_identifier = Authentication::Base::RoleIdentifier.new( + identifier: full_role_id + ) + + if @role[full_role_id].nil? + return @failure.new( + role_identifier, + exception: Errors::Authentication::Security::RoleNotFound.new(role_id) + ) + end + + role_credentials = @credentials[full_role_id] + if role_credentials.nil? + return @failure.new( + role_identifier, + exception: Errors::Authentication::RoleHasNoCredentials.new(role_id) + ) + end + + return @success.new(role_identifier) if role_credentials.valid_api_key?(api_key) + + @failure.new( + role_identifier, + exception: Errors::Conjur::ApiKeyNotFound.new(role_id) + ) + end + + # Called by status handler. This handles checking as much of the strategy + # integrity as possible without performing an actual authentication. + def verify_status + true + end + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/authenticator.rb b/app/domain/authentication/authn_oidc/authenticator.rb index 17cfcccc06..45201b2d45 100644 --- a/app/domain/authentication/authn_oidc/authenticator.rb +++ b/app/domain/authentication/authn_oidc/authenticator.rb @@ -28,29 +28,28 @@ def status(authenticator_status_input:) # term, we need to port the V1 functionality to V2. Once that # is done, the following check can be removed. - # Attempt to load the V2 version of the OIDC Authenticator - authenticator = DB::Repository::AuthenticatorRepository.new( - data_object: Authentication::AuthnOidc::V2::DataObjects::Authenticator - ).find( + DB::Repository::AuthenticatorRepository.new.find( type: authenticator_status_input.authenticator_name, account: authenticator_status_input.account, service_id: authenticator_status_input.service_id - ) - # If successful, validate the new set of required variables - if authenticator.present? - Authentication::AuthnOidc::ValidateStatus.new( - required_variable_names: %w[provider-uri client-id client-secret claim-mapping], - optional_variable_names: %w[ca-cert] - ).( - account: authenticator_status_input.account, - service_id: authenticator_status_input.service_id - ) - else - # Otherwise, perform the default check - Authentication::AuthnOidc::ValidateStatus.new.( - account: authenticator_status_input.account, - service_id: authenticator_status_input.service_id - ) + ).bind do |authenticator_data| + # check if this authenticator appears to be a Code Redirect authenticator + if authenticator_data.key?(:client_id) + Authentication::AuthnOidc::ValidateStatus.new( + required_variable_names: %w[provider-uri client-id client-secret claim-mapping], + optional_variable_names: %w[ca-cert] + ).( + account: authenticator_status_input.account, + service_id: authenticator_status_input.service_id + ) + + # Otherwise, use the old style check + else + Authentication::AuthnOidc::ValidateStatus.new.( + account: authenticator_status_input.account, + service_id: authenticator_status_input.service_id + ) + end end end end diff --git a/app/domain/authentication/authn_oidc/v2/client.rb b/app/domain/authentication/authn_oidc/v2/client.rb deleted file mode 100644 index e15ab6d655..0000000000 --- a/app/domain/authentication/authn_oidc/v2/client.rb +++ /dev/null @@ -1,292 +0,0 @@ -module Authentication - module AuthnOidc - module V2 - class Client - def initialize( - authenticator:, - client: ::OpenIDConnect::Client, - oidc_id_token: ::OpenIDConnect::ResponseObject::IdToken, - discovery_configuration: ::OpenIDConnect::Discovery::Provider::Config, - cache: Rails.cache, - logger: Rails.logger - ) - @authenticator = authenticator - @client = client - @oidc_id_token = oidc_id_token - @discovery_configuration = discovery_configuration - @cache = cache - @logger = logger - end - - # Writing certificates to the default system cert store requires - # superuser privilege. Instead, Conjur will use ${CONJUR_ROOT}/tmp/certs. - def self.default_cert_dir(dir: Dir, fileutils: FileUtils) - if @default_cert_dir.blank? - conjur_root = __dir__.slice(0..(__dir__.index('/app'))) - @default_cert_dir = File.join(conjur_root, "tmp/certs") - end - - fileutils.mkdir_p(@default_cert_dir) unless dir.exist?(@default_cert_dir.to_s) - - @default_cert_dir - end - - def oidc_client - @oidc_client ||= begin - issuer_uri = URI(@authenticator.provider_uri) - @client.new( - identifier: @authenticator.client_id, - secret: @authenticator.client_secret, - redirect_uri: @authenticator.redirect_uri, - scheme: issuer_uri.scheme, - host: issuer_uri.host, - port: issuer_uri.port, - authorization_endpoint: URI(discovery_information.authorization_endpoint).path, - token_endpoint: URI(discovery_information.token_endpoint).path, - userinfo_endpoint: URI(discovery_information.userinfo_endpoint).path, - jwks_uri: URI(discovery_information.jwks_uri).path - ) - end - end - - def callback(code:, nonce:, code_verifier: nil) - oidc_client.authorization_code = code - access_token_args = { client_auth_method: :basic } - access_token_args[:code_verifier] = code_verifier if code_verifier.present? - begin - bearer_token = oidc_client.access_token!(**access_token_args) - rescue Rack::OAuth2::Client::Error => e - # Only handle the expected errors related to access token retrieval. - case e.message - when /PKCE verification failed/, # Okta's PKCE failure msg - /challenge mismatch/ # Identity's PKCE failure msg - raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - 'PKCE verification failed' - when /The authorization code is invalid or has expired/, # Okta's reused code msg - /supplied code does not match known request/ # Identity's reused code msg - raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - 'Authorization code is invalid or has expired' - when /Code not valid/ - raise Errors::Authentication::AuthnOidc::TokenRetrievalFailed, - 'Authorization code is invalid' - end - raise e - end - id_token = bearer_token.id_token || bearer_token.access_token - - begin - attempts ||= 0 - decoded_id_token = @oidc_id_token.decode( - id_token, - discovery_information.jwks - ) - rescue StandardError => e - attempts += 1 - raise e if attempts > 1 - - # If the JWKS verification fails, blow away the existing cache and - # try again. This is intended to handle the case where the OIDC certificate - # changes, and we want to cache the new certificate without decode failing. - discovery_information(invalidate: true) - retry - end - - begin - decoded_id_token.verify!( - issuer: @authenticator.provider_uri, - client_id: @authenticator.client_id, - nonce: nonce - ) - rescue OpenIDConnect::ResponseObject::IdToken::InvalidNonce - raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, - 'Provided nonce does not match the nonce in the JWT' - rescue OpenIDConnect::ResponseObject::IdToken::ExpiredToken - raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, - 'JWT has expired' - rescue OpenIDConnect::ValidationFailed => e - raise Errors::Authentication::AuthnOidc::TokenVerificationFailed, - e.message - end - decoded_id_token - end - - # callback_with_temporary_cert wraps the callback method with commands - # to write & clean up a given certificate or cert chain in a given - # directory. By default, ${CONJUR_ROOT}/tmp/certs is used. - # - # The temporary certificate file name is "x.n", where x is the hash of - # the certificate subject name, and n is incrememnted from 0 in case of - # collision. - # - # Unlike self.discover, which wraps a single ::OpenIDConnect method, - # callback_with_temporary_cert wraps the entire callback method, which - # includes multiple calls to the OIDC provider, including at least one - # discover! call. The temporary certs will apply to all required - # operations. - def callback_with_temporary_cert( - code:, - nonce:, - code_verifier: nil, - cert_dir: Authentication::AuthnOidc::V2::Client.default_cert_dir, - cert_string: nil - ) - c = -> { callback(code: code, nonce: nonce, code_verifier: code_verifier) } - - return c.call if cert_string.blank? - - begin - certs_a = ::Conjur::CertUtils.parse_certs(cert_string) - rescue OpenSSL::X509::CertificateError => e - raise Errors::Authentication::AuthnOidc::InvalidCertificate, e.message - end - raise Errors::Authentication::AuthnOidc::InvalidCertificate, "provided string does not contain a certificate" if certs_a.empty? - - symlink_a = [] - - Dir.mktmpdir do |tmp_dir| - certs_a.each_with_index do |cert, idx| - tmp_file = File.join(tmp_dir, "conjur-oidc-client.#{idx}.pem") - File.write(tmp_file, cert.to_s) - - n = 0 - hash = cert.subject.hash.to_s(16) - while true - symlink = File.join(cert_dir, "#{hash}.#{n}") - break unless File.exist?(symlink) - - n += 1 - end - - File.symlink(tmp_file, symlink) - symlink_a << symlink - end - - if OpenIDConnect.http_config.nil? || OpenIDConnect.http_client.ssl.ca_path != cert_dir - config_proc = proc do |config| - config.ssl.ca_path = cert_dir - config.ssl.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - - # OpenIDConnect gem only accepts a single Faraday configuration - # through calls to its .http_config method, and future calls to - # the #http_config method return the first config instance. - # - # On the first call to OpenIDConnect.http_config, it will pass the - # new Faraday configuration to its dependency gems that also have - # nil configs. We can't be certain that each gem is configured - # with the same Faraday config and need them synchronized, so we - # inject them manually. - OpenIDConnect.class_variable_set(:@@http_config, config_proc) - WebFinger.instance_variable_set(:@http_config, config_proc) - SWD.class_variable_set(:@@http_config, config_proc) - Rack::OAuth2.class_variable_set(:@@http_config, config_proc) - end - - c.call - ensure - symlink_a.each{ |s| File.unlink(s) if s.present? && File.symlink?(s) } - end - end - - def discovery_information(invalidate: false) - @cache.fetch( - "#{@authenticator.account}/#{@authenticator.service_id}/#{URI::Parser.new.escape(@authenticator.provider_uri)}", - force: invalidate, - skip_nil: true - ) do - self.class.discover( - provider_uri: @authenticator.provider_uri, - discovery_configuration: @discovery_configuration, - cert_string: @authenticator.ca_cert - ) - rescue Errno::ETIMEDOUT => e - raise Errors::Authentication::OAuth::ProviderDiscoveryTimeout.new(@authenticator.provider_uri, e.message) - rescue => e - raise Errors::Authentication::OAuth::ProviderDiscoveryFailed.new(@authenticator.provider_uri, e.message) - end - end - - # discover wraps ::OpenIDConnect::Discovery::Provider::Config.discover! - # with commands to write & clean up a given certificate or cert chain in - # a given directory. By default, ${CONJUR_ROOT}/tmp/certs is used. - # - # The temporary certificate file name is "x.n", where x is the hash of - # the certificate subject name, and n is incremented from 0 in case of - # collision. - # - # discover is a class method, because there are a few contexts outside - # this class where the underlying discover! method is used. Call it by - # running Authentication::AuthnOIDC::V2::Client.discover(...). - def self.discover( - provider_uri:, - discovery_configuration: ::OpenIDConnect::Discovery::Provider::Config, - cert_dir: default_cert_dir, - cert_string: nil, - jwks: false - ) - case jwks - when false - d = -> { discovery_configuration.discover!(provider_uri) } - when true - d = -> { discovery_configuration.discover!(provider_uri).jwks } - end - - return d.call if cert_string.blank? - - begin - certs_a = ::Conjur::CertUtils.parse_certs(cert_string) - rescue OpenSSL::X509::CertificateError => e - raise Errors::Authentication::AuthnOidc::InvalidCertificate, e.message - end - raise Errors::Authentication::AuthnOidc::InvalidCertificate, "provided string does not contain a certificate" if certs_a.empty? - - symlink_a = [] - - Dir.mktmpdir do |tmp_dir| - certs_a.each_with_index do |cert, idx| - tmp_file = File.join(tmp_dir, "conjur-oidc-client.#{idx}.pem") - File.write(tmp_file, cert.to_s) - - n = 0 - hash = cert.subject.hash.to_s(16) - while true - symlink = File.join(cert_dir, "#{hash}.#{n}") - break unless File.exist?(symlink) - - n += 1 - end - - File.symlink(tmp_file, symlink) - symlink_a << symlink - end - - if OpenIDConnect.http_config.nil? || OpenIDConnect.http_client.ssl.ca_path != cert_dir - config_proc = proc do |config| - config.ssl.ca_path = cert_dir - config.ssl.verify_mode = OpenSSL::SSL::VERIFY_PEER - end - - # OpenIDConnect gem only accepts a single Faraday configuration - # through calls to its .http_config method, and future calls to - # the #http_config method return the first config instance. - # - # On the first call to OpenIDConnect.http_config, it will pass the - # new Faraday configuration to its dependency gems that also have - # nil configs. We can't be certain that each gem is configured - # with the same Faraday config and need them synchronized, so we - # inject them manually. - OpenIDConnect.class_variable_set(:@@http_config, config_proc) - WebFinger.instance_variable_set(:@http_config, config_proc) - SWD.class_variable_set(:@@http_config, config_proc) - Rack::OAuth2.class_variable_set(:@@http_config, config_proc) - end - - d.call - ensure - symlink_a.each{ |s| File.unlink(s) if s.present? && File.symlink?(s) } - end - end - end - end - end -end diff --git a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb index b542b1fb7d..c29cf6bd2d 100644 --- a/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb +++ b/app/domain/authentication/authn_oidc/v2/data_objects/authenticator.rb @@ -2,10 +2,9 @@ module Authentication module AuthnOidc module V2 module DataObjects - class Authenticator + class Authenticator < Authentication::Base::DataObject - REQUIRED_VARIABLES = %i[provider_uri client_id client_secret claim_mapping].freeze - OPTIONAL_VARIABLES = %i[redirect_uri response_type provider_scope name token_ttl ca_cert].freeze + REQUIRES_ROLE_ANNOTIONS = false attr_reader( :provider_uri, @@ -19,6 +18,8 @@ class Authenticator :ca_cert ) + # rubocop:disable Metrics/ParameterLists + # rubocop:disable Lint/MissingSuper def initialize( provider_uri:, client_id:, @@ -30,7 +31,7 @@ def initialize( name: nil, response_type: 'code', provider_scope: nil, - token_ttl: 'PT60M', + token_ttl: '', ca_cert: nil ) @account = account @@ -43,9 +44,13 @@ def initialize( @name = name @provider_scope = provider_scope @redirect_uri = redirect_uri - @token_ttl = token_ttl @ca_cert = ca_cert + + # Set TTL to 60 minutes by default + @token_ttl = token_ttl.present? ? token_ttl : 'PT60M' end + # rubocop:enable Metrics/ParameterLists + # rubocop:enable Lint/MissingSuper def scope (%w[openid email profile] + [*@provider_scope.to_s.split(' ')]).uniq.join(' ') @@ -54,17 +59,6 @@ def scope def name @name || @service_id.titleize end - - def resource_id - "#{account}:webservice:conjur/authn-oidc/#{service_id}" - end - - # Returns the validity duration, in seconds, of an instance's access tokens. - def token_ttl - ActiveSupport::Duration.parse(@token_ttl) - rescue ActiveSupport::Duration::ISO8601Parser::ParsingError - raise Errors::Authentication::DataObjects::InvalidTokenTTL.new(resource_id, @token_ttl) - end end end end diff --git a/app/domain/authentication/authn_oidc/v2/network_transporter.rb b/app/domain/authentication/authn_oidc/v2/network_transporter.rb new file mode 100644 index 0000000000..2568624846 --- /dev/null +++ b/app/domain/authentication/authn_oidc/v2/network_transporter.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Authentication + module AuthnOidc + module V2 + class NetworkTransporter + def initialize(hostname:, ca_certificate: nil, temp_directory: './tmp/certificates', http: Net::HTTP) + # Set as empty so we can check it later when making network calls + @ca_certificate = nil + if ca_certificate.present? + @temp_directory = temp_directory + create_directory_if_missing(temp_directory) + @ca_certificate = ca_certificate + end + + # Set the default hostname + @uri = URI(hostname) + # Stripping path if present + @uri.path = '' + + @http = http + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def get(path) + http_client.start do |http| + request = Net::HTTP::Get.new(@uri.to_s + URI(path).path) + response = http.request(request) + if response.code == '200' + @success.new(JSON.parse(response.body)) + else + @failure.new(response) + end + end + rescue => e + @failure.new(e.message, exception: e, status: :bad_request) + end + + def post(path:, body: '', basic_auth: []) + http_client.start do |http| + request = Net::HTTP::Post.new(@uri + URI(path).path) + request.body = body + request.basic_auth(*basic_auth) unless basic_auth.empty? + response = http.request(request) + begin + if response.code == '200' + @success.new(JSON.parse(response.body)) + else + @failure.new(response) + end + rescue => e + @failure.new(e.message, exception: e, status: :bad_request) + end + end + end + + private + + def http_client + @http_client ||= begin + http = @http.new(@uri.host, @uri.port) + return http unless @uri.instance_of?(URI::HTTPS) + + # Enable SSL support + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + + store = OpenSSL::X509::Store.new + # If CA Certificate is available, we write it to a tempfile for + # import. This allows us to handle certificate chains. + if @ca_certificate.present? + with_ca_certificate(@ca_certificate) do |file| + store.add_file(file.path) + end + else + # Auto-include system CAs unless a CA has been defined + store.set_default_paths + end + http.cert_store = store + + # return the http object + http + end + end + + def with_ca_certificate(certificate_content, &block) + Tempfile.create('ca', @temp_directory) do |ca_certificate| + ca_certificate.write(certificate_content) + ca_certificate.close + block.call(ca_certificate) + end + end + + def create_directory_if_missing(path) + Dir.mkdir(path) unless Dir.exist?(path) + end + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/v2/oidc_client.rb b/app/domain/authentication/authn_oidc/v2/oidc_client.rb new file mode 100644 index 0000000000..5f644fd614 --- /dev/null +++ b/app/domain/authentication/authn_oidc/v2/oidc_client.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Authentication + module AuthnOidc + module V2 + class OidcClient + def initialize( + authenticator:, + client: NetworkTransporter, + cache: Rails.cache, + logger: Rails.logger + ) + @authenticator = authenticator + @cache = cache + @logger = logger + + @client = client.new( + hostname: @authenticator.provider_uri, + ca_certificate: @authenticator.ca_cert + ) + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def exchange(code:, nonce:, code_verifier: nil) + exchange_token(code: code, nonce: nonce, code_verifier: code_verifier).bind do |bearer_token| + decode_token(bearer_token).bind do |decoded_token| + verify_token(token: decoded_token, nonce: nonce).bind do |verified_token| + @success.new(verified_token) + end + end + end + end + + # 'jwks_uri' - GET + # 'token_endpoint' - POST + # Public method so strategy can call it to verify configuration + def oidc_configuration + @oidc_configuration ||= begin + response = @client.get("#{@authenticator.provider_uri}/.well-known/openid-configuration").bind do |response| + return @success.new(response) + end + @failure.new( + "Authn-OIDC '#{@authenticator.service_id}' provider-uri: '#{@authenticator.provider_uri}' is unreachable", + exception: Errors::Authentication::OAuth::ProviderDiscoveryFailed.new( + @authenticator.provider_uri, + response.message + ), + status: :unauthorized + ) + end + end + + private + + def exchange_token(code:, nonce:, code_verifier:) + args = { + grant_type: 'authorization_code', + scope: true, + code: code, + nonce: nonce + } + args[:code_verifier] = code_verifier if code_verifier.present? + args[:redirect_uri] = ERB::Util.url_encode(@authenticator.redirect_uri) if @authenticator.redirect_uri.present? + + oidc_configuration.bind do |config| + response = @client.post( + path: config['token_endpoint'], + body: args.map { |k, v| "#{k}=#{v}" }.join('&'), + basic_auth: [@authenticator.client_id, @authenticator.client_secret] + ).bind do |token| + bearer_token = token['id_token'] || token['access_token'] + return @success.new(bearer_token) if bearer_token.present? + + @failure.new( + 'Bearer Token is empty', + exception: Errors::Authentication::AuthnOidc::TokenRetrievalFailed.new(response.message), + status: :bad_request + ) + end + @failure.new( + response.message, + exception: Errors::Authentication::AuthnOidc::TokenRetrievalFailed.new(response.message), + status: :bad_request + ) + end + end + + def decode_token(encoded_token) + fetch_jwks.bind do |jwks| + return @success.new( + JWT.decode( + encoded_token, + nil, + true, # Verify the signature of this token + algorithms: %w[RS256 RS384 RS512], + iss: @authenticator.provider_uri, + verify_iss: true, + aud: @authenticator.client_id, + verify_aud: true, + jwks: jwks + ).first + ) + end + rescue => e + @failure.new(e.message, exception: e, status: :bad_request) + end + + def verify_token(token:, nonce:) + unless token['nonce'] == nonce + return @failure.new('nonce does not match') + end + + @success.new(token) + end + + def fetch_jwks + oidc_configuration.bind do |configuration| + @client.get( + configuration['jwks_uri'] + ).bind do |response| + @success.new(response) + end + rescue => e + @failure.new(e.message, exception: e, status: :bad_request) + end + end + end + end + end +end diff --git a/app/domain/authentication/authn_oidc/v2/strategy.rb b/app/domain/authentication/authn_oidc/v2/strategy.rb index 0b70d72af6..c6cba84cb8 100644 --- a/app/domain/authentication/authn_oidc/v2/strategy.rb +++ b/app/domain/authentication/authn_oidc/v2/strategy.rb @@ -1,44 +1,95 @@ +# frozen_string_literal: true + module Authentication module AuthnOidc module V2 class Strategy + REQUIRED_PARAMS = %i[code nonce].freeze + ALLOWED_PARAMS = (REQUIRED_PARAMS + %i[code_verifier]).freeze + def initialize( authenticator:, - client: Authentication::AuthnOidc::V2::Client, + oidc_client: OidcClient, + # client: Authentication::AuthnOidc::V2::Client, logger: Rails.logger ) @authenticator = authenticator - @client = client.new(authenticator: authenticator) + @oidc_client = oidc_client.new(authenticator: authenticator) @logger = logger + + @success = ::SuccessResponse + @failure = ::FailureResponse end - def callback(args) - # NOTE: `code_verifier` param is optional - %i[code nonce].each do |param| - unless args[param].present? - raise Errors::Authentication::RequestBody::MissingRequestParam, param.to_s + # rubocop:disable Lint/UnusedMethodArgument + def callback(parameters:, request_body: nil) + validate_parameters(parameters).bind do |params| + validate_jwt_token(params).bind do |jwt_token| + resolve_identity(jwt: jwt_token).bind do |identity| + @success.new( + Authentication::Base::RoleIdentifier.new( + identifier: "#{@authenticator.account}:user:#{identity}" + ) + ) + end end end - identity = resolve_identity( - jwt: @client.callback_with_temporary_cert( - code: args[:code], - nonce: args[:nonce], - code_verifier: args[:code_verifier], - cert_string: @authenticator.ca_cert - ) - ) - unless identity.present? - raise Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( - @authenticator.claim_mapping, - "claim-mapping" - ) + end + # rubocop:enable Lint/UnusedMethodArgument + + # Called by status handler. This handles checking as much of the strategy + # integrity as possible without performing an actual authentication. + def verify_status + # @client.discover + @oidc_client.configuration + end + + private + + def validate_parameters(parameters) + REQUIRED_PARAMS.each do |param| + unless parameters[param].present? + return @failure.new( + "Missing parameter: '#{param}'", + exception: Errors::Authentication::RequestBody::MissingRequestParam.new( + param.to_s + ), + status: :bad_request + ) + end end - identity + @success.new(parameters.select {|item| ALLOWED_PARAMS.include?(item) } ) + end + + def validate_jwt_token(parameters) + @oidc_client.exchange( + code: parameters[:code], + nonce: parameters[:nonce], + code_verifier: parameters[:code_verifier] + ) + # @client.callback_with_temporary_cert( + # code: parameters[:code], + # nonce: parameters[:nonce], + # code_verifier: parameters[:code_verifier], + # cert_string: @authenticator.ca_cert + # ) end def resolve_identity(jwt:) - jwt.raw_attributes.with_indifferent_access[@authenticator.claim_mapping] + # binding.pry + # identity = jwt.raw_attributes.with_indifferent_access[@authenticator.claim_mapping] + identity = jwt[@authenticator.claim_mapping] + return @success.new(identity) if identity.present? + + @failure.new( + 'Claim was not found', + exception: Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty.new( + @authenticator.claim_mapping, + 'claim-mapping' + ), + status: :unauthorized + ) end end end diff --git a/app/domain/authentication/authn_oidc/v2/validations/authenticator_configuration.rb b/app/domain/authentication/authn_oidc/v2/validations/authenticator_configuration.rb new file mode 100644 index 0000000000..e68b73157d --- /dev/null +++ b/app/domain/authentication/authn_oidc/v2/validations/authenticator_configuration.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Authentication + module AuthnOidc + module V2 + module Validations + + # This class validates the configuration of the OIDC Authenticator as defined + # by the authenticator's variables. + # + # All required and optional variables should be defined here, as well as any + # the validation of those input's values. + # + # This validations are executed against the data loaded from Conjur variables when + # the authenicator is loaded via the AuthenticatorRepository. + class AuthenticatorConfiguration < Dry::Validation::Contract + option :utils + + schema do + required(:account).value(:string) + required(:service_id).value(:string) + required(:provider_uri).value(:string) + required(:client_id).value(:string) + required(:client_secret).value(:string) + required(:claim_mapping).value(:string) + + optional(:redirect_uri).value(:string) + optional(:response_type).value(:string) + optional(:provider_scope).value(:string) + optional(:name).value(:string) + optional(:token_ttl).value(:string) + optional(:provider_scope).value(:string) + optional(:ca_cert).value(:string) + end + + # Verify that `provider_uri` has a secret value set if variable is present + rule(:provider_uri, :service_id, :account) do + if values[:provider_uri].empty? + utils.failed_response( + key: key, + error: Errors::Conjur::RequiredSecretMissing.new( + "#{values[:account]}:variable:conjur/authn-oidc/#{values[:service_id]}/provider-uri" + ) + ) + end + end + end + end + end + end +end diff --git a/app/domain/authentication/base/data_object.rb b/app/domain/authentication/base/data_object.rb new file mode 100644 index 0000000000..a241393b8d --- /dev/null +++ b/app/domain/authentication/base/data_object.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Authentication + module Base + class DataObject + def type + @type ||= self.class.to_s.split('::')[1].underscore.dasherize + end + + def identifier + [type, @service_id].compact.join('/') + end + + def resource_id + [ + @account, + 'webservice', + [ + 'conjur', + type, + @service_id + ].compact.join('/') + ].join(':') + end + + def token_ttl + ttl = @token_ttl.present? ? @token_ttl : 'PT8M' + ActiveSupport::Duration.parse(ttl.to_s) + rescue ActiveSupport::Duration::ISO8601Parser::ParsingError + raise Errors::Authentication::DataObjects::InvalidTokenTTL.new(resource_id, @token_ttl) + end + + def annotations_required + # binding.pry + requires_role_annotations = self.class::REQUIRES_ROLE_ANNOTIONS + return requires_role_annotations unless requires_role_annotations.nil? + + raise "class constant 'REQUIRES_ROLED_ANNOTIONS' must be defined" + end + + end + end +end diff --git a/app/domain/authentication/base/role_identifier.rb b/app/domain/authentication/base/role_identifier.rb new file mode 100644 index 0000000000..867bffb00c --- /dev/null +++ b/app/domain/authentication/base/role_identifier.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Authentication + module Base + class RoleIdentifier + attr_reader :identifier, :annotations + + def initialize(identifier:, annotations: {}) + @identifier = identifier + @annotations = annotations + end + + def type + @identifier.split(':')[1] + end + + def account + @identifier.split(':')[0] + end + + # Role identifier within the account and type context: + # :: + def id + @identifier.split(':')[2] + end + + # Essentially just an alias, but doing it this way to + # avoid duplicate alias Rubocop warning. + # def conjur_role + # @role_identifier + # end + + def role_for_error + type == 'host' ? "host/#{id}" : id + end + end + end +end diff --git a/app/domain/authentication/handler/authentication_handler.rb b/app/domain/authentication/handler/authentication_handler.rb index 8e2a36d22c..1a58049381 100644 --- a/app/domain/authentication/handler/authentication_handler.rb +++ b/app/domain/authentication/handler/authentication_handler.rb @@ -5,142 +5,228 @@ module Handler class AuthenticationHandler def initialize( authenticator_type:, - role: ::Role, - resource: ::Resource, - authn_repo: DB::Repository::AuthenticatorRepository, + authenticator_repository: DB::Repository::AuthenticatorRepository.new, namespace_selector: Authentication::Util::NamespaceSelector, logger: Rails.logger, - authentication_error: LogMessages::Authentication::AuthenticationError + audit_logger: ::Audit.logger, + authentication_error: LogMessages::Authentication::AuthenticationError, + available_authenticators: Authentication::InstalledAuthenticators, + role_repository: DB::Repository::AuthenticatorRoleRepository, + authorization: RBAC::Permission.new, + token_factory: TokenFactory.new, + validator: DB::Validation ) - @role = role - @resource = resource @authenticator_type = authenticator_type @logger = logger + @audit_logger = audit_logger @authentication_error = authentication_error + @available_authenticators = available_authenticators + @role_repository = role_repository + @authorization = authorization + @token_factory = token_factory + @authenticator_repository = authenticator_repository + @validator = validator # Dynamically load authenticator specific classes namespace = namespace_selector.select( authenticator_type: authenticator_type ) - @identity_resolver = "#{namespace}::ResolveIdentity".constantize @strategy = "#{namespace}::Strategy".constantize - @authn_repo = authn_repo.new( - data_object: "#{namespace}::DataObjects::Authenticator".constantize - ) - end - - def call(parameters:, request_ip:) - # Load Authenticator policy and values (validates data stored as variables) - authenticator = @authn_repo.find( - type: @authenticator_type, - account: parameters[:account], - service_id: parameters[:service_id] - ) - - if authenticator.nil? - raise( - Errors::Conjur::RequestedResourceNotFound, - "Unable to find authenticator with account: #{parameters[:account]} and service-id: #{parameters[:service_id]}" + @authenticator_klass = "#{namespace}::DataObjects::Authenticator".constantize + @authenticator_validation = Util::KlassLoader.set_if_present do + "#{namespace}::Validations::AuthenticatorConfiguration".constantize.new( + utils: ::Util::ContractUtils ) end + # @role_validation = Util::KlassLoader.set_if_present { "#{@namespace}::Validations::RoleMappingValidations".constantize.new(utils: ::Util::ContractUtils) } - role = @identity_resolver.new.call( - identity: @strategy.new( - authenticator: authenticator - ).callback(parameters), - account: parameters[:account], - allowed_roles: @role.that_can( - :authenticate, - @resource[authenticator.resource_id] - ).all - ) - - # TODO: Add an error message - raise 'failed to authenticate' unless role + @success = ::SuccessResponse + @failure = ::FailureResponse + end - unless role.valid_origin?(request_ip) - raise Errors::Authentication::InvalidOrigin + def call(request_ip:, parameters:, request_body: nil, action: nil) + service_id = parameters[:service_id] + account = parameters[:account] + role_for_audit = nil + identified_authenticator = nil + + response = retrieve_authenticator(service_id: service_id, account: account).bind do |authenticator| + identified_authenticator = authenticator + identify_role(authenticator: authenticator, parameters: parameters, request_body: request_body).bind do |role_identifier| + retrieve_role(authenticator: authenticator, role_identifier: role_identifier).bind do |role| + role_for_audit = role + check_usage_permitted(role: role, authenticator: authenticator).bind do |check_permitted_role| + check_origin_permitted(role: check_permitted_role, request_ip: request_ip).bind do |check_allowed_role| + log_audit_success(authenticator, check_allowed_role.role_id, request_ip, authenticator.type) + issue_authentication_token(account: account, login: check_allowed_role.login, ttl: authenticator.token_ttl).bind do |token| + log_audit_success(authenticator, role.role_id, request_ip, authenticator.type) + return @success.new(token) + end + end + end + end + end end - log_audit_success(authenticator, role, request_ip, @authenticator_type) + if role_for_audit.present? + role_identifier = role_for_audit.is_a?(String) ? role_for_audit : role_for_audit&.role_id + log_audit_failure(identified_authenticator, role_identifier, request_ip, authenticator&.type, e) + end - TokenFactory.new.signed_token( - account: parameters[:account], - username: role.role_id.split(':').last, - user_ttl: authenticator.token_ttl - ) + response rescue => e - log_audit_failure(parameters[:account], parameters[:service_id], request_ip, @authenticator_type, e) - handle_error(e) + @failure.new(e.message, exception: e) end - def handle_error(err) - @logger.info("#{err.class.name}: #{err.message}") + def params_allowed + allowed = %i[authenticator service_id account] + allowed += @strategy::ALLOWED_PARAMS if @strategy.const_defined?('ALLOWED_PARAMS') + allowed + end - case err - when Errors::Authentication::Security::RoleNotAuthorizedOnResource - raise ApplicationController::Forbidden + private - when Errors::Authentication::RequestBody::MissingRequestParam, - Errors::Authentication::AuthnOidc::TokenVerificationFailed, - Errors::Authentication::AuthnOidc::TokenRetrievalFailed - raise ApplicationController::BadRequest + def issue_authentication_token(account:, login:, ttl:) + @success.new( + @token_factory.signed_token( + account: account, + username: login, + user_ttl: ttl + ) + ) + end - when Errors::Conjur::RequestedResourceNotFound - raise ApplicationController::RecordNotFound.new(err.message) + def check_origin_permitted(role:, request_ip:) + if role.valid_origin?(request_ip) + @success.new(role) + else + @failure.new( + role, + status: :forbidden, + exception: Errors::Authentication::InvalidOrigin + ) + end + end + + def check_usage_permitted(role:, authenticator:) + return @success.new(role) if @available_authenticators.native_authenticators.include?(authenticator.identifier) + + # Verify that the identified role is permitted to use this authenticator + @authorization.permitted?( + role: role, + resource_id: authenticator.resource_id, + privilege: :authenticate + ) + end - when Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty - raise ApplicationController::Unauthorized + def retrieve_role(authenticator:, role_identifier:) + @role_repository.new( + authenticator: authenticator + ).find( + role_identifier: role_identifier + ) + end - when Errors::Authentication::Jwt::TokenExpired - raise ApplicationController::Unauthorized.new(err.message, true) + def identify_role(authenticator:, parameters:, request_body:) + @strategy.new( + authenticator: authenticator + ).callback(parameters: parameters, request_body: request_body) + end - when Errors::Authentication::Security::RoleNotFound - raise ApplicationController::BadRequest + def retrieve_authenticator(service_id:, account:) + identifier = [@authenticator_type, service_id].compact.join('/') - when Errors::Authentication::Security::MultipleRoleMatchesFound - raise ApplicationController::Forbidden - # Code value mismatch - when Rack::OAuth2::Client::Error - raise ApplicationController::BadRequest + # verify authenticator is whitelisted.... + unless @available_authenticators.enabled_authenticators.include?(identifier) + return @failure.new( + "Authenticator: '#{identifier}' is no enabled.", + status: :bad_request, + exception: Errors::Authentication::Security::AuthenticatorNotWhitelisted.new(identifier) + ) + end + # If this is a native authenticator (like API Key), it won't be stored + # as webservice, so just load the authenticator. + if @available_authenticators.native_authenticators.include?(identifier) + @success.new(@authenticator_klass.new(account: account)) else - raise ApplicationController::Unauthorized + # Load Authenticator policy and variables + @authenticator_repository.find( + type: @authenticator_type, + account: account, + service_id: service_id + ).bind do |authenticator_data| + + # validate data against authenticator specific validations + @validator.new( + validations: @authenticator_validation + ).validate(data: authenticator_data).bind do |validated_authenticator_data| + + # Instantiate and return authenticator data object for future use + @success.new(@authenticator_klass.new(**validated_authenticator_data)) + rescue => e + @failure.new(e.message, exception: e) + end + end end end - def log_audit_success(authenticator, conjur_role, client_ip, type) - ::Authentication::LogAuditEvent.new.call( - authentication_params: - Authentication::AuthenticatorInput.new( - authenticator_name: "#{type}", - service_id: authenticator.service_id, - account: authenticator.account, - username: conjur_role.role_id, - client_ip: client_ip, - credentials: nil, - request: nil - ), - audit_event_class: Audit::Event::Authn::Authenticate, - error: nil + # def handle_error(err) + # # Log authentication errors (but don't raise...) + # authentication_error = LogMessages::Authentication::AuthenticationError.new(err.inspect) + # @logger.info(authentication_error) + + # @logger.info("#{err.class.name}: #{err.message}") + # err.backtrace.each {|l| @logger.info(l) } + + # case err + # when Errors::Authentication::Security::RoleNotAuthorizedOnResource + # raise ApplicationController::Forbidden + + # when Errors::Authentication::RequestBody::MissingRequestParam, + # Errors::Authentication::Security::RoleNotFound, + # Errors::Authentication::Security::AuthenticatorNotWhitelisted, + # Errors::Authentication::AuthnOidc::TokenVerificationFailed, + # Errors::Authentication::AuthnOidc::TokenRetrievalFailed, + # Rack::OAuth2::Client::Error # Code value mismatch + # raise ApplicationController::BadRequest + + # when Errors::Conjur::RequestedResourceNotFound, + # Errors::Authentication::AuthnOidc::IdTokenClaimNotFoundOrEmpty + # raise ApplicationController::Unauthorized + + # when Errors::Authentication::Jwt::TokenExpired + # raise ApplicationController::Unauthorized.new(err.message, true) + + # else + # raise ApplicationController::Unauthorized + # end + # end + + def log_audit_success(service, role_id, client_ip, type) + @audit_logger.log( + ::Audit::Event::Authn::Authenticate.new( + authenticator_name: type, + service: service, + role_id: role_id, + client_ip: client_ip, + success: true, + error_message: nil + ) ) end - def log_audit_failure(account, service_id, client_ip, type, error) - ::Authentication::LogAuditEvent.new.call( - authentication_params: - Authentication::AuthenticatorInput.new( - authenticator_name: "#{type}", - service_id: service_id, - account: account, - username: nil, - client_ip: client_ip, - credentials: nil, - request: nil - ), - audit_event_class: Audit::Event::Authn::Authenticate, - error: error + def log_audit_failure(service, role_id, client_ip, type, error) + @audit_logger.log( + ::Audit::Event::Authn::Authenticate.new( + authenticator_name: type, + service: service, + role_id: role_id, + client_ip: client_ip, + success: false, + error_message: error.message + ) ) end end diff --git a/app/domain/authentication/installed_authenticators.rb b/app/domain/authentication/installed_authenticators.rb index 295d5c19de..850e778e74 100644 --- a/app/domain/authentication/installed_authenticators.rb +++ b/app/domain/authentication/installed_authenticators.rb @@ -44,8 +44,12 @@ def enabled_authenticators_str enabled_authenticators.join(',') end + def native_authenticators + %w[authn] + end + private - + def db_enabled_authenticators # Always include 'authn' when enabling authenticators via CLI so that it # doesn't get disabled when another authenticator is enabled diff --git a/app/domain/authentication/util/klass_loader.rb b/app/domain/authentication/util/klass_loader.rb new file mode 100644 index 0000000000..506bfbff78 --- /dev/null +++ b/app/domain/authentication/util/klass_loader.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Authentication + module Util + + # This is a utility to handle detection of Authenticator and Role Annotation + # validations. This enables us to optionally add validations for a particular + # authenticator. + # + # It returns either the provided block or nil. + class KlassLoader + class << self + def set_if_present(&block) + block.call + rescue NameError + nil + end + end + end + end +end diff --git a/app/domain/authentication/util/namespace_selector.rb b/app/domain/authentication/util/namespace_selector.rb index d168505d43..9339560f94 100644 --- a/app/domain/authentication/util/namespace_selector.rb +++ b/app/domain/authentication/util/namespace_selector.rb @@ -5,6 +5,8 @@ module Util class NamespaceSelector def self.select(authenticator_type:) case authenticator_type + when 'authn' + 'Authentication::AuthnApiKey::V2' when 'authn-oidc' # 'V2' is a bit of a hack to handle the fact that # the original OIDC authenticator is really a diff --git a/app/domain/errors.rb b/app/domain/errors.rb index 8f654f4025..9127fe025c 100644 --- a/app/domain/errors.rb +++ b/app/domain/errors.rb @@ -170,11 +170,6 @@ module Security msg: "Account '{0-account-name}' is not defined in Conjur", code: "CONJ00008E" ) - - MultipleRoleMatchesFound = ::Util::TrackableErrorClass.new( - msg: "'{0-role}' matched multiple roles", - code: "CONJ00009E" - ) end module RequestBody diff --git a/app/domain/rbac/permission.rb b/app/domain/rbac/permission.rb new file mode 100644 index 0000000000..8382d702a1 --- /dev/null +++ b/app/domain/rbac/permission.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module RBAC + class Permission + def initialize(resource_library: ::Resource) + @resource_library = resource_library + + @success = ::SuccessResponse + @failure = ::FailureResponse + end + + def permitted?(role:, resource_id:, privilege:) + resource = @resource_library[resource_id] + + unless resource.present? + return @failure.new( + "Resource '#{resource_id}' was not found", + status: :unauthorized, + exception: Errors::Conjur::RequiredResourceMissing.new(resource_id) + ) + end + + # binding.pry + if role.present? && role.allowed_to?(privilege, resource) + @success.new(role) + else + @failure.new( + role, + status: :forbidden, + exception: Errors::Authentication::Security::RoleNotAuthorizedOnResource.new( + [role.try(:kind), role.try(:identifier)].join('/'), + privilege, + resource_id + ) + ) + end + end + end +end diff --git a/app/domain/responses.rb b/app/domain/responses.rb index 4fc9948f17..4a3a9cad94 100644 --- a/app/domain/responses.rb +++ b/app/domain/responses.rb @@ -26,12 +26,14 @@ def bind(&_block) # Responsible for handling "failed" requests. # Log level and Response code are both option. class FailureResponse - attr_reader :message, :status + attr_reader :message, :status, :exception, :backtrace - def initialize(message, level: :warn, status: :unauthorized) + def initialize(message, level: :warn, status: :unauthorized, exception: nil) @message = message @level = level @status = status + @exception = exception + @backtrace = caller # Add stack trace end def success? diff --git a/app/domain/util/contract_utils.rb b/app/domain/util/contract_utils.rb new file mode 100644 index 0000000000..cc8139ba72 --- /dev/null +++ b/app/domain/util/contract_utils.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Util + class ContractUtils + class << self + def failed_response(error:, key:) + key.failure(exception: error, text: error.message) + end + end + end +end diff --git a/ci/oauth/keycloak/fetch_certificate b/ci/oauth/keycloak/fetch_certificate index 5b103f3cfc..3ea58cce23 100755 --- a/ci/oauth/keycloak/fetch_certificate +++ b/ci/oauth/keycloak/fetch_certificate @@ -15,4 +15,6 @@ openssl s_client \ hash=$(openssl x509 -hash -in /etc/ssl/certs/keycloak.pem --noout) +rm -f "/etc/ssl/certs/${hash}.0" + ln -s /etc/ssl/certs/keycloak.pem "/etc/ssl/certs/${hash}.0" || true diff --git a/config/routes.rb b/config/routes.rb index 702ed3277e..88b0912bef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,12 +29,16 @@ def matches?(request) get '/:authenticator(/:service_id)/:account/login' => 'authenticate#login' + constraints authenticator: /authn/ do + post '/:authenticator/:account/:id/authenticate' => 'authenticate#authenticate_via_post' + end + constraints authenticator: /authn|authn-azure|authn-iam|authn-k8s|authn-ldap/ do post '/:authenticator(/:service_id)/:account/:id/authenticate' => 'authenticate#authenticate' end # New OIDC endpoint - get '/:authenticator(/:service_id)/:account/authenticate' => 'authenticate#oidc_authenticate_code_redirect' + get '/:authenticator(/:service_id)/:account/authenticate' => 'authenticate#authenticate_via_get' post '/authn-gcp/:account/authenticate' => 'authenticate#authenticate_gcp' post '/authn-oidc(/:service_id)/:account/authenticate' => 'authenticate#authenticate_oidc' diff --git a/cucumber/authenticators_oidc/features/authn_oidc_v2.feature b/cucumber/authenticators_oidc/features/authn_oidc_v2.feature index 4b6daff86e..adfbc46b09 100644 --- a/cucumber/authenticators_oidc/features/authn_oidc_v2.feature +++ b/cucumber/authenticators_oidc/features/authn_oidc_v2.feature @@ -264,10 +264,10 @@ Feature: OIDC Authenticator V2 - Users can authenticate with OIDC authenticator Given I save my place in the log file And I fetch a code for username "alice" and password "alice" from "keycloak2" When I authenticate via OIDC V2 with code and service-id "non-exist" - Then it is not found + Then it is a bad request And The following appears in the log after my savepoint: """ - Errors::Conjur::RequestedResourceNotFound: CONJ00123E Resource + Errors::Authentication::Security::AuthenticatorNotWhitelisted: CONJ00004E 'authn-oidc/non-exist' is not enabled """ @smoke diff --git a/dev/start b/dev/start index d0817759ee..e2b1882be9 100755 --- a/dev/start +++ b/dev/start @@ -13,9 +13,9 @@ if [ ! -f "../VERSION" ]; then fi # Minimal set of services. We add to this list based on cmd line flags. -services=(pg conjur client) +services=(pg conjur client cucumber) -# Authenticators to enable. +# Authenticators to enable. default_authenticators="authn,authn-k8s/test" enabled_authenticators="$default_authenticators" @@ -334,7 +334,7 @@ enable_oidc_authenticators() { echo "Configuring Keycloak as OpenID provider for manual testing" # We enable an OIDC authenticator without a service-id to test that it's # invalid. - enabled_authenticators="$enabled_authenticators,authn-oidc/keycloak,authn-oidc,authn-oidc/keycloak2" + enabled_authenticators="$enabled_authenticators,authn-oidc/keycloak,authn-oidc,authn-oidc/keycloak2,authn-oidc/keycloak2-long-lived" fi if [[ $ENABLE_OIDC_OKTA = true ]]; then