EGI-FCTF/rOCCI-api

View on GitHub
lib/occi/api/client/http/authn_plugins/keystone.rb

Summary

Maintainability
C
1 day
Test Coverage
module Occi::Api::Client
  module Http
    module AuthnPlugins

      class Keystone < Base

        KEYSTONE_URI_REGEXP = /^(Keystone|snf-auth) uri=("|')(.+)("|')$/
        KEYSTONE_VERSION_REGEXP = /^v([0-9]).*$/

        def setup(options = {})
          # get Keystone URL if possible
          set_keystone_base_url

          # discover Keystone API version
          @env_ref.class.headers.delete 'X-Auth-Token'
          if @options[:type] == 'oauth2'
            keystone_version = 3
          end
          set_auth_token ENV['ROCCI_CLIENT_KEYSTONE_TENANT'], keystone_version

          raise ::Occi::Api::Client::Errors::AuthnError,
                "Unable to get a tenant from Keystone, fallback failed!" if @env_ref.class.headers['X-Auth-Token'].blank?
        end

        def authenticate(options = {})
          # OCCI-OS doesn't support HEAD method!
          response = @env_ref.class.get "#{@env_ref.endpoint.to_s}/-/"
          raise ::Occi::Api::Client::Errors::AuthnError,
                "Authentication failed with code #{response.code.to_s}!" unless response.success?
        end

        private

        def set_keystone_base_url
          response = @env_ref.class.get "#{@env_ref.endpoint.to_s}/-/"
          Occi::Api::Log.debug response.inspect

          return if response.success?
          raise ::Occi::Api::Client::Errors::AuthnError,
                "Keystone AuthN failed with #{response.code.to_s}!" unless response.code == 401

          process_headers(response)
        end

        def process_headers(response)
          authN_header = response.headers['www-authenticate']

          if authN_header.blank?
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Response does not contain the www-authenticate header, fallback failed!"
          end

          match = KEYSTONE_URI_REGEXP.match(authN_header)
          raise ::Occi::Api::Client::Errors::AuthnError,
                "Unable to get Keystone's URL from the response, fallback failed!" unless match && match[3]

          @keystone_url = match[3]
        end

        def set_auth_token(tenant = nil, keystone_version = nil)
          response = @env_ref.class.get @keystone_url
          Occi::Api::Log.debug response.inspect

          raise ::Occi::Api::Client::Errors::AuthnError,
                "Unable to get Keystone API version from the response, fallback failed!" if (400..599).include?(response.code)

          # multiple choices, sort them by version id (preferred is v2)
          if response.code == 300
            versions = response['versions']['values'].sort_by { |v| v['id']}
          else
            # assume a single version
            versions = [response['version']]
          end

          versions.each do |v|
            match = KEYSTONE_VERSION_REGEXP.match(v['id'])
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to get Keystone API version from the response, fallback failed!" unless match && match[1]
            if match[1] == '2'
              if keystone_version == nil or keystone_version == 2
                Occi::Api::Log.debug "Selecting Keystone V2 interface"
                handler_class = KeystoneV2
              else
                next
              end
            elsif match[1] == '3'
              if keystone_version == nil or keystone_version == 3
                Occi::Api::Log.debug "Selecting Keystone V3 interface"
                handler_class = KeystoneV3
              else
                next
              end
            end
            v['links'].each do |link|
              begin
                if link['rel'] == 'self'
                  keystone_url = link['href'].chomp('/')
                  keystone_handler = handler_class.new(keystone_url, @env_ref, @options)
                  token = keystone_handler.set_auth_token(tenant)
                  # found a working keystone, stop looking
                  return
                end
              rescue ::Occi::Api::Client::Errors::AuthnError
                # ignore and try with next link
              end
            end
          end
        end

      end

      class KeystoneV2
        def initialize(base_url, env_ref, options = {})
          @base_url = base_url
          @env_ref = env_ref
          @options = options
        end

        def set_auth_token(tenant = nil)
          if !tenant.blank?
            # get a scoped token for the specified tenant directly
            authenticate ENV['ROCCI_CLIENT_KEYSTONE_TENANT']
          else
            # get an unscoped token, use the unscoped token
            # for tenant discovery and get a scoped token
            authenticate
            get_first_working_tenant
          end
        end

        def authenticate(tenant = nil)
          response = @env_ref.class.post(
            "#{@base_url}/tokens",
            :body => get_keystone_req(tenant),
            :headers => get_req_headers
          )
          Occi::Api::Log.debug response.inspect

          if response.success?
            @env_ref.class.headers['X-Auth-Token'] = response['access']['token']['id']
          else
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to get a token from Keystone, fallback failed!"
          end
        end

        def get_keystone_req(tenant = nil)
          if @options[:original_type] == "x509"
            body = { "auth" => { "voms" => true } }
          elsif @options[:username] && @options[:password]
            body = {
              "auth" => {
                "passwordCredentials" => {
                  "username" => @options[:username],
                  "password" => @options[:password]
                }
              }
            }
          else
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to request a token from Keystone! Chosen " \
                  "AuthN is not supported, fallback failed!"
          end

          body['auth']['tenantName'] = tenant unless tenant.blank?
          body.to_json
        end

        def get_first_working_tenant
          response = @env_ref.class.get(
            "#{@base_url}/tenants",
            :headers => get_req_headers
          )
          Occi::Api::Log.debug response.inspect

          raise ::Occi::Api::Client::Errors::AuthnError,
                "Keystone didn't return any tenants, fallback failed!" if response['tenants'].blank?

          response['tenants'].each do |tenant|
            begin
              Occi::Api::Log.debug "Authenticating for tenant #{tenant['name'].inspect}"
              authenticate(tenant['name'])

              # found a working tenant, stop looking
              break
            rescue ::Occi::Api::Client::Errors::AuthnError
              # ignoring and trying the next tenant
            end
          end
        end

        def get_req_headers
          headers = @env_ref.class.headers.clone
          headers['Content-Type'] = "application/json"
          headers['Accept'] = headers['Content-Type']

          headers
        end
      end

      class KeystoneV3
        def initialize(base_url, env_ref, options = {})
          @base_url = base_url
          @env_ref = env_ref
          @options = options
        end

        def set_auth_token(tenant = nil)
          if @options[:original_type] == "x509"
            set_voms_unscoped_token
          elsif @options[:type] == "oauth2"
            set_oauth2_unscoped_token
          elsif @options[:username] && @options[:password]
            passwd_authenticate
          else
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to request a token from Keystone! Chosen " \
                  "AuthN is not supported, fallback failed!"
          end

          if !tenant.blank?
            set_scoped_token(tenant)
          else
            get_first_working_project
          end
        end

        def passwd_authenticate
          raise ::Occi::Api::Client::Errors::AuthnError,
                "Needs to be implemented, check http://developer.openstack.org/api-ref-identity-v3.html#authenticatePasswordUnscoped"
        end

        def set_voms_unscoped_token
          response = @env_ref.class.post(
            # FIXME(enolfc) egi.eu and mapped below should be configurable
            "#{@base_url}/OS-FEDERATION/identity_providers/egi.eu/protocols/mapped/auth",
          )
          Occi::Api::Log.debug response.inspect

          if response.success?
            @env_ref.class.headers['X-Auth-Token'] = response.headers['x-subject-token']
          else
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to get a token from Keystone, fallback failed!"
          end
        end

        def set_oauth2_unscoped_token
          headers = get_req_headers
          headers['Authorization'] = "Bearer #{@options[:token]}"
          response = @env_ref.class.post(
            # FIXME(enolfc) egi.eu and oidc below should be configurable
            "#{@base_url}/OS-FEDERATION/identity_providers/egi.eu/protocols/oidc/auth",
            :headers => headers
          )
          Occi::Api::Log.debug response.inspect

          if response.success?
            @env_ref.class.headers['X-Auth-Token'] = response.headers['x-subject-token']
          else
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to get a token from Keystone, fallback failed!"
          end
        end

        def get_first_working_project
          response = @env_ref.class.get(
            "#{@base_url}/auth/projects",
            :headers => get_req_headers
          )
          Occi::Api::Log.debug response.inspect

          raise ::Occi::Api::Client::Errors::AuthnError,
                "Keystone didn't return any projects, fallback failed!" if response['projects'].blank?

          response['projects'].each do |project|
            begin
              Occi::Api::Log.debug "Authenticating for project #{project['name'].inspect}"
              set_scoped_token(project['id'])

              # found a working project, stop looking
              break
            rescue ::Occi::Api::Client::Errors::AuthnError
              # ignoring and trying the next tenant
            end
          end
        end

        def set_scoped_token(project)
          body = {
            "auth" => {
              "identity" => {
                "methods" => ["token"],
                "token" => {"id" => @env_ref.class.headers['X-Auth-Token'] }
              },
              "scope" => {
                "project" => {"id" => project}
              }
            }
          }
          response = @env_ref.class.post(
            "#{@base_url}/auth/tokens",
            :body => body.to_json,
            :headers => get_req_headers
          )

          Occi::Api::Log.debug response.inspect

          if response.success?
            @env_ref.class.headers['X-Auth-Token'] = response.headers['x-subject-token']
          else
            raise ::Occi::Api::Client::Errors::AuthnError,
                  "Unable to get a token from Keystone, fallback failed!"
          end
        end

        def get_req_headers
          headers = @env_ref.class.headers.clone
          headers['Content-Type'] = 'application/json'
          headers['Accept'] = headers['Content-Type']

          headers
        end

      end

    end
  end
end