Skip to content

Commit

Permalink
🚧 WIP: Use net-imap's SASL implementation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nevans committed Sep 27, 2023
1 parent bf27727 commit 7d89dac
Show file tree
Hide file tree
Showing 11 changed files with 163 additions and 106 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ jobs:
- name: Install dependencies
run: bundle install
- name: Run test
run: rake test
run: bundle exec rake test
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ group :development do
gem "rake"
gem "test-unit"
end

gem "net-imap", github: "nevans/net-imap", branch: "sasl/abstract-protocol"
60 changes: 35 additions & 25 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,6 @@ def tcp_socket(address, port)

def do_start(helo_domain, user, secret, authtype)
raise IOError, 'SMTP session already started' if @started
if user or secret
check_auth_method(authtype || DEFAULT_AUTH_TYPE)
check_auth_args user, secret
end
s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do
tcp_socket(@address, @port)
end
Expand Down Expand Up @@ -832,31 +828,24 @@ def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream
DEFAULT_AUTH_TYPE = :plain

def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
check_auth_method authtype
check_auth_args user, secret
authenticator = Authenticator.auth_class(authtype).new(self)
authenticator.auth(user, secret)
end

private

def check_auth_method(type)
unless Authenticator.auth_class(type)
raise ArgumentError, "wrong authentication type #{type}"
end
end

def auth_method(type)
"auth_#{type.to_s.downcase}".intern
end

def check_auth_args(user, secret, authtype = DEFAULT_AUTH_TYPE)
unless user
raise ArgumentError, 'SMTP-AUTH requested but missing user name'
end
unless secret
raise ArgumentError, 'SMTP-AUTH requested but missing secret phrase'
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)
mechanism && args.any? and raise ArgumentError,
"don't use 'mechanism' keyword with positional arguments"
mechanism ||= args.shift || DEFAULT_AUTH_TYPE
critical {
Authenticator.auth(self, mechanism, *args, **kwargs, &block)
}
end

#
Expand Down Expand Up @@ -967,6 +956,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_yielding_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
raise SMTPAuthenticationError.new(server_resp) unless server_resp.success?
server_resp
end

private

def validate_line(line)
Expand Down
37 changes: 37 additions & 0 deletions lib/net/smtp/auth_compatibility.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module Net
class SMTP
class Authenticator

# This curries arguments to support the Authenticator API used by v0.4.0.
#
# Net::SMTP#authenticate still uses 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 sasl_authenticator(user = nil, secret = nil, *args, **kwargs, &block)
mechanism = mechanism
args = []
args << user unless user.nil? && secret.nil?
args << secret unless secret.nil?
SASL.authenticator(@mechanism, *args, **kwargs, &block)
end

def auth(*args, **kwargs, &block)
sasl = sasl_authenticator(*args, **kwargs, &block)
Adapter.new(@smtp, @mechanism, sasl).auth
end
end

auth_classes.default_proc = ->h, mech { CompatibilityAdapter.new(mech) }

end
end
end
48 changes: 0 additions & 48 deletions lib/net/smtp/auth_cram_md5.rb

This file was deleted.

11 changes: 0 additions & 11 deletions lib/net/smtp/auth_login.rb

This file was deleted.

73 changes: 73 additions & 0 deletions lib/net/smtp/auth_net_imap_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
module Net
class SMTP
class Authenticator

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

class ClientAdapter
attr_reader :client, :mechanism, :sasl

def initialize(client, mechanism, sasl)
@client, @mechanism, @sasl = client, mechanism, sasl
end

def auth
response = encode_ir sasl.process nil if send_initial_response?
result = run_auth_command response do |challenge|
encode sasl.process decode challenge
end
handle_failure result
handle_incomplete result if sasl.respond_to?(:done?) && !sasl.done?
handle_success result
end

def decode(string) string.unpack1("m0") end
def encode(string) [string].pack('m0') end
def encode_ir(string) string.empty? ? "=" : encode(string) end

# Returns protocol and server capabilities for both IR and mechanism
def send_initial_response?
capable_initial_response? &&
server_supports?(mechanism) &&
sasl.respond_to?(:initial_response?) &&
sasl.initial_response?
end

# Protocol clients should override to return boolean based on client
# preferences and server/protocol capabilities.
def capable_initial_response?; true end

# Protocol clients should override to return boolean based on server's
# advertized support.
def server_supports?(mechanism) true end
end

class Adapter < ClientAdapter
def capable_initial_response?; true end # TODO: check capabilities
def server_supports?(mechanism) true end # TODO: check capabilities

# sends the appropriate auth command (encoding initial client response)
# yields each continuation's encoded data
# block may run in same thread or in another thread as a callback
# sends each encoded client response
# returns the command's final response or result object
def run_auth_command(sasl_response, &block)
cmdargs = ["AUTH", mechanism, sasl_response].compact
res = client.send_command_yielding_continuations(*cmdargs, &block)
raise SMTPAuthenticationError.new(res) unless res.success?
res
end

def handle_failure(res)
raise SMTPAuthenticationError.new(res) unless res.success?
end

def handle_incomplete(res) raise res.exception_class.new(res) end
def handle_success(res) res end
end
end
end
end
9 changes: 0 additions & 9 deletions lib/net/smtp/auth_plain.rb

This file was deleted.

8 changes: 6 additions & 2 deletions lib/net/smtp/authenticator.rb
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
require "net/imap"

module Net
class SMTP
class Authenticator
SASL = Net::IMAP::SASL

def self.auth_classes
@classes ||= {}
end

def self.auth_type(type)
Authenticator.auth_classes[type] = self
Authenticator.auth_classes[type.intern] = self
end

def self.auth_class(type)
Authenticator.auth_classes[type.intern]
Authenticator.auth_classes[type]
end

attr_reader :smtp
Expand Down
2 changes: 2 additions & 0 deletions net-smtp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

spec.add_dependency "net-protocol"

spec.add_development_dependency "net-imap" # experimental SASL support
end
17 changes: 7 additions & 10 deletions test/net/smtp/test_smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ def test_address
assert_equal ['p0=123', 'p1=456', 'p2', 'p3=789'], a.parameters
end

def test_auth_sasl
server = FakeServer.start(auth: 'plain')
smtp = Net::SMTP.start 'localhost', server.port
assert smtp.auth("PLAIN", "account", "password").success?
assert_equal "AUTH PLAIN AGFjY291bnQAcGFzc3dvcmQ=\r\n", server.commands.last
end

def test_auth_plain
server = FakeServer.start(auth: 'plain')
smtp = Net::SMTP.start 'localhost', server.port
Expand Down Expand Up @@ -496,16 +503,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
Expand Down

0 comments on commit 7d89dac

Please sign in to comment.