diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 013624d..a7c2c45 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -26,9 +26,15 @@ jobs: continue-on-error: ${{ matrix.channel != 'stable' }} env: - SP_USERNAME: test - SP_PASSWORD: test SP_URL: http://localhost:1234/ + SP_AUTHENTICATION: token + SP_CLIENT_ID: clientfoo + SP_CLIENT_SECRET: secretfoo + SP_TENANT_ID: tenantfoo + SP_CERT_NAME: certfoo + SP_AUTH_SCOPE: http://localhost:1234/ + SP_USERNAME: userfoo + SP_PASSWORD: passfoo steps: - name: Install libmagic-dev diff --git a/README.md b/README.md index 32aafdb..b43623e 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,28 @@ And then execute: You can instantiate a number of SharePoint clients in your application: +#### Token authentication + ```rb client = Sharepoint::Client.new({ - username: 'username', - password: 'password', - uri: 'https://sharepoint_url' + authentication: "token", + client_id: "client_id", + client_secret: "client_secret", + tenant_id: "tenant_id", + cert_name: "cert_name", + auth_scope: "auth_scope", + uri: "http://sharepoint_url" +}) +``` + +#### NTLM authentication + +```rb +client = Sharepoint::Client.new({ + authentication: "ntlm", + username: "username", + password: "password", + uri: "http://sharepoint_url" }) ``` @@ -47,3 +64,11 @@ client.upload filename, content, path ```rb client.update_metadata filename, metadata, path ``` + +## Testing + +Create a .env file based on the `env-example` and run + +```bash +$ bundle exec rake +``` diff --git a/env-example b/env-example index 0eb4e30..996b2d0 100644 --- a/env-example +++ b/env-example @@ -1,4 +1,11 @@ # SharePoint Access -SP_USERNAME= -SP_PASSWORD= -SP_URL= +SP_AUTHENTICATION=token +SP_CLIENT_ID=foo +SP_CLIENT_SECRET=foo +SP_TENANT_ID=foo +SP_CERT_NAME=foo +SP_AUTH_SCOPE=https://foobar.example.org +SP_URL=https://foobar.example.org +SP_USERNAME=foo +SP_PASSWORD=foo +SP_AUTHENTICATION= token | ntlm diff --git a/lib/sharepoint/client.rb b/lib/sharepoint/client.rb index 7d59887..207d015 100644 --- a/lib/sharepoint/client.rb +++ b/lib/sharepoint/client.rb @@ -6,23 +6,51 @@ require 'active_support/core_ext/string/inflections' require 'active_support/core_ext/object/blank' +require 'sharepoint/client/token' module Sharepoint class Client FILENAME_INVALID_CHARS = '~"#%&*:<>?/\{|}' + DEFAULT_TOKEN_ETHON_OPTIONS = { followlocation: 1, maxredirs: 5 }.freeze + VALID_TOKEN_CONFIG_OPTIONS = %i[client_id client_secret tenant_id cert_name auth_scope].freeze + + DEFAULT_NTLM_ETHON_OPTIONS = { httpauth: :ntlm, followlocation: 1, maxredirs: 5 }.freeze + VALID_NTLM_CONFIG_OPTIONS = %i[username password].freeze + + def authenticating_with_token + generate_new_token + yield + end + + def generate_new_token + token.retrieve + end + + def bearer_auth + "Bearer #{token}" + end + # @return [OpenStruct] The current configuration. attr_reader :config + attr_reader :token # Initializes a new client with given options. # # @param [Hash] config The client options: # - `:uri` The SharePoint server's root url + # - `:authentication` The authentication method to use [:ntlm, :token] # - `:username` self-explanatory # - `:password` self-explanatory + # - `:client_id` self-explanatory + # - `:client_secret` self-explanatory + # - `:tenant_id` self-explanatory + # - `:cert_name` self-explanatory + # - `:auth_scope` self-explanatory # @return [Sharepoint::Client] client object def initialize(config = {}) @config = OpenStruct.new(config) + @token = Token.new(@config) validate_config! end @@ -304,11 +332,11 @@ def create_folder(name, path, site_path = nil) path = path[1..-1] if path[0].eql?('/') url = uri_escape "#{url}GetFolderByServerRelativeUrl('#{path}')/Folders" easy = ethon_easy_json_requester - easy.headers = { - 'accept' => 'application/json;odata=verbose', - 'content-type' => 'application/json;odata=verbose', - 'X-RequestDigest' => xrequest_digest(site_path) - } + easy.headers = with_bearer_authentication_header({ + 'accept' => 'application/json;odata=verbose', + 'content-type' => 'application/json;odata=verbose', + 'X-RequestDigest' => xrequest_digest(site_path) + }) payload = { '__metadata' => { 'type' => 'SP.Folder' @@ -351,10 +379,8 @@ def upload(filename, content, path, site_path = nil) path = path[1..-1] if path[0].eql?('/') url = uri_escape "#{url}GetFolderByServerRelativeUrl('#{path}')/Files/Add(url='#{sanitized_filename}',overwrite=true)" easy = ethon_easy_json_requester - easy.headers = { - 'accept' => 'application/json;odata=verbose', - 'X-RequestDigest' => xrequest_digest(site_path) - } + easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose', + 'X-RequestDigest' => xrequest_digest(site_path) }) easy.http_request(url, :post, { body: content }) easy.perform check_and_raise_failure(easy) @@ -382,13 +408,11 @@ def update_metadata(filename, metadata, path, site_path = nil) prepared_metadata = prepare_metadata(metadata, __metadata['type']) easy = ethon_easy_json_requester - easy.headers = { - 'accept' => 'application/json;odata=verbose', - 'content-type' => 'application/json;odata=verbose', - 'X-RequestDigest' => xrequest_digest(site_path), - 'X-Http-Method' => 'PATCH', - 'If-Match' => '*' - } + easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose', + 'content-type' => 'application/json;odata=verbose', + 'X-RequestDigest' => xrequest_digest(site_path), + 'X-Http-Method' => 'PATCH', + 'If-Match' => '*' }) easy.http_request(update_metadata_url, :post, { body: prepared_metadata }) @@ -456,6 +480,10 @@ def index_field(list_name, field_name, site_path = '') update_object_metadata parsed_response_body['d']['__metadata'], { 'Indexed' => true }, site_path end + def requester + ethon_easy_requester + end + private def process_url(url, fields) @@ -478,6 +506,24 @@ def process_url(url, fields) end end + def token_auth? + config.authentication == 'token' + end + + def ntlm_auth? + config.authentication == 'ntlm' + end + + def with_bearer_authentication_header(h) + return h if ntlm_auth? + + h.merge(bearer_auth_header) + end + + def bearer_auth_header + { 'Authorization' => bearer_auth } + end + def base_url config.uri end @@ -504,7 +550,7 @@ def computed_web_api_url(site) def ethon_easy_json_requester easy = ethon_easy_requester - easy.headers = { 'accept' => 'application/json;odata=verbose' } + easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose' }) easy end @@ -513,10 +559,18 @@ def ethon_easy_options end def ethon_easy_requester - easy = Ethon::Easy.new({ httpauth: :ntlm, followlocation: 1, maxredirs: 5 }.merge(ethon_easy_options)) - easy.username = config.username - easy.password = config.password - easy + if token_auth? + authenticating_with_token do + easy = Ethon::Easy.new(DEFAULT_TOKEN_ETHON_OPTIONS.merge(ethon_easy_options)) + easy.headers = with_bearer_authentication_header({}) + easy + end + elsif ntlm_auth? + easy = Ethon::Easy.new(DEFAULT_NTLM_ETHON_OPTIONS.merge(ethon_easy_options)) + easy.username = config.username + easy.password = config.password + easy + end end # When you send a POST request, the request must include the form digest @@ -584,26 +638,63 @@ def extract_paths(url) } end + def validate_token_config + valid_config_options(VALID_TOKEN_CONFIG_OPTIONS) + end + + def validate_ntlm_config + valid_config_options(VALID_NTLM_CONFIG_OPTIONS) + end + + def valid_config_options(options = []) + options.map do |opt| + c = config.send(opt) + + next if c.present? && string_not_blank?(c) + + opt + end.compact + end + def validate_config! - raise Errors::UsernameConfigurationError.new unless string_not_blank?(@config.username) - raise Errors::PasswordConfigurationError.new unless string_not_blank?(@config.password) - raise Errors::UriConfigurationError.new unless valid_config_uri? - raise Errors::EthonOptionsConfigurationError.new unless ethon_easy_options.is_a?(Hash) + raise Errors::InvalidAuthenticationError.new unless valid_authentication?(config.authentication) + + validate_token_config! if config.authentication == 'token' + validate_ntlm_config! if config.authentication == 'ntlm' + + raise Errors::UriConfigurationError.new unless valid_uri?(config.uri) + raise Errors::EthonOptionsConfigurationError.new unless ethon_easy_options.is_a?(Hash) + end + + def validate_token_config! + invalid_token_opts = validate_token_config + + raise Errors::InvalidTokenConfigError.new(invalid_token_opts) unless invalid_token_opts.empty? + end + + def validate_ntlm_config! + invalid_ntlm_opts = validate_ntlm_config + + raise Errors::InvalidNTLMConfigError.new(invalid_ntlm_opts) unless invalid_ntlm_opts.empty? end def string_not_blank?(object) !object.nil? && object != '' && object.is_a?(String) end - def valid_config_uri? - if @config.uri and @config.uri.is_a? String - uri = URI.parse(@config.uri) + def valid_uri?(which) + if which and which.is_a? String + uri = URI.parse(which) uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) else false end end + def valid_authentication?(which) + %w[ntlm token].include?(which) + end + # Waiting for RFC 3986 to be implemented, we need to escape square brackets def uri_escape(uri) URI::DEFAULT_PARSER.escape(uri).gsub('[', '%5B').gsub(']', '%5D') @@ -756,13 +847,11 @@ def update_object_metadata(metadata, new_metadata, site_path = '') prepared_metadata = prepare_metadata(new_metadata, metadata['type']) easy = ethon_easy_json_requester - easy.headers = { - 'accept' => 'application/json;odata=verbose', - 'content-type' => 'application/json;odata=verbose', - 'X-RequestDigest' => xrequest_digest(site_path), - 'X-Http-Method' => 'PATCH', - 'If-Match' => '*' - } + easy.headers = with_bearer_authentication_header({ 'accept' => 'application/json;odata=verbose', + 'content-type' => 'application/json;odata=verbose', + 'X-RequestDigest' => xrequest_digest(site_path), + 'X-Http-Method' => 'PATCH', + 'If-Match' => '*' }) easy.http_request(update_metadata_url, :post, diff --git a/lib/sharepoint/client/token.rb b/lib/sharepoint/client/token.rb new file mode 100644 index 0000000..6a0f060 --- /dev/null +++ b/lib/sharepoint/client/token.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Sharepoint + class Client + class Token # rubocop:disable Style/Documentation + class InvalidTokenError < StandardError + end + + attr_accessor :expires_in, :access_token, :fetched_at + attr_reader :config + + def initialize(config) + @config = config + end + + def retrieve + return access_token unless access_token.nil? || expired? + + fetch + end + + def to_s + access_token + end + + def fetch + response = request_new_token + + details = response['Token'] + self.fetched_at = Time.now.utc.to_i + self.expires_in = details['expires_in'] + self.access_token = details['access_token'] + end + + private + + def expired? + return true unless fetched_at && expires_in + + (fetched_at + expires_in) < Time.now.utc.to_i + end + + def request_new_token + auth_request = { + client_id: config.client_id, + client_secret: config.client_secret, + tenant_id: config.tenant_id, + cert_name: config.cert_name, + auth_scope: config.auth_scope + }.to_json + + headers = { 'Content-Type' => 'application/json' } + + ethon = Ethon::Easy.new(followlocation: true) + ethon.http_request(config.token_url, :post, body: auth_request, headers: headers) + ethon.perform + + raise InvalidTokenError.new(ethon.response_body.to_s) unless ethon.response_code == 200 + + JSON.parse(ethon.response_body) + end + end + end +end diff --git a/lib/sharepoint/errors.rb b/lib/sharepoint/errors.rb index d4fe870..65ca127 100644 --- a/lib/sharepoint/errors.rb +++ b/lib/sharepoint/errors.rb @@ -1,26 +1,40 @@ module Sharepoint module Errors - class UsernameConfigurationError < StandardError + class UriConfigurationError < StandardError def initialize - super('Invalid Username Configuration') + super('Invalid Uri configuration') end end - class PasswordConfigurationError < StandardError + class EthonOptionsConfigurationError < StandardError def initialize - super('Invalid Password configuration') + super('Invalid ethon easy options') end end - class UriConfigurationError < StandardError + class InvalidAuthenticationError < StandardError def initialize - super('Invalid Uri configuration') + super('Invalid authentication mechanism') end end - class EthonOptionsConfigurationError < StandardError - def initialize - super('Invalid ethon easy options') + class InvalidTokenConfigError < StandardError + def initialize(invalid_entries) + error_messages = invalid_entries.map do |e| + "Invalid #{e} in Token configuration" + end + + super(error_messages.join(',')) + end + end + + class InvalidNTLMConfigError < StandardError + def initialize(invalid_entries) + error_messages = invalid_entries.map do |e| + "Invalid #{e} in NTLM configuration" + end + + super(error_messages.join(',')) end end end diff --git a/lib/sharepoint/spec_helpers.rb b/lib/sharepoint/spec_helpers.rb index ef073aa..6300d34 100644 --- a/lib/sharepoint/spec_helpers.rb +++ b/lib/sharepoint/spec_helpers.rb @@ -11,12 +11,32 @@ def value_to_string(value) end end + def sp_config(authentication: nil) + { + uri: ENV.fetch('SP_URL', nil), + authentication: authentication || ENV.fetch('SP_AUTHENTICATION', nil), + client_id: ENV.fetch('SP_CLIENT_ID', nil), + client_secret: ENV.fetch('SP_CLIENT_SECRET', nil), + tenant_id: ENV.fetch('SP_TENANT_ID', nil), + cert_name: ENV.fetch('SP_CERT_NAME', nil), + auth_scope: ENV.fetch('SP_AUTH_SCOPE', nil), + username: ENV.fetch('SP_USERNAME', nil), + password: ENV.fetch('SP_PASSWORD', nil) + } + end + def mock_requests allow_any_instance_of(Ethon::Easy) .to receive(:perform) .and_return(nil) end + def mock_token_responses + allow_any_instance_of(Sharepoint::Client::Token) + .to receive(:request_new_token) + .and_return({ 'Token' => { 'expires_in' => 3600, 'access_token' => 'access_token' } }) + end + def mock_responses(fixture_file) allow_any_instance_of(Ethon::Easy) .to receive(:response_code) diff --git a/spec/lib/sharepoint/client_methods_spec.rb b/spec/lib/sharepoint/client_methods_spec.rb index 1a4f8a9..451b156 100644 --- a/spec/lib/sharepoint/client_methods_spec.rb +++ b/spec/lib/sharepoint/client_methods_spec.rb @@ -3,15 +3,13 @@ require 'spec_helper' describe Sharepoint::Client do - before { mock_requests } - - let(:config) do - { - username: ENV.fetch('SP_USERNAME', nil), - password: ENV.fetch('SP_PASSWORD', nil), - uri: ENV.fetch('SP_URL', nil) - } + before do + mock_requests + mock_token_responses end + + let(:config) { sp_config } + let(:client) { described_class.new(config) } describe '#documents_for' do diff --git a/spec/lib/sharepoint/client_spec.rb b/spec/lib/sharepoint/client_spec.rb index 145348e..97a3a1f 100644 --- a/spec/lib/sharepoint/client_spec.rb +++ b/spec/lib/sharepoint/client_spec.rb @@ -3,11 +3,9 @@ require 'spec_helper' describe Sharepoint::Client do - let(:config) do - { username: ENV.fetch('SP_USERNAME', nil), - password: ENV.fetch('SP_PASSWORD', nil), - uri: ENV.fetch('SP_URL', nil) } - end + before { ENV['SP_URL'] = 'https://localhost:8888' } + + let(:config) { sp_config } describe '#initialize' do context 'with success' do @@ -20,7 +18,7 @@ it 'sets config object' do client_config = client.config expect(client_config).to be_a OpenStruct - %i[username password url].each do |key| + %i[client_id client_secret tenant_id cert_name auth_scope url].each do |key| value = client_config.send(key) expect(value).to eq config[key] end @@ -39,15 +37,27 @@ end end + context 'with authentication' do + [{ value: 'ntlm', name: 'ntlm' }, + { value: 'token', name: 'token' }].each do |ocurrence| + it "does not raise authentication configuration error for #{ocurrence[:name]} authentication" do + correct_config = config + correct_config[:authentication] = ocurrence[:value] + + expect do + described_class.new(correct_config) + end.not_to raise_error + end + end + end + context 'with ethon easy options' do context 'with success' do - subject(:client) { described_class.new(config_ethon) } - let(:config_ethon) { config.merge({ ethon_easy_options: ssl_verifypeer }) } let(:ssl_verifypeer) { { ssl_verifypeer: false } } it 'sets ethon easy options in the client' do - expect(client.send(:ethon_easy_options)).to eql(ssl_verifypeer) + expect(described_class.new(config_ethon).send(:ethon_easy_options)).to eql(ssl_verifypeer) end end @@ -63,54 +73,207 @@ end context 'with failure' do - context 'with bad username' do + context 'with bad authentication' do [{ value: nil, name: 'nil' }, { value: '', name: 'blank' }, { value: 344, name: 344 }].each do |ocurrence| - it "raises username configuration error for #{ocurrence[:name]} username" do + it "raises authentication configuration error for #{ocurrence[:name]} authentication" do wrong_config = config - wrong_config[:username] = ocurrence[:value] + wrong_config[:authentication] = ocurrence[:value] expect do described_class.new(wrong_config) - end.to raise_error(Sharepoint::Errors::UsernameConfigurationError) + end.to raise_error(Sharepoint::Errors::InvalidAuthenticationError) end end end - context 'with bad password' do - [{ value: nil, name: 'nil' }, - { value: '', name: 'blank' }, - { value: 344, name: 344 }].each do |ocurrence| - it "raises password configuration error for #{ocurrence[:name]} password" do - wrong_config = config - wrong_config[:password] = ocurrence[:value] + context 'with token' do + before { ENV['SP_AUTHENTICATION'] = 'token' } - expect do - described_class.new(wrong_config) - end.to raise_error(Sharepoint::Errors::PasswordConfigurationError) + context 'with bad client_id' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises client_id configuration error for #{ocurrence[:name]} client_id" do + wrong_config = config + wrong_config[:client_id] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidTokenConfigError) + end + end + end + + context 'with bad client_secret' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises client_secret configuration error for #{ocurrence[:name]} client_secret" do + wrong_config = config + wrong_config[:client_secret] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidTokenConfigError) + end + end + end + + context 'with bad tenant_id' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises tenant_id configuration error for #{ocurrence[:name]} tenant_id" do + wrong_config = config + wrong_config[:tenant_id] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidTokenConfigError) + end + end + end + + context 'with bad cert_name' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises cert_name configuration error for #{ocurrence[:name]} cert_name" do + wrong_config = config + wrong_config[:cert_name] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidTokenConfigError) + end + end + end + + context 'with bad auth_scope' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises auth_scope configuration error for #{ocurrence[:name]} auth_scope" do + wrong_config = config + wrong_config[:auth_scope] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidTokenConfigError) + end + end + end + + it 'with bad auth_scope uri format' do + skip 'Uri is not formatted' + [{ value: 'ftp://www.test.com', name: 'invalid auth_scope' }].each do |ocurrence| + it "raises auth_scope configuration error for #{ocurrence[:name]} auth_scope" do + wrong_config = config + wrong_config[:auth_scope] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::UriConfigurationError) + end + end + end + + context 'with bad uri' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }, + { value: 'ftp://www.test.com', name: 'invalid uri' }].each do |ocurrence| + it "raises uri configuration error for #{ocurrence[:name]} uri" do + wrong_config = config + wrong_config[:uri] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::UriConfigurationError) + end end end end - context 'with bad uri' do - [{ value: nil, name: 'nil' }, - { value: '', name: 'blank' }, - { value: 344, name: 344 }, - { value: 'ftp://www.test.com', name: 'invalid uri' }].each do |ocurrence| - it "raises uri configuration error for #{ocurrence[:name]} uri" do - wrong_config = config - wrong_config[:uri] = ocurrence[:value] + context 'when ntlm' do + before { ENV['SP_AUTHENTICATION'] = 'ntlm' } - expect do - described_class.new(wrong_config) - end.to raise_error(Sharepoint::Errors::UriConfigurationError) + context 'with bad username' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises username configuration error for #{ocurrence[:name]} username" do + wrong_config = config + wrong_config[:username] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidNTLMConfigError) + end + end + end + + context 'with bad password' do + [{ value: nil, name: 'nil' }, + { value: '', name: 'blank' }, + { value: 344, name: 344 }].each do |ocurrence| + it "raises password configuration error for #{ocurrence[:name]} password" do + wrong_config = config + wrong_config[:password] = ocurrence[:value] + + expect do + described_class.new(wrong_config) + end.to raise_error(Sharepoint::Errors::InvalidNTLMConfigError) + end end end end end end + describe '#ethon_requester' do + subject(:requester) { client.send(:ethon_easy_requester) } + + let(:client) { described_class.new(client_config) } + let(:token) { instance_double(Token, access_token: 'footoken') } + + before do + mock_token_responses + allow(client).to receive(:authenticating_with_token).and_call_original + end + + context 'when has token authentication' do + let(:client_config) { sp_config(authentication: 'token') } + + it 'calls authenticating_with_token' do + requester + expect(client).to have_received(:authenticating_with_token) + end + + it 'client token is set' do + requester + expect(client.token.access_token).not_to be_nil + end + end + + context 'when has ntlm authentication' do + subject { client.send(:ethon_easy_requester) } + + let(:client_config) { sp_config(authentication: 'ntlm') } + + it 'does not call authenticating_with_token' do + requester + expect(client).not_to have_received(:authenticating_with_token) + end + + it 'token is null' do + expect(client.token.access_token).to be_nil + end + end + end + describe '#remove_double_slashes' do { 'foobar' => 'foobar',