From 4913bf47ce102ff213f39e826914459bd03b3ff9 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 9 Oct 2023 15:39:21 -0400 Subject: [PATCH] =?UTF-8?q?Use=20net-imap's=20SASL=20implementation=20?= =?UTF-8?q?=F0=9F=9A=A7[WIP]=F0=9F=9A=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds the `net-imap` as a default fallback for mechanisms that haven't otherwise been added. In this commit, the original implementation is still used by `#authenticate` for the `PLAIN`, `LOGIN`, and `CRAM-MD5` mechanisms. Every other mechanism supported by `net-imap` v0.4.0 is added here: * `ANONYMOUS` * `DIGEST-MD5` _(deprecated)_ * `EXTERNAL` * `OAUTHBEARER` * `SCRAM-SHA-1` and `SCRAM-SHA-256` * `XOAUTH` **TODO:** Ideally, `net-smtp` and `net-imap` should both depend on a shared `sasl` or `net-sasl` gem, rather than keep the SASL implementation inside one or the other. See https://github.com/ruby/net-imap/issues/23. --- lib/net/smtp.rb | 59 +++++++++++++-- lib/net/smtp/auth_sasl_client_adapter.rb | 72 +++++++++++++++++++ .../smtp/auth_sasl_compatibility_adapter.rb | 19 +++++ net-smtp.gemspec | 1 + test/net/smtp/test_smtp.rb | 10 --- 5 files changed, 146 insertions(+), 15 deletions(-) create mode 100644 lib/net/smtp/auth_sasl_client_adapter.rb create mode 100644 lib/net/smtp/auth_sasl_compatibility_adapter.rb diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 0321c0a..a8a4a96 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -175,8 +175,8 @@ class SMTPUnsupportedCommand < ProtocolError # # The Net::SMTP class supports the \SMTP extension for SASL Authentication # [RFC4954[https://www.rfc-editor.org/rfc/rfc4954.html]] and the following - # SASL mechanisms: +PLAIN+, +LOGIN+ _(deprecated)_, and +CRAM-MD5+ - # _(deprecated)_. + # SASL mechanisms: +ANONYMOUS+, +EXTERNAL+, +OAUTHBEARER+, +PLAIN+, + # +SCRAM-SHA-1+, +SCRAM-SHA-256+, and +XOAUTH2+. # # To use \SMTP authentication, pass extra arguments to # SMTP.start or SMTP#start. @@ -190,10 +190,38 @@ class SMTPUnsupportedCommand < ProtocolError # password: password, # authzid: "authorization identity"}) # optional # - # Support for other SASL mechanisms—such as +EXTERNAL+, +OAUTHBEARER+, - # +SCRAM-SHA-256+, and +XOAUTH2+—will be added in a future release. + # # SCRAM-SHA-256 + # Net::SMTP.start("your.smtp.server", 25, + # user: "authentication identity", secret: password, + # authtype: :scram_sha_256) + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :scram_sha_256, + # username: "authentication identity", + # password: password, + # authzid: "authorization identity"}) # optional + # + # # OAUTHBEARER + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :oauthbearer, + # oauth2_token: oauth2_access_token, + # authzid: "authorization identity", # optional + # host: "your.smtp.server", # optional + # port: 25}) # optional + # + # # XOAUTH2 + # Net::SMTP.start("your.smtp.server", 25, + # user: "username", secret: oauth2_access_token, authtype: :xoauth2) + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :xoauth2, + # username: "username", + # oauth2_token: oauth2_token}) + # + # # EXTERNAL + # Net::SMTP.start("your.smtp.server", 587, + # starttls: :always, ssl_context_params: ssl_ctx_params, + # authtype: "external") # - # The +LOGIN+ and +CRAM-MD5+ mechanisms are still available for backwards + # +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are still available for backwards # compatibility, but are deprecated and should be avoided. # class SMTP < Protocol @@ -1038,6 +1066,27 @@ def get_response(reqline) recv_response() end + # Returns a successful Response. + # + # Yields continuation data. + # + # This method may raise: + # + # * Net::SMTPAuthenticationError + # * Net::SMTPServerBusy + # * Net::SMTPSyntaxError + # * Net::SMTPFatalError + # * Net::SMTPUnknownError + def send_command_with_continuations(*args) + server_resp = get_response args.join(" ") + while server_resp.continue? + client_resp = yield server_resp.string.strip.split(nil, 2).last + server_resp = get_response client_resp + end + server_resp.success? or raise server_resp.exception_class.new(server_resp) + server_resp + end + private def validate_line(line) diff --git a/lib/net/smtp/auth_sasl_client_adapter.rb b/lib/net/smtp/auth_sasl_client_adapter.rb new file mode 100644 index 0000000..53f42b9 --- /dev/null +++ b/lib/net/smtp/auth_sasl_client_adapter.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "net/imap" + +module Net + class SMTP + SASL = Net::IMAP::SASL + + # Experimental + # + # Initialize with a block that runs a command, yielding for continuations. + class SASLClientAdapter < SASL::ClientAdapter + include SASL::ProtocolAdapters::SMTP + + RESPONSE_ERRORS = [ + SMTPAuthenticationError, + SMTPServerBusy, + SMTPSyntaxError, + SMTPFatalError, + ].freeze + + def initialize(...) + super + @command_proc ||= client.method(:send_command_with_continuations) + end + + # Translates +user+ to +username+ and +type+ kwarg to the first arg. + def authenticate(typearg = nil, *args, + type: nil, user: nil, + **kwargs, &block) + kwargs[:username] ||= user if user + type ||= typearg || DEFAULT_AUTH_TYPE + args, kwargs = backward_compatible_auth_args(type, *args, **kwargs) + super(type, *args, **kwargs) + rescue SMTPServerBusy, SMTPSyntaxError, SMTPFatalError => error + raise SMTPAuthenticationError.new(error.response) + rescue SASL::AuthenticationIncomplete => error + raise error.response.exception_class.new(error.response) + end + + def host; client.address end + def response_errors; RESPONSE_ERRORS end + def sasl_ir_capable?; true end + def drop_connection; client.finish end + def drop_connection!; client.finish end + + private + + # Temporarily adapt for differences between SMTP and Net::IMAP::SASL: + # + # * Net::IMAP::SASL adapters don't handle the +secret+ keyword. + # * Net::IMAP::SASL::LoginAdapter and CramMd5Adapter don't accept kwargs! + # + # These are reasonable changes and Net::IMAP::SASL can be updated. + def backward_compatible_auth_args(type, *args, secret: nil, **kwargs) + if secret + secret_type = type.match?(/\AX?OAUTH/i) ? :oauth2_token : :password + kwargs[secret_type] ||= secret + end + if type.match?(/\A(?:LOGIN|CRAM[-_]MD5)\z/i) + usernames = [kwargs.delete(:authcid), kwargs.delete(:username)] + secrets = [kwargs.delete(:password)] + args[0] ||= usernames.compact.first + args[1] ||= secrets.compact.first + end + [args, kwargs] + end + + end + + end +end diff --git a/lib/net/smtp/auth_sasl_compatibility_adapter.rb b/lib/net/smtp/auth_sasl_compatibility_adapter.rb new file mode 100644 index 0000000..a73ae77 --- /dev/null +++ b/lib/net/smtp/auth_sasl_compatibility_adapter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Net + class SMTP + + # Curries arguments to SASLAdapter.authenticate. + class AuthSASLCompatibilityAdapter + def initialize(mechanism) @mechanism = mechanism end + def check_args(...) SASL.authenticator(...) end + def new(smtp) @sasl_adapter = SASLClientAdapter.new(smtp); self end + def auth(...) @sasl_adapter.authenticate(@mechanism, ...) end + end + + Authenticator.auth_classes.default_proc = ->hash, mechanism { + hash[mechanism] = AuthSASLCompatibilityAdapter.new(mechanism) + } + + end +end diff --git a/net-smtp.gemspec b/net-smtp.gemspec index dfef600..74dec15 100644 --- a/net-smtp.gemspec +++ b/net-smtp.gemspec @@ -26,4 +26,5 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "net-protocol" + spec.add_dependency "net-imap", ">= 0.4.1" # experimental SASL support end diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index f7a38b8..d2fd19a 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -530,16 +530,6 @@ def test_start_auth_cram_md5 assert_raise Net::SMTPAuthenticationError do Net::SMTP.start('localhost', port, user: 'account', password: 'password', authtype: :cram_md5){} end - - port = fake_server_start(auth: 'CRAM-MD5') - smtp = Net::SMTP.new('localhost', port) - auth_cram_md5 = Net::SMTP::AuthCramMD5.new(smtp) - auth_cram_md5.define_singleton_method(:digest_class) { raise '"openssl" or "digest" library is required' } - Net::SMTP::AuthCramMD5.define_singleton_method(:new) { |_| auth_cram_md5 } - e = assert_raise RuntimeError do - smtp.start(user: 'account', password: 'password', authtype: :cram_md5){} - end - assert_equal('"openssl" or "digest" library is required', e.message) end def test_start_instance