diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 754d3282..43f4b477 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1015,7 +1015,7 @@ def starttls(options = {}, verify = true) # +PLAIN+:: See PlainAuthenticator. # Login using clear-text username and password. # - # +XOAUTH2+:: See XOauth2Authenticator. + # +XOAUTH2+:: See XOAuth2Authenticator. # Login using a username and OAuth2 access token. # Non-standard and obsoleted by +OAUTHBEARER+, but widely # supported. @@ -1074,10 +1074,7 @@ def starttls(options = {}, verify = true) # completes. If the TaggedResponse to #authenticate includes updated # capabilities, they will be cached. def authenticate(mechanism, *creds, sasl_ir: true, **props, &callback) - authenticator = self.class.authenticator(mechanism, - *creds, - **props, - &callback) + authenticator = SASL.authenticator(mechanism, *creds, **props, &callback) cmdargs = ["AUTHENTICATE", mechanism] if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) && SASL.initial_response?(authenticator) diff --git a/lib/net/imap/authenticators.rb b/lib/net/imap/authenticators.rb index 3104a36c..8ecb3851 100644 --- a/lib/net/imap/authenticators.rb +++ b/lib/net/imap/authenticators.rb @@ -1,64 +1,37 @@ # frozen_string_literal: true -# Registry for SASL authenticators used by Net::IMAP. +# Backward compatible delegators from Net::IMAP to Net::IMAP::SASL. module Net::IMAP::Authenticators - # Adds an authenticator for Net::IMAP#authenticate to use. +mechanism+ is the - # {SASL mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml] - # implemented by +authenticator+ (for instance, "PLAIN"). - # - # The +authenticator+ must respond to +#new+ (or #call), receiving the - # authenticator configuration and return a configured authentication session. - # The authenticator session must respond to +#process+, receiving the server's - # challenge and returning the client's response. - # - # See PlainAuthenticator, XOauth2Authenticator, and DigestMD5Authenticator for - # examples. - def add_authenticator(auth_type, authenticator) - authenticators[auth_type] = authenticator + # Deprecated. Use Net::IMAP::SASL.add_authenticator instead. + def add_authenticator(...) + warn( + "%s.%s is deprecated. Use %s.%s instead." % [ + Net::IMAP, __method__, Net::IMAP::SASL, __method__ + ], + uplevel: 1 + ) + Net::IMAP::SASL.add_authenticator(...) end - # :call-seq: - # authenticator(mechanism, ...) -> authenticator - # authenticator(mech, *creds, **props) {|prop, auth| val } -> authenticator - # authenticator(mechanism, authnid, creds, authzid=nil) -> authenticator - # authenticator(mechanism, **properties) -> authenticator - # authenticator(mechanism) {|propname, authctx| value } -> authenticator - # - # Builds a new authentication session context for +mechanism+. - # - # [Note] - # This method is intended for internal use by connection protocol code only. - # Protocol client users should see refer to their client's documentation, - # e.g. Net::IMAP#authenticate for Net::IMAP. - # - # The call signatures documented for this method are recommendations for - # authenticator implementors. All arguments (other than +mechanism+) are - # forwarded to the registered authenticator's +#new+ (or +#call+) method, and - # each authenticator must document its own arguments. - # - # The returned object represents a single authentication exchange and must - # not be reused for multiple authentication attempts. - def authenticator(mechanism, ...) - auth = authenticators.fetch(mechanism.upcase) do - raise ArgumentError, 'unknown auth type - "%s"' % mechanism - end - auth.respond_to?(:new) ? auth.new(...) : auth.call(...) - end - - private - - def authenticators - @authenticators ||= {} + # Deprecated. Use Net::IMAP::SASL.authenticator instead. + def authenticator(...) + warn( + "%s.%s is deprecated. Use %s.%s instead." % [ + Net::IMAP, __method__, Net::IMAP::SASL, __method__ + ], + uplevel: 1 + ) + Net::IMAP::SASL.authenticator(...) end + Net::IMAP.extend self end -Net::IMAP.extend Net::IMAP::Authenticators +class Net::IMAP + PlainAuthenticator = SASL::PlainAuthenticator # :nodoc: + deprecate_constant :PlainAuthenticator -require_relative "authenticators/plain" - -require_relative "authenticators/login" -require_relative "authenticators/cram_md5" -require_relative "authenticators/digest_md5" -require_relative "authenticators/xoauth2" + XOauth2Authenticator = SASL::XOAuth2Authenticator # :nodoc: + deprecate_constant :XOauth2Authenticator +end diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index eca77bdd..6f2db923 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -32,6 +32,27 @@ module SASL autoload :ProhibitedCodepoint, sasl_stringprep_rb autoload :BidiStringError, sasl_stringprep_rb + sasl_dir = File.expand_path("sasl", __dir__) + autoload :Authenticators, "#{sasl_dir}/authenticators" + + autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator" + autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator" + + autoload :CramMD5Authenticator, "#{sasl_dir}/cram_md5_authenticator" + autoload :DigestMD5Authenticator, "#{sasl_dir}/digest_md5_authenticator" + autoload :LoginAuthenticator, "#{sasl_dir}/login_authenticator" + + # Returns the default global SASL::Authenticators instance. + def self.authenticators + @authenticators ||= Authenticators.new(use_defaults: true) + end + + # Delegates to ::authenticators. See Authenticators#authenticator. + def self.authenticator(...) authenticators.authenticator(...) end + + # Delegates to ::authenticators. See Authenticators#add_authenticator. + def self.add_authenticator(...) authenticators.add_authenticator(...) end + module_function # See Net::IMAP::StringPrep::SASLprep#saslprep. diff --git a/lib/net/imap/sasl/authenticators.rb b/lib/net/imap/sasl/authenticators.rb new file mode 100644 index 00000000..779a8018 --- /dev/null +++ b/lib/net/imap/sasl/authenticators.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Net::IMAP::SASL + + # Registry for SASL authenticators + # + # Registered authenticators must respond to +#new+ or +#call+ (e.g. a class or + # a proc), receiving any credentials and options and returning an + # authenticator instance. The returned object represents a single + # authentication exchange and must not be reused for multiple + # authentication attempts. + # + # An authenticator instance object must respond to +#process+, receiving the + # server's challenge and returning the client's response. Optionally, it may + # also respond to +#initial_response?+ and +#done?+. When + # +#initial_response?+ returns +true+, +#process+ may be called the first + # time with +nil+. When +#done?+ returns +false+, the exchange is incomplete + # and an exception should be raised if the exchange terminates prematurely. + # + # See the source for PlainAuthenticator, XOAuth2Authenticator, and + # ScramSHA1Authenticator for examples. + class Authenticators + + # Create a new Authenticators registry. + # + # This class is usually not instantiated directly. Use SASL.authenticators + # to reuse the default global registry. + # + # By default, the registry will be empty--without any registrations. When + # +add_defaults+ is +true+, authenticators for all standard mechanisms will + # be registered. + # + def initialize(use_defaults: false) + @authenticators = {} + if use_defaults + add_authenticator "PLAIN", PlainAuthenticator + add_authenticator "XOAUTH2", XOAuth2Authenticator + add_authenticator "LOGIN", LoginAuthenticator # deprecated + add_authenticator "CRAM-MD5", CramMD5Authenticator # deprecated + add_authenticator "DIGEST-MD5", DigestMD5Authenticator # deprecated + end + end + + # Returns the names of all registered SASL mechanisms. + def names; @authenticators.keys end + + # :call-seq: + # add_authenticator(mechanism) + # add_authenticator(mechanism, authenticator_class) + # add_authenticator(mechanism, authenticator_proc) + # + # Registers an authenticator for #authenticator to use. +mechanism+ is the + # name of the + # {SASL mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml] + # implemented by +authenticator_class+ (for instance, "PLAIN"). + # + # If +mechanism+ refers to an existing authenticator, a warning will be + # printed and the old authenticator will be replaced. + # + # When only a single argument is given, the authenticator class will be + # lazily loaded from Net::IMAP::SASL::#{name}Authenticator (case is + # preserved and non-alphanumeric characters are removed.. + def add_authenticator(auth_type, authenticator) + @authenticators[auth_type] = authenticator + end + + # :call-seq: + # authenticator(mechanism, ...) -> auth_session + # + # Builds an authenticator instance using the authenticator registered to + # +mechanism+. The returned object represents a single authentication + # exchange and must not be reused for multiple authentication + # attempts. + # + # All arguments (except +mechanism+) are forwarded to the registered + # authenticator's +#new+ or +#call+ method. Each authenticator must + # document its own arguments. + # + # [Note] + # This method is intended for internal use by connection protocol code + # 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) do + raise ArgumentError, 'unknown auth type - "%s"' % mechanism + end + auth.respond_to?(:new) ? auth.new(...) : auth.call(...) + end + + end + +end diff --git a/lib/net/imap/authenticators/cram_md5.rb b/lib/net/imap/sasl/cram_md5_authenticator.rb similarity index 94% rename from lib/net/imap/authenticators/cram_md5.rb rename to lib/net/imap/sasl/cram_md5_authenticator.rb index ed602655..3359f5a1 100644 --- a/lib/net/imap/authenticators/cram_md5.rb +++ b/lib/net/imap/sasl/cram_md5_authenticator.rb @@ -13,7 +13,7 @@ # Additionally, RFC8314[https://tools.ietf.org/html/rfc8314] discourage the use # of cleartext and recommends TLS version 1.2 or greater be used for all # traffic. With TLS +CRAM-MD5+ is okay, but so is +PLAIN+ -class Net::IMAP::CramMD5Authenticator +class Net::IMAP::SASL::CramMD5Authenticator def process(challenge) digest = hmac_md5(challenge, @password) return @user + " " + digest @@ -47,5 +47,4 @@ def hmac_md5(text, key) return Digest::MD5.hexdigest(k_opad + digest) end - Net::IMAP.add_authenticator "CRAM-MD5", self end diff --git a/lib/net/imap/authenticators/digest_md5.rb b/lib/net/imap/sasl/digest_md5_authenticator.rb similarity index 97% rename from lib/net/imap/authenticators/digest_md5.rb rename to lib/net/imap/sasl/digest_md5_authenticator.rb index 86aae605..e8f29a0a 100644 --- a/lib/net/imap/authenticators/digest_md5.rb +++ b/lib/net/imap/sasl/digest_md5_authenticator.rb @@ -8,7 +8,7 @@ # "+DIGEST-MD5+" has been deprecated by # {RFC6331}[https://tools.ietf.org/html/rfc6331] and should not be relied on for # security. It is included for compatibility with existing servers. -class Net::IMAP::DigestMD5Authenticator +class Net::IMAP::SASL::DigestMD5Authenticator def process(challenge) case @stage when STAGE_ONE @@ -111,5 +111,4 @@ def qdval(k, v) end end - Net::IMAP.add_authenticator "DIGEST-MD5", self end diff --git a/lib/net/imap/authenticators/login.rb b/lib/net/imap/sasl/login_authenticator.rb similarity index 94% rename from lib/net/imap/authenticators/login.rb rename to lib/net/imap/sasl/login_authenticator.rb index 0096cbaf..9ee838b0 100644 --- a/lib/net/imap/authenticators/login.rb +++ b/lib/net/imap/sasl/login_authenticator.rb @@ -17,7 +17,7 @@ # compatibility with existing servers. See # {draft-murchison-sasl-login}[https://www.iana.org/go/draft-murchison-sasl-login] # for both specification and deprecation. -class Net::IMAP::LoginAuthenticator +class Net::IMAP::SASL::LoginAuthenticator def process(data) case @state when STATE_USER @@ -42,5 +42,4 @@ def initialize(user, password, warn_deprecation: true, **_ignored) @state = STATE_USER end - Net::IMAP.add_authenticator "LOGIN", self end diff --git a/lib/net/imap/authenticators/plain.rb b/lib/net/imap/sasl/plain_authenticator.rb similarity index 95% rename from lib/net/imap/authenticators/plain.rb rename to lib/net/imap/sasl/plain_authenticator.rb index e7fe07c4..18d763bb 100644 --- a/lib/net/imap/authenticators/plain.rb +++ b/lib/net/imap/sasl/plain_authenticator.rb @@ -9,7 +9,7 @@ # RFC8314[https://tools.ietf.org/html/rfc8314] recommends TLS version 1.2 or # greater be used for all traffic, and deprecate cleartext access ASAP. +PLAIN+ # can be secured by TLS encryption. -class Net::IMAP::PlainAuthenticator +class Net::IMAP::SASL::PlainAuthenticator def initial_response?; true end @@ -39,5 +39,4 @@ def initialize(username, password, authzid: nil) @authzid = authzid end - Net::IMAP.add_authenticator "PLAIN", self end diff --git a/lib/net/imap/authenticators/xoauth2.rb b/lib/net/imap/sasl/xoauth2_authenticator.rb similarity index 81% rename from lib/net/imap/authenticators/xoauth2.rb rename to lib/net/imap/sasl/xoauth2_authenticator.rb index 67b6e5aa..9f5a99f8 100644 --- a/lib/net/imap/authenticators/xoauth2.rb +++ b/lib/net/imap/sasl/xoauth2_authenticator.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Net::IMAP::XOauth2Authenticator +class Net::IMAP::SASL::XOAuth2Authenticator def initial_response?; true end @@ -19,5 +19,4 @@ def build_oauth2_string(user, oauth2_token) format("user=%s\1auth=Bearer %s\1\1", user, oauth2_token) end - Net::IMAP.add_authenticator 'XOAUTH2', self end diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb index cdf32e0a..93c85817 100644 --- a/test/net/imap/test_imap_authenticators.rb +++ b/test/net/imap/test_imap_authenticators.rb @@ -5,14 +5,20 @@ class IMAPAuthenticatorsTest < Test::Unit::TestCase + def test_net_imap_authenticator_deprecated + assert_warn(/Net::IMAP\.authenticator .+deprecated./) do + Net::IMAP.authenticator("PLAIN", "user", "pass") + end + end + # ---------------------- # PLAIN # ---------------------- - def plain(...) Net::IMAP.authenticator("PLAIN", ...) end + def plain(...) Net::IMAP::SASL.authenticator("PLAIN", ...) end def test_plain_authenticator_matches_mechanism - assert_kind_of(Net::IMAP::PlainAuthenticator, plain("user", "pass")) + assert_kind_of(Net::IMAP::SASL::PlainAuthenticator, plain("user", "pass")) end def test_plain_supports_initial_response @@ -36,10 +42,10 @@ def test_plain_no_null_chars # XOAUTH2 # ---------------------- - def xoauth2(...) Net::IMAP.authenticator("XOAUTH2", ...) end + def xoauth2(...) Net::IMAP::SASL.authenticator("XOAUTH2", ...) end def test_xoauth2_authenticator_matches_mechanism - assert_kind_of(Net::IMAP::XOauth2Authenticator, xoauth2("user", "pass")) + assert_kind_of(Net::IMAP::SASL::XOAuth2Authenticator, xoauth2("user", "tok")) end def test_xoauth2 @@ -59,13 +65,13 @@ def test_xoauth2_supports_initial_response # ---------------------- def login(*args, warn_deprecation: false, **kwargs, &block) - Net::IMAP.authenticator( + Net::IMAP::SASL.authenticator( "LOGIN", *args, warn_deprecation: warn_deprecation, **kwargs, &block ) end def test_login_authenticator_matches_mechanism - assert_kind_of(Net::IMAP::LoginAuthenticator, login("n", "p")) + assert_kind_of(Net::IMAP::SASL::LoginAuthenticator, login("n", "p")) end def test_login_does_not_support_initial_response @@ -74,7 +80,7 @@ def test_login_does_not_support_initial_response def test_login_authenticator_deprecated assert_warn(/LOGIN.+deprecated.+PLAIN/) do - Net::IMAP.authenticator("LOGIN", "user", "pass") + Net::IMAP::SASL.authenticator("LOGIN", "user", "pass") end end @@ -89,13 +95,13 @@ def test_login_responses # ---------------------- def cram_md5(*args, warn_deprecation: false, **kwargs, &block) - Net::IMAP.authenticator( + Net::IMAP::SASL.authenticator( "CRAM-MD5", *args, warn_deprecation: warn_deprecation, **kwargs, &block ) end def test_cram_md5_authenticator_matches_mechanism - assert_kind_of(Net::IMAP::CramMD5Authenticator, cram_md5("n", "p")) + assert_kind_of(Net::IMAP::SASL::CramMD5Authenticator, cram_md5("n", "p")) end def test_cram_md5_does_not_support_initial_response @@ -104,7 +110,7 @@ def test_cram_md5_does_not_support_initial_response def test_cram_md5_authenticator_deprecated assert_warn(/CRAM-MD5.+deprecated./) do - Net::IMAP.authenticator("CRAM-MD5", "user", "pass") + Net::IMAP::SASL.authenticator("CRAM-MD5", "user", "pass") end end @@ -119,18 +125,19 @@ def test_cram_md5_authenticator # ---------------------- def digest_md5(*args, warn_deprecation: false, **kwargs, &block) - Net::IMAP.authenticator( + Net::IMAP::SASL.authenticator( "DIGEST-MD5", *args, warn_deprecation: warn_deprecation, **kwargs, &block ) end def test_digest_md5_authenticator_matches_mechanism - assert_kind_of(Net::IMAP::DigestMD5Authenticator, digest_md5("n", "p", "z")) + assert_kind_of(Net::IMAP::SASL::DigestMD5Authenticator, + digest_md5("n", "p", "z")) end def test_digest_md5_authenticator_deprecated assert_warn(/DIGEST-MD5.+deprecated.+RFC6331/) do - Net::IMAP.authenticator("DIGEST-MD5", "user", "pass") + Net::IMAP::SASL.authenticator("DIGEST-MD5", "user", "pass") end end diff --git a/test/net/imap/test_regexps.rb b/test/net/imap/test_regexps.rb index 2aa7d462..83d1db24 100644 --- a/test/net/imap/test_regexps.rb +++ b/test/net/imap/test_regexps.rb @@ -25,7 +25,11 @@ class IMAPRegexpsTest < Test::Unit::TestCase RegexpCollector.new( Net::IMAP, exclude_map: { - Net::IMAP => %i[BodyTypeAttachment BodyTypeExtension], # deprecated + Net::IMAP => %i[ + BodyTypeAttachment BodyTypeExtension + PlainAuthenticator + XOauth2Authenticator + ], # deprecated }, ).to_h )