diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 66f41769..ade8f95e 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -670,8 +670,9 @@ class IMAP < Protocol "UTF8=ONLY" => "UTF8=ACCEPT", }.freeze - autoload :SASL, File.expand_path("imap/sasl", __dir__) - autoload :StringPrep, File.expand_path("imap/stringprep", __dir__) + autoload :SASL, File.expand_path("imap/sasl", __dir__) + autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__) + autoload :StringPrep, File.expand_path("imap/stringprep", __dir__) include MonitorMixin if defined?(OpenSSL::SSL) @@ -1142,7 +1143,10 @@ def starttls(**options) end # :call-seq: - # authenticate(mechanism, *, sasl_ir: true, **, &) -> ok_resp + # authenticate(mechanism, *, + # sasl_ir: true, + # registry: Net::IMAP::SASL.authenticators, + # **, &) -> ok_resp # # Sends an {AUTHENTICATE command [IMAP4rev1 ยง6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2] # to authenticate the client. If successful, the connection enters the @@ -2746,6 +2750,10 @@ def start_tls_session end end + def sasl_adapter + SASLAdapter.new(self, &method(:send_command_with_continuations)) + end + #-- # We could get the saslprep method by extending the SASLprep module # directly. It's done indirectly, so SASLprep can be lazily autoloaded, diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index 62185ba8..c9154dca 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -135,6 +135,10 @@ def initialize(response, message = "authentication ended prematurely") autoload :BidiStringError, sasl_stringprep_rb sasl_dir = File.expand_path("sasl", __dir__) + autoload :AuthenticationExchange, "#{sasl_dir}/authentication_exchange" + autoload :ClientAdapter, "#{sasl_dir}/client_adapter" + autoload :ProtocolAdapters, "#{sasl_dir}/protocol_adapters" + autoload :Authenticators, "#{sasl_dir}/authenticators" autoload :GS2Header, "#{sasl_dir}/gs2_header" autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm" @@ -155,8 +159,10 @@ def initialize(response, message = "authentication ended prematurely") # Returns the default global SASL::Authenticators instance. def self.authenticators; @authenticators ||= Authenticators.new end - # Delegates to ::authenticators. See Authenticators#authenticator. - def self.authenticator(...) authenticators.authenticator(...) end + # Delegates to registry.new See Authenticators#new. + def self.authenticator(*args, registry: authenticators, **kwargs, &block) + registry.new(*args, **kwargs, &block) + end # Delegates to ::authenticators. See Authenticators#add_authenticator. def self.add_authenticator(...) authenticators.add_authenticator(...) end diff --git a/lib/net/imap/sasl/authentication_exchange.rb b/lib/net/imap/sasl/authentication_exchange.rb new file mode 100644 index 00000000..3276580c --- /dev/null +++ b/lib/net/imap/sasl/authentication_exchange.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +module Net + class IMAP + module SASL + + # This API is *experimental*, and may change. + # + # TODO: catch exceptions in #process and send #cancel_response. + # TODO: raise an error if the command succeeds after being canceled. + # TODO: use with more clients, to verify the API can accommodate them. + # + # Create an AuthenticationExchange from a client adapter and a mechanism + # authenticator: + # def authenticate(mechanism, ...) + # authenticator = SASL.authenticator(mechanism, ...) + # SASL::AuthenticationExchange.new( + # sasl_adapter, mechanism, authenticator + # ).authenticate + # end + # + # private + # + # def sasl_adapter = MyClientAdapter.new(self, &method(:send_command)) + # + # Or delegate creation of the authenticator to ::build: + # def authenticate(...) + # SASL::AuthenticationExchange.build(sasl_adapter, ...) + # .authenticate + # end + # + # As a convenience, ::authenticate combines ::build and #authenticate: + # def authenticate(...) + # SASL::AuthenticationExchange.authenticate(sasl_adapter, ...) + # end + # + # Likewise, ClientAdapter#authenticate delegates to #authenticate: + # def authenticate(...) = sasl_adapter.authenticate(...) + # + class AuthenticationExchange + # Convenience method for build(...).authenticate + def self.authenticate(...) build(...).authenticate end + + # Use +registry+ to override the global Authenticators registry. + def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block) + authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block) + new(client, mechanism, authenticator, sasl_ir: sasl_ir) + end + + attr_reader :mechanism, :authenticator + + def initialize(client, mechanism, authenticator, sasl_ir: true) + @client = client + @mechanism = -mechanism.to_s.upcase.tr(?_, ?-) + @authenticator = authenticator + @sasl_ir = sasl_ir + @processed = false + end + + # Call #authenticate to execute an authentication exchange for #client + # using #authenticator. Authentication failures will raise an + # exception. Any exceptions other than those in RESPONSE_ERRORS will + # drop the connection. + def authenticate + client.run_command(mechanism, initial_response) { process _1 } + .tap { raise AuthenticationIncomplete, _1 unless done? } + rescue *client.response_errors + raise # but don't drop the connection + rescue + client.drop_connection + raise + rescue Exception # rubocop:disable Lint/RescueException + client.drop_connection! + raise + end + + def send_initial_response? + @sasl_ir && + authenticator.respond_to?(:initial_response?) && + authenticator.initial_response? && + client.sasl_ir_capable? && + client.auth_capable?(mechanism) + end + + def done? + authenticator.respond_to?(:done?) ? authenticator.done? : @processed + end + + private + + attr_reader :client + + def initial_response + return unless send_initial_response? + client.encode_ir authenticator.process nil + end + + def process(challenge) + client.encode authenticator.process client.decode challenge + ensure + @processed = true + end + + end + end + end +end diff --git a/lib/net/imap/sasl/authenticators.rb b/lib/net/imap/sasl/authenticators.rb index 09d271c3..34e52041 100644 --- a/lib/net/imap/sasl/authenticators.rb +++ b/lib/net/imap/sasl/authenticators.rb @@ -65,7 +65,7 @@ def names; @authenticators.keys end # lazily loaded from Net::IMAP::SASL::#{name}Authenticator (case is # preserved and non-alphanumeric characters are removed.. def add_authenticator(name, authenticator = nil) - key = name.upcase.to_sym + key = -name.to_s.upcase.tr(?_, ?-) authenticator ||= begin class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym auth_class = nil @@ -79,12 +79,12 @@ def add_authenticator(name, authenticator = nil) # Removes the authenticator registered for +name+ def remove_authenticator(name) - key = name.upcase.to_sym + key = -name.to_s.upcase.tr(?_, ?-) @authenticators.delete(key) end def mechanism?(name) - key = name.upcase.to_sym + key = -name.to_s.upcase.tr(?_, ?-) @authenticators.key?(key) end @@ -105,8 +105,9 @@ def mechanism?(name) # only. Protocol client users should see refer to their client's # documentation, e.g. Net::IMAP#authenticate. def authenticator(mechanism, ...) - auth = @authenticators.fetch(mechanism.upcase.to_sym) do - raise ArgumentError, 'unknown auth type - "%s"' % mechanism + key = -mechanism.to_s.upcase.tr(?_, ?-) + auth = @authenticators.fetch(key) do + raise ArgumentError, 'unknown auth type - "%s"' % key end auth.respond_to?(:new) ? auth.new(...) : auth.call(...) end diff --git a/lib/net/imap/sasl/client_adapter.rb b/lib/net/imap/sasl/client_adapter.rb new file mode 100644 index 00000000..ea9e993a --- /dev/null +++ b/lib/net/imap/sasl/client_adapter.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Net + class IMAP + module SASL + + # This API is *experimental*, and may change. + # + # TODO: use with more clients, to verify the API can accommodate them. + # + # An abstract base class for implementing a SASL authentication exchange. + # Different clients will each have their own adapter subclass, overridden + # to match their needs. + # + # Although the default implementations _may_ be sufficient, subclasses + # will probably need to override some methods. Additionally, subclasses + # may need to include a protocol adapter mixin, if the default + # ProtocolAdapters::Generic isn't sufficient. + class ClientAdapter + include ProtocolAdapters::Generic + + attr_reader :client, :command_proc + + # +command_proc+ can used to avoid exposing private methods on #client. + # It should run a command with the arguments sent to it, yield each + # continuation payload, respond to the server with the result of each + # yield, and return the result. Non-successful results *MUST* raise an + # exception. Exceptions in the block *MUST* cause the command to fail. + # + # Subclasses that override #run_command may use #command_proc for + # other purposes. + def initialize(client, &command_proc) + @client, @command_proc = client, command_proc + end + + # Delegates to AuthenticationExchange.authenticate. + def authenticate(...) AuthenticationExchange.authenticate(self, ...) end + + # Do the protocol and server both support an initial response? + def sasl_ir_capable?; client.sasl_ir_capable? end + + # Does the server advertise support for the mechanism? + def auth_capable?(mechanism); client.auth_capable?(mechanism) end + + # Runs the authenticate command with +mechanism+ and +initial_response+. + # When +initial_response+ is nil, an initial response must NOT be sent. + # + # Yields each continuation payload, responds to the server with the + # result of each yield, and returns the result. Non-successful results + # *MUST* raise an exception. Exceptions in the block *MUST* cause the + # command to fail. + # + # Subclasses that override this may use #command_proc differently. + def run_command(mechanism, initial_response = nil, &block) + command_proc or raise Error, "initialize with block or override" + args = [command_name, mechanism, initial_response].compact + command_proc.call(*args, &block) + end + + # Returns an array of server responses errors raised by run_command. + # Exceptions in this array won't drop the connection. + def response_errors; [] end + + # Drop the connection gracefully. + def drop_connection; client.drop_connection end + + # Drop the connection abruptly. + def drop_connection!; client.drop_connection! end + end + end + end +end diff --git a/lib/net/imap/sasl/protocol_adapters.rb b/lib/net/imap/sasl/protocol_adapters.rb new file mode 100644 index 00000000..519b4596 --- /dev/null +++ b/lib/net/imap/sasl/protocol_adapters.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Net + class IMAP + module SASL + + module ProtocolAdapters + # This API is experimental, and may change. + module Generic + def command_name; "AUTHENTICATE" end + def service; raise "Implement in subclass or module" end + def host; client.host end + def port; client.port end + def encode_ir(string) string.empty? ? "=" : encode(string) end + def encode(string) [string].pack("m0") end + def decode(string) string.unpack1("m0") end + def cancel_response; "*" end + end + + # See RFC-3501 (IMAP4rev1), RFC-4959 (SASL-IR capability), + # and RFC-9051 (IMAP4rev2). + module IMAP + include Generic + def service; "imap" end + end + + # See RFC-4954 (AUTH capability). + module SMTP + include Generic + def command_name; "AUTH" end + def service; "smtp" end + end + + # See RFC-5034 (SASL capability). + module POP + include Generic + def command_name; "AUTH" end + def service; "pop" end + end + + end + + end + end +end diff --git a/lib/net/imap/sasl_adapter.rb b/lib/net/imap/sasl_adapter.rb new file mode 100644 index 00000000..7979414e --- /dev/null +++ b/lib/net/imap/sasl_adapter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Net + class IMAP + + # Experimental + class SASLAdapter < SASL::ClientAdapter + include SASL::ProtocolAdapters::IMAP + + RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError] + .freeze + + def response_errors; RESPONSE_ERRORS end + def sasl_ir_capable?; client.capable?("SASL-IR") end + def auth_capable?(mechanism); client.auth_capable?(mechanism) end + def drop_connection; client.logout! end + def drop_connection!; client.disconnect end + end + + end +end