Skip to content

Commit

Permalink
Update SASL: simplify, compatibility, #start (WIP)
Browse files Browse the repository at this point in the history
Not all mechanisms take two parameters, and not all of them have a
username or secret.  For maximum flexibility, all parameters should be
forwarded to the authenticator.

Support arbitrary authenticators from start.
  • Loading branch information
nevans committed Oct 8, 2023
1 parent 4ebe970 commit 887035d
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 71 deletions.
147 changes: 107 additions & 40 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,21 +171,55 @@ class SMTPUnsupportedCommand < ProtocolError
#
# === SMTP Authentication
#
# The Net::SMTP class supports three authentication schemes;
# PLAIN, LOGIN and CRAM MD5. (SMTP Authentication: [RFC2554])
# The Net::SMTP class supports several authentication schemes;
# ({SMTP Authentication: [RFC4956]}[https://www.rfc-editor.org/rfc/rfc4954.html])
# +ANONYMOUS+, +EXTERNAL+, +OAUTHBEARER+, +PLAIN+, +SCRAM-SHA-1+,
# +SCRAM-SHA-256+, and +XOAUTH2+. +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are
# deprecated and should be avoided, but are still available for backwards
# compatibility. <em>Using a deprecated authentication mechanisms will print
# a warning.</em>
#
# To use SMTP authentication, pass extra arguments to
# SMTP.start or SMTP#start.
#
# # PLAIN
# Net::SMTP.start('your.smtp.server', 25,
# user: 'Your Account', secret: 'Your Password', authtype: :plain)
# # LOGIN
# Net::SMTP.start('your.smtp.server', 25,
# user: 'Your Account', secret: 'Your Password', authtype: :login)
#
# # CRAM MD5
# Net::SMTP.start('your.smtp.server', 25,
# user: 'Your Account', secret: 'Your Password', authtype: :cram_md5)
# Net::SMTP.start("your.smtp.server", 25,
# user: "authentication identity", secret: password,
# authtype: :plain)
# Net::SMTP.start("your.smtp.server", 25,
# auth: {type: :plain,
# username: "authentication identity",
# password: password,
# authzid: "authorization identity"}) # optional
# # 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_context_params,
# authtype: "external")
#
class SMTP < Protocol
VERSION = "0.4.0"
Expand Down Expand Up @@ -452,6 +486,7 @@ def debug_output=(arg)

