diff --git a/lib/net/imap.rb b/lib/net/imap.rb index b8cb6fd91..dcef290dc 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -109,7 +109,7 @@ module Net # sending them. Special care should be taken to follow the #capabilities # requirements for #starttls, #login, and #authenticate. # - # See #capable?, #auth_capable, #capabilities, #auth_mechanisms to discover + # See #capable?, #auth_capable?, #capabilities, #auth_mechanisms to discover # server capabilities. For relevant capability requirements, see the # documentation on each \IMAP command. # @@ -1139,13 +1139,13 @@ def starttls(options = {}, verify = true) # the documentation for the specific mechanisms you are using: # # +ANONYMOUS+:: - # See AnonymousAuthenticator[Net::IMAP::SASL::AnonymousAuthenticator]. + # See AnonymousAuthenticator[rdoc-ref:Net::IMAP::SASL::AnonymousAuthenticator]. # # Allows the user to gain access to public services or resources without # authenticating or disclosing an identity. # # +EXTERNAL+:: - # See ExternalAuthenticator[Net::IMAP::SASL::ExternalAuthenticator]. + # See ExternalAuthenticator[rdoc-ref:Net::IMAP::SASL::ExternalAuthenticator]. # # Authenticates using already established credentials, such as a TLS # certificate or IPsec. diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index 7ea0edc71..3bcffb6b0 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -28,13 +28,13 @@ class IMAP # the documentation for the specific mechanisms you are using: # # +ANONYMOUS+:: - # See AnonymousAuthenticator[Net::IMAP::SASL::AnonymousAuthenticator]. + # See AnonymousAuthenticator. # # Allows the user to gain access to public services or resources without # authenticating or disclosing an identity. # # +EXTERNAL+:: - # See ExternalAuthenticator[Net::IMAP::SASL::ExternalAuthenticator]. + # See ExternalAuthenticator. # # Authenticates using already established credentials, such as a TLS # certificate or IPsec. diff --git a/lib/net/imap/sasl/anonymous_authenticator.rb b/lib/net/imap/sasl/anonymous_authenticator.rb index 7debff6a8..fbcfeeac8 100644 --- a/lib/net/imap/sasl/anonymous_authenticator.rb +++ b/lib/net/imap/sasl/anonymous_authenticator.rb @@ -9,6 +9,17 @@ module SASL # Net::IMAP#authenticate. class AnonymousAuthenticator + # An optional token sent for the +ANONYMOUS+ mechanism., up to 255 UTF-8 + # characters in length. + # + # If it contains an "@" sign, the message must be a valid email address + # (+addr-spec+ from RFC-2822[https://tools.ietf.org/html/rfc2822]). + # Email syntax is _not_ validated by AnonymousAuthenticator. + # + # Otherwise, it can be any UTF8 string which is permitted by the + # StringPrep::Trace profile. + attr_reader :anonymous_message + # :call-seq: # new(anonymous_message = "", **) -> authenticator # new(anonymous_message: "", **) -> authenticator @@ -21,7 +32,7 @@ class AnonymousAuthenticator # #anonymous_message is an optional message which is sent to the server. # It may be sent as a positional argument or as a keyword argument. # - # Any other keyword parameters are quietly ignored. + # Any other keyword arguments are silently ignored. def initialize(anon_msg = nil, anonymous_message: nil, **) message = (anonymous_message || anon_msg || "").to_str @anonymous_message = StringPrep::Trace.stringprep_trace message @@ -31,16 +42,6 @@ def initialize(anon_msg = nil, anonymous_message: nil, **) end end - # A token sent for the +ANONYMOUS+ mechanism. - # - # If it contains an "@" sign, the message must be a valid email address - # (+addr-spec+ from RFC-2822[https://tools.ietf.org/html/rfc2822]). - # Email syntax is _not_ validated by AnonymousAuthenticator. - # - # Otherwise, it can be any UTF8 string which is permitted by the - # StringPrep::Trace profile, up to 255 UTF-8 characters in length. - attr_reader :anonymous_message - # :call-seq: # initial_response? -> true # @@ -48,7 +49,9 @@ def initialize(anon_msg = nil, anonymous_message: nil, **) def initial_response?; true end # Returns #anonymous_message. - def process(_server_challenge_string) anonymous_message end + def process(_server_challenge_string) + anonymous_message + end end end diff --git a/lib/net/imap/sasl/cram_md5_authenticator.rb b/lib/net/imap/sasl/cram_md5_authenticator.rb index 3359f5a10..42935d3a0 100644 --- a/lib/net/imap/sasl/cram_md5_authenticator.rb +++ b/lib/net/imap/sasl/cram_md5_authenticator.rb @@ -14,13 +14,6 @@ # 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::SASL::CramMD5Authenticator - def process(challenge) - digest = hmac_md5(challenge, @password) - return @user + " " + digest - end - - private - def initialize(user, password, warn_deprecation: true, **_ignored) if warn_deprecation warn "WARNING: CRAM-MD5 mechanism is deprecated." # TODO: recommend SCRAM @@ -30,6 +23,13 @@ def initialize(user, password, warn_deprecation: true, **_ignored) @password = password end + def process(challenge) + digest = hmac_md5(challenge, @password) + return @user + " " + digest + end + + private + def hmac_md5(text, key) if key.length > 64 key = Digest::MD5.digest(key) diff --git a/lib/net/imap/sasl/digest_md5_authenticator.rb b/lib/net/imap/sasl/digest_md5_authenticator.rb index e8f29a0aa..8833a43a7 100644 --- a/lib/net/imap/sasl/digest_md5_authenticator.rb +++ b/lib/net/imap/sasl/digest_md5_authenticator.rb @@ -1,14 +1,72 @@ # frozen_string_literal: true # Net::IMAP authenticator for the "`DIGEST-MD5`" SASL mechanism type, specified -# in RFC2831(https://tools.ietf.org/html/rfc2831). See Net::IMAP#authenticate. +# in RFC-2831[https://tools.ietf.org/html/rfc2831]. See Net::IMAP#authenticate. # # == Deprecated # # "+DIGEST-MD5+" has been deprecated by -# {RFC6331}[https://tools.ietf.org/html/rfc6331] and should not be relied on for +# RFC-6331[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::SASL::DigestMD5Authenticator + STAGE_ONE = :stage_one + STAGE_TWO = :stage_two + private_constant :STAGE_ONE, :STAGE_TWO + + # Authentication identity: the identity that matches the #password. + # + # RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+. + # "Authentication identity" is the generic term used by + # RFC-4422[https://tools.ietf.org/html/rfc4422]. + # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate + # that to +authcid+. So +authcid+ is available as an alias for #username. + attr_reader :username + + # A password or passphrase that matches the #username. + # + # The +password+ will be used to create the response digest. + attr_reader :password + + # Authorization identity: an identity to act as or on behalf of. The identity + # form is application protocol specific. If not provided or left blank, the + # server derives an authorization identity from the authentication identity. + # The server is responsible for verifying the client's credentials and + # verifying that the identity it associates with the client's authentication + # identity is allowed to act as (or on behalf of) the authorization identity. + # + # For example, an administrator or superuser might take on another role: + # + # imap.authenticate "DIGEST-MD5", "root", ->{passwd}, authzid: "user" + # + attr_reader :authzid + + # :call-seq: + # new(username, password, authzid = nil) -> authenticator + # + # Creates an Authenticator for the "+DIGEST-MD5+" SASL mechanism. + # + # Called by Net::IMAP#authenticate and similar methods on other clients. + # + # ==== Parameters + # + # * #username — Identity whose #password is used. + # * #password — A password or passphrase associated with this #username. + # * #authzid ― Alternate identity to act as or on behalf of. Optional. + # * +warn_deprecation+ — Set to +false+ to silence the warning. + # + # See the documentation for each attribute for more details. + def initialize(username, password, authzid = nil, warn_deprecation: true) + if warn_deprecation + warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331." + # TODO: recommend SCRAM instead. + end + require "digest/md5" + require "strscan" + @username, @password, @authzid = username, password, authzid + @nc, @stage = {}, STAGE_ONE + end + + # Responds to server challenge in two stages. def process(challenge) case @stage when STAGE_ONE @@ -31,7 +89,7 @@ def process(challenge) response = { :nonce => sparams['nonce'], - :username => @user, + :username => @username, :realm => sparams['realm'], :cnonce => Digest::MD5.hexdigest("%.15f:%.15f:%d" % [Time.now.to_f, rand, Process.pid.to_s]), :'digest-uri' => 'imap/' + sparams['realm'], @@ -41,7 +99,7 @@ def process(challenge) :charset => sparams['charset'], } - response[:authzid] = @authname unless @authname.nil? + response[:authzid] = @authzid unless @authzid.nil? # now, the real thing a0 = Digest::MD5.digest( [ response.values_at(:username, :realm), @password ].join(':') ) @@ -74,23 +132,8 @@ def process(challenge) end end - def initialize(user, password, authname = nil, warn_deprecation: true) - if warn_deprecation - warn "WARNING: DIGEST-MD5 SASL mechanism was deprecated by RFC6331." - # TODO: recommend SCRAM instead. - end - require "digest/md5" - require "strscan" - @user, @password, @authname = user, password, authname - @nc, @stage = {}, STAGE_ONE - end - - private - STAGE_ONE = :stage_one - STAGE_TWO = :stage_two - def nc(nonce) if @nc.has_key? nonce @nc[nonce] = @nc[nonce] + 1 diff --git a/lib/net/imap/sasl/external_authenticator.rb b/lib/net/imap/sasl/external_authenticator.rb index e49af4731..12a0113a4 100644 --- a/lib/net/imap/sasl/external_authenticator.rb +++ b/lib/net/imap/sasl/external_authenticator.rb @@ -12,6 +12,12 @@ module SASL # established external to SASL, for example by TLS certificate or IPsec. class ExternalAuthenticator + # Authorization identity: an identity to act as or on behalf of. + # + # If not explicitly provided, the server defaults to using the identity + # that was authenticated by the external credentials. + attr_reader :authzid + # :call-seq: # new(authzid: nil, **) -> authenticator # @@ -30,12 +36,6 @@ def initialize(authzid: nil) end end - # Authorization identity: an identity to act as or on behalf of. - # - # If not explicitly provided, the server defaults to using the identity - # that was authenticated by the external credentials. - attr_reader :authzid - # :call-seq: # initial_response? -> true # diff --git a/lib/net/imap/sasl/login_authenticator.rb b/lib/net/imap/sasl/login_authenticator.rb index 9ee838b0b..11d508df5 100644 --- a/lib/net/imap/sasl/login_authenticator.rb +++ b/lib/net/imap/sasl/login_authenticator.rb @@ -18,20 +18,9 @@ # {draft-murchison-sasl-login}[https://www.iana.org/go/draft-murchison-sasl-login] # for both specification and deprecation. class Net::IMAP::SASL::LoginAuthenticator - def process(data) - case @state - when STATE_USER - @state = STATE_PASSWORD - return @user - when STATE_PASSWORD - return @password - end - end - - private - STATE_USER = :USER STATE_PASSWORD = :PASSWORD + private_constant :STATE_USER, :STATE_PASSWORD def initialize(user, password, warn_deprecation: true, **_ignored) if warn_deprecation @@ -42,4 +31,13 @@ def initialize(user, password, warn_deprecation: true, **_ignored) @state = STATE_USER end + def process(data) + case @state + when STATE_USER + @state = STATE_PASSWORD + return @user + when STATE_PASSWORD + return @password + end + end end diff --git a/lib/net/imap/sasl/oauthbearer_authenticator.rb b/lib/net/imap/sasl/oauthbearer_authenticator.rb index d99fb2278..c23c35f96 100644 --- a/lib/net/imap/sasl/oauthbearer_authenticator.rb +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -14,35 +14,6 @@ module SASL class OAuthAuthenticator include GS2Header - # Creates an RFC7628[https://tools.ietf.org/html/rfc7628] OAuth - # authenticator. - # - # === Options - # - # See child classes for required configuration parameter(s). The - # following parameters are all optional, but protocols or servers may - # add requirements for #authzid, #host, #port, or any other parameter. - # - # * #authzid ― Identity to act as or on behalf of. - # * #host — Hostname to which the client connected. - # * #port — Service port to which the client connected. - # * #mthd — HTTP method - # * #path — HTTP path data - # * #post — HTTP post data - # * #qs — HTTP query string - # - def initialize(authzid: nil, host: nil, port: nil, - mthd: nil, path: nil, post: nil, qs: nil, **) - @authzid = authzid - @host = host - @port = port - @mthd = mthd - @path = path - @post = post - @qs = qs - @done = false - end - # Authorization identity: an identity to act as or on behalf of. # # If no explicit authorization identity is provided, it is usually @@ -73,6 +44,45 @@ def initialize(authzid: nil, host: nil, port: nil, # this may hold information about the failure reason, as JSON. attr_reader :last_server_response + # Creates an RFC7628[https://tools.ietf.org/html/rfc7628] OAuth + # authenticator. + # + # === Options + # + # See child classes for required configuration parameter(s). The + # following parameters are all optional, but protocols or servers may + # add requirements for #authzid, #host, #port, or any other parameter. + # + # * #authzid ― Identity to act as or on behalf of. + # * #host — Hostname to which the client connected. + # * #port — Service port to which the client connected. + # * #mthd — HTTP method + # * #path — HTTP path data + # * #post — HTTP post data + # * #qs — HTTP query string + # + def initialize(authzid: nil, host: nil, port: nil, + mthd: nil, path: nil, post: nil, qs: nil, **) + @authzid = authzid + @host = host + @port = port + @mthd = mthd + @path = path + @post = post + @qs = qs + @done = false + end + + # The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1] + # formatted response. + def initial_client_response + kv_pairs = { + host: host, port: port, mthd: mthd, path: path, post: post, qs: qs, + auth: authorization, # authorization is implemented by subclasses + }.compact + [gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1") + end + # Returns initial_client_response the first time, then "^A". def process(data) @last_server_response = data @@ -87,16 +97,6 @@ def process(data) # does *not* indicate success. def done?; @done end - # The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1] - # formatted response. - def initial_client_response - kv_pairs = { - host: host, port: port, mthd: mthd, path: path, post: post, qs: qs, - auth: authorization, # authorization is implemented by subclasses - }.compact - [gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1") - end - # Value of the HTTP Authorization header # # Implemented by subclasses. @@ -116,6 +116,9 @@ def authorization; raise "must be implemented by subclass" end # the bearer token. class OAuthBearerAuthenticator < OAuthAuthenticator + # An OAuth2 bearer token, generally the access token. + attr_reader :oauth2_token + # :call-seq: # new(oauth2_token, **options) -> authenticator # new(oauth2_token:, **options) -> authenticator @@ -145,9 +148,6 @@ def initialize(oauth2_token_arg = nil, oauth2_token: nil, **args, &blk) raise ArgumentError, "missing oauth2_token" end - # An OAuth2 bearer token, generally the access token. - attr_reader :oauth2_token - # :call-seq: # initial_response? -> true # diff --git a/lib/net/imap/sasl/plain_authenticator.rb b/lib/net/imap/sasl/plain_authenticator.rb index 18d763bb2..7b2de294d 100644 --- a/lib/net/imap/sasl/plain_authenticator.rb +++ b/lib/net/imap/sasl/plain_authenticator.rb @@ -1,35 +1,55 @@ # frozen_string_literal: true # Authenticator for the "+PLAIN+" SASL mechanism, specified in -# RFC4616[https://tools.ietf.org/html/rfc4616]. See Net::IMAP#authenticate. +# RFC-4616[https://tools.ietf.org/html/rfc4616]. See Net::IMAP#authenticate. # # +PLAIN+ authentication sends the password in cleartext. -# RFC3501[https://tools.ietf.org/html/rfc3501] encourages servers to disable +# RFC-3501[https://tools.ietf.org/html/rfc3501] encourages servers to disable # cleartext authentication until after TLS has been negotiated. -# RFC8314[https://tools.ietf.org/html/rfc8314] recommends TLS version 1.2 or +# RFC-8314[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::SASL::PlainAuthenticator - def initial_response?; true end + NULL = -"\0".b + private_constant :NULL - def process(data) - return "#@authzid\0#@username\0#@password" - end + # Authentication identity: the identity that matches the #password. + # + # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate + # this to +authcid+. + attr_reader :username - # :nodoc: - NULL = -"\0".b + # A password or passphrase that matches the #username. + attr_reader :password - private + # Authorization identity: an identity to act as or on behalf of. The identity + # form is application protocol specific. If not provided or left blank, the + # server derives an authorization identity from the authentication identity. + # The server is responsible for verifying the client's credentials and + # verifying that the identity it associates with the client's authentication + # identity is allowed to act as (or on behalf of) the authorization identity. + # + # For example, an administrator or superuser might take on another role: + # + # imap.authenticate "PLAIN", "root", passwd, authzid: "user" + # + attr_reader :authzid - # +username+ is the authentication identity, the identity whose +password+ is - # used. +username+ is referred to as +authcid+ by - # RFC4616[https://tools.ietf.org/html/rfc4616]. + # :call-seq: + # new(username, password, authzid: nil) -> authenticator + # + # Creates an Authenticator for the "+PLAIN+" SASL mechanism. # - # +authzid+ is the authorization identity (identity to act as). It can - # usually be left blank. When +authzid+ is left blank (nil or empty string) - # the server will derive an identity from the credentials and use that as the - # authorization identity. + # Called by Net::IMAP#authenticate and similar methods on other clients. + # + # === Parameters + # + # * #username ― Identity whose +password+ is used. + # * #password ― Password or passphrase associated with this username+. + # * #authzid ― Alternate identity to act as or on behalf of. Optional. + # + # See attribute documentation for more details. def initialize(username, password, authzid: nil) raise ArgumentError, "username contains NULL" if username&.include?(NULL) raise ArgumentError, "password contains NULL" if password&.include?(NULL) @@ -39,4 +59,15 @@ def initialize(username, password, authzid: nil) @authzid = authzid end + # :call-seq: + # initial_response? -> true + # + # +PLAIN+ can send an initial client response. + def initial_response?; true end + + # Responds with the client's credentials. + def process(data) + return "#@authzid\0#@username\0#@password" + end + end diff --git a/lib/net/imap/sasl/xoauth2_authenticator.rb b/lib/net/imap/sasl/xoauth2_authenticator.rb index 9f5a99f82..00dc397ec 100644 --- a/lib/net/imap/sasl/xoauth2_authenticator.rb +++ b/lib/net/imap/sasl/xoauth2_authenticator.rb @@ -1,22 +1,73 @@ # frozen_string_literal: true +# Authenticator for the "+XOAUTH2+" SASL mechanism. This mechanism was +# originally created for GMail and widely adopted by hosted email providers. +# +XOAUTH2+ has been documented by +# Google[https://developers.google.com/gmail/imap/xoauth2-protocol] and +# Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth]. +# +# This mechanism requires an OAuth2 +access_token+ which has been authorized +# with the appropriate OAuth2 scopes to access IMAP. These scopes are not +# standardized---consult each email service provider's documentation. +# +# Although this mechanism was never standardized and has been obsoleted by +# "+OAUTHBEARER+", it is still very widely supported. +# +# See Net::IMAP::SASL:: OAuthBearerAuthenticator. class Net::IMAP::SASL::XOAuth2Authenticator + # It is unclear from {Google's original XOAUTH2 + # documentation}[https://developers.google.com/gmail/imap/xoauth2-protocol], + # whether "User" refers to the authentication identity (+authcid+) or the + # authorization identity (+authzid+). It appears to behave as +authzid+. + # + # {Microsoft's documentation for shared + # mailboxes}[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2-authentication-for-shared-mailboxes-in-office-365] + # clearly indicate that the Office 365 server interprets it as the + # authorization identity. + attr_reader :username + + # An OAuth2 access token which has been authorized with the appropriate OAuth2 + # scopes to use the service for #username. + attr_reader :oauth2_token + + # :call-seq: + # :call-seq: + # new(username, oauth2_token) -> authenticator + # + # Creates an Authenticator for the "+XOAUTH2+" SASL mechanism, as specified by + # Google[https://developers.google.com/gmail/imap/xoauth2-protocol], + # Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth] + # and Yahoo[https://senders.yahooinc.com/developer/documentation]. + # + # === Properties + # + # * #username --- the username for the account being accessed. + # * #oauth2_token --- An OAuth2.0 access token which is authorized to access + # the service for #username. + # + # See the documentation for each attribute for more details. + def initialize(username, oauth2_token) + @username = username + @oauth2_token = oauth2_token + end + + # :call-seq: + # initial_response? -> true + # + # +PLAIN+ can send an initial client response. def initial_response?; true end + # Returns the XOAUTH2 formatted response, which combines the +username+ + # with the +oauth2_token+. def process(_data) - build_oauth2_string(@user, @oauth2_token) + build_oauth2_string(@username, @oauth2_token) end private - def initialize(user, oauth2_token) - @user = user - @oauth2_token = oauth2_token - end - - def build_oauth2_string(user, oauth2_token) - format("user=%s\1auth=Bearer %s\1\1", user, oauth2_token) + def build_oauth2_string(username, oauth2_token) + format("user=%s\1auth=Bearer %s\1\1", username, oauth2_token) end end diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb index 79ce20762..8e63c883f 100644 --- a/test/net/imap/test_imap_authenticators.rb +++ b/test/net/imap/test_imap_authenticators.rb @@ -88,7 +88,7 @@ def test_xoauth2_authenticator_matches_mechanism assert_kind_of(Net::IMAP::SASL::XOAuth2Authenticator, xoauth2("user", "tok")) end - def test_xoauth2 + def test_xoauth2_response assert_equal( "user=username\1auth=Bearer token\1\1", xoauth2("username", "token").process(nil)