From 92b67d40f141bd26ac98e5f69f1827b83a51b865 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. --- .github/workflows/test.yml | 2 +- lib/net/smtp.rb | 56 +++++++++++++++++-- lib/net/smtp/auth_sasl_client_adapter.rb | 42 ++++++++++++++ .../smtp/auth_sasl_compatibility_adapter.rb | 19 +++++++ net-smtp.gemspec | 2 + test/net/smtp/test_smtp.rb | 10 ---- 6 files changed, 114 insertions(+), 17 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/.github/workflows/test.yml b/.github/workflows/test.yml index a68d69d..39404c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,4 +19,4 @@ jobs: - name: Install dependencies run: bundle install - name: Run test - run: rake test + run: bundle exec rake test diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 2cb5d40..ca600ff 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. @@ -185,10 +185,38 @@ class SMTPUnsupportedCommand < ProtocolError # Net::SMTP.start('your.smtp.server', 25, # username: 'Your Account', secret: 'Your Password', authtype: :plain) # - # 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 # - # The +LOGIN+ and +CRAM-MD5+ mechanisms are still available for backwards + # # 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") + # + # +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are still available for backwards # compatibility, but are deprecated and should be avoided. # class SMTP < Protocol @@ -921,8 +949,9 @@ def auth(authtype = DEFAULT_AUTH_TYPE, *args, **kwargs, &block) private - def check_auth_args(type_arg = nil, *args, type: nil, **kwargs) + def check_auth_args(type_arg = nil, *args, type: nil, user: nil, **kwargs) type ||= type_arg || DEFAULT_AUTH_TYPE + kwargs[:username] ||= user if user klass = Authenticator.auth_class(type) or raise ArgumentError, "wrong authentication type #{type}" klass.check_args(*args, **kwargs) @@ -1037,6 +1066,21 @@ def get_response(reqline) recv_response() end + # Returns a successful Response. + # + # Yields continuation data and replies to the server using the block result. + # + # Raises an exception for any non-successful, non-continuation response. + 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..e61d951 --- /dev/null +++ b/lib/net/smtp/auth_sasl_client_adapter.rb @@ -0,0 +1,42 @@ +# 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 + + def authenticate(...) + super + 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 + 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..e6b5642 --- /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(@mechanism, ...) 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..147d68d 100644 --- a/net-smtp.gemspec +++ b/net-smtp.gemspec @@ -26,4 +26,6 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "net-protocol" + + spec.add_dependency "net-imap", ">= 0.4.2" # 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