#
# :call-seq:
# start(address, port = nil, helo: 'localhost', auth: nil, tls: false, starttls: :auto, tls_verify: true, tls_hostname: nil, ssl_context_params: nil) { |smtp| ... }
# start(address, port = nil, helo: 'localhost', user: nil, secret: nil, authtype: nil, tls: false, starttls: :auto, tls_verify: true, tls_hostname: nil, ssl_context_params: nil) { |smtp| ... }
# start(address, port = nil, helo = 'localhost', user = nil, secret = nil, authtype = nil) { |smtp| ... }
#
Expand Down Expand Up @@ -518,6 +553,7 @@ def debug_output=(arg)
#
def SMTP.start(address, port = nil, *args, helo: nil,
user: nil, secret: nil, password: nil, authtype: nil,
auth: nil,
tls: false, starttls: :auto,
tls_verify: true, tls_hostname: nil, ssl_context_params: nil,
&block)
Expand All @@ -526,7 +562,7 @@ def SMTP.start(address, port = nil, *args, helo: nil,
user ||= args[1]
secret ||= password || args[2]
authtype ||= args[3]
new(address, port, tls: tls, starttls: starttls, tls_verify: tls_verify, tls_hostname: tls_hostname, ssl_context_params: ssl_context_params).start(helo: helo, user: user, secret: secret, authtype: authtype, &block)
new(address, port, tls: tls, starttls: starttls, tls_verify: tls_verify, tls_hostname: tls_hostname, ssl_context_params: ssl_context_params).start(helo: helo, user: user, secret: secret, authtype: authtype, auth: auth, &block)
end

# +true+ if the SMTP session has been started.
Expand All @@ -538,6 +574,7 @@ def started?
# :call-seq:
# start(helo: 'localhost', user: nil, secret: nil, authtype: nil) { |smtp| ... }
# start(helo = 'localhost', user = nil, secret = nil, authtype = nil) { |smtp| ... }
# start(helo = 'localhost', authtype: nil, auth: {...}) { |smtp| ... }
#
# Opens a TCP connection and starts the SMTP session.
#
Expand All @@ -546,11 +583,10 @@ def started?
# +helo+ is the _HELO_ _domain_ that you'll dispatch mails from; see
# the discussion in the overview notes.
#
# If both of +user+ and +secret+ are given, SMTP authentication
# will be attempted using the AUTH command. +authtype+ specifies
# the type of authentication to attempt; it must be one of
# :login, :plain, and :cram_md5. See the notes on SMTP Authentication
# in the overview.
# If either +auth+ or +user+ and +secret+ are given, SMTP authentication
# will be attempted using the AUTH command. +authtype+ specifies the type
# of authentication to attempt; it must be one of :login, :plain, and
# :cram_md5. See the notes on SMTP Authentication in the overview.
#
# === Block Usage
#
Expand Down Expand Up @@ -589,12 +625,14 @@ def started?
# * Net::ReadTimeout
# * IOError
#
def start(*args, helo: nil, user: nil, secret: nil, password: nil, authtype: nil)
def start(*args, helo: nil,
user: nil, secret: nil, password: nil, authtype: nil, auth: nil)
raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0..4)" if args.size > 4
helo ||= args[0] || 'localhost'
user ||= args[1]
secret ||= password || args[2]
authtype ||= args[3]
auth ||= [user, secret, authtype] if user || secret
if defined?(OpenSSL::VERSION)
ssl_context_params = @ssl_context_params || {}
unless ssl_context_params.has_key?(:verify_mode)
Expand All @@ -609,13 +647,13 @@ def start(*args, helo: nil, user: nil, secret: nil, password: nil, authtype: nil
end
if block_given?
begin
do_start helo, user, secret, authtype
do_start helo, user, secret, authtype, auth
return yield(self)
ensure
do_finish
end
else
do_start helo, user, secret, authtype
do_start helo, user, secret, authtype, auth
return self
end
end
Expand All @@ -633,7 +671,8 @@ def tcp_socket(address, port)
TCPSocket.open address, port
end

def do_start(helo_domain, user, secret, authtype)
def do_start(helo_domain, user, secret, authtype, auth = nil)
# pp({method: __method__, user:, secret:, authtype:, auth:}.compact)
raise IOError, 'SMTP session already started' if @started
s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do
tcp_socket(@address, @port)
Expand Down Expand Up @@ -829,29 +868,57 @@ def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream

DEFAULT_AUTH_TYPE = :plain

def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
authenticator = Authenticator.auth_class(authtype).new(self)
critical { authenticator.auth(user, secret) }
# call-seq:
# authenticate(authtype, **kwargs)
# authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
#
# If two or three positional arguments are provided, they are +username+,
# +secret+, and an optional +authtype+. Keyword parameters cannot be
# used with this form. These arguments may be interpreted differently,
# based on the +authtype+.
#
# If only one positional argument is provided, it must be the +authtype+ and
# all other options must be sent as keyword arguments, which are forwarded
# directly to the authenticator. Different authenticators take different
# options, but common options include +username+ (which may refer to
# "authentication identity" or "authorization identity", depending on the
# +authtype+), +password+, +oauth2_token+, +authcid+ for authentication
# identity, and +authzid+ for authorization identity. Keyword arguments
# that do not apply to the +authtype+ will be silently ignored.
def authenticate(*args, **kwargs)
case args.length
when 1
authtype = args.first
auth(authtype, **kwargs)
when 2..3
unless kwargs.empty?
raise ArgumentError, "do not send keyword arguments when using " \
"positional user and secret arguments"
end
user = args[0]
secret = args[1]
authtype = args[2] || DEFAULT_AUTH_TYPE
authenticator = Authenticator.auth_class(authtype).new(self)
critical { authenticator.auth(user, secret) }
else
raise ArgumentError, "wrong number of arguments " \
"(given %d, expected 1..3)" % [args.length]
end
end

# call-seq:
# auth(mechanism: DEFAULT_AUTH_TYPE, **args)
# auth(mechanism, *args, **kwargs)
#
# If positional arguments are used, +mechanism+ must be the first argument.
#
# All other arguments are forwarded to the SASL authenticator.
def auth(*args, mechanism: nil, **kwargs, &block)
if args.any?
mechanism and raise ArgumentError,
"don't use positional arguments with 'mechanism' keyword"
mechanism = args.shift
# auth(mechanism, ...)
#
# All arguments besides +mechanism+ are forwarded directly to the
# authenticator.
def auth(...)
critical do
Authenticator::SASLAdapter.new(self).authenticate(...)
rescue SMTPServerBusy, SMTPSyntaxError, SMTPFatalError => error
raise SMTPAuthenticationError.new(error.response)
rescue Net::IMAP::SASL::AuthenticationIncomplete => error
raise error.response.exception_class.new(error.response)
end
mechanism ||= DEFAULT_AUTH_TYPE
critical {
Authenticator::SASLAdapter.authenticate(self, mechanism,
*args, **kwargs, &block)
}
end

#
Expand Down
41 changes: 10 additions & 31 deletions lib/net/smtp/auth_sasl_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,11 @@ class Authenticator
# Net::SMTP#authenticate still supports the old API, so v0.4.0 compatible
# Authenticators can still be added and used with it.
class CompatibilityAdapter
def initialize(mechanism)
@mechanism = mechanism.to_s.tr("_", "-").upcase
end

def new(smtp)
@smtp = smtp
self
end

def initialize(mechanism) @mechanism = mechanism end
def new(smtp) @smtp = smtp; self end
def auth(*args, **kwargs, &block)
args.pop while args.any? && args.last.nil?
SASLAdapter.authenticate(@smtp, @mechanism, *args, **kwargs, &block)
@smtp.auth(@mechanism, *args, **kwargs, &block)
end
end

Expand All @@ -35,39 +28,25 @@ def auth(*args, **kwargs, &block)
#
# Initialize with a block that runs a command, yielding for continuations.
class SASLAdapter < SASL::ClientAdapter
COMMAND_NAME = "AUTH"
include SASL::ProtocolAdapters::SMTP

RESPONSE_ERRORS = [
SMTPAuthenticationError,
SMTPServerBusy,
SMTPSyntaxError,
SMTPFatalError,
].freeze

def self.authenticate(client, mechanism, *args, **kwargs, &block)
sasl = SASL.authenticator(mechanism, *args, **kwargs, &block)
adapter = new(client, mechanism, sasl)
adapter.authenticate
end

def initialize(...)
super
@command_proc ||= client.method(:send_command_with_continuations)
end

def supports_initial_response?; true end
def drop_connection; client.finish end
def drop_connection!; client.finish end

def transform_exception(error)
case error
when SMTPServerBusy, SMTPSyntaxError, SMTPFatalError
SMTPAuthenticationError.new(error.response)
when SASL::AuthenticationIncomplete
error.response.exception_class.new(error.response)
else
super
end
end
def response_errors; RESPONSE_ERRORS end
def sasl_ir_capable?; true end # TODO
def auth_capable?(mechanism); client.auth_capable?(mechanism) end
def drop_connection; client.finish end
def drop_connection!; client.finish end # TODO
end

end
Expand Down

0 comments on commit 887035d

Please sign in to comment.