Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace PLAIN, CRAM-MD5, and LOGIN implementations #70

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
uses: ruby/actions/.github/workflows/ruby_versions.yml@master
with:
engine: cruby
min_version: 2.6
min_version: 2.7

build:
needs: ruby-versions
Expand All @@ -26,4 +26,4 @@ jobs:
- name: Install dependencies
run: bundle install
- name: Run test
run: rake test
run: bundle exec rake test
188 changes: 154 additions & 34 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,20 +175,48 @@ 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.
#
# # PLAIN
# Net::SMTP.start('your.smtp.server', 25,
# user: 'Your Account', secret: 'Your Password', authtype: :plain)
# 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
Expand Down Expand Up @@ -459,15 +487,16 @@ def debug_output=(arg)

#
# :call-seq:
# 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| ... }
# 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', username: 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', username = nil, secret = nil, authtype = nil) { |smtp| ... }
#
# Creates a new Net::SMTP object and connects to the server.
#
# This method is equivalent to:
#
# Net::SMTP.new(address, port, tls_verify: flag, tls_hostname: hostname, ssl_context_params: nil)
# .start(helo: helo_domain, user: account, secret: password, authtype: authtype)
# .start(helo: helo_domain, username: account, secret: password, authtype: authtype)
#
# See also: Net::SMTP.new, #start
#
Expand Down Expand Up @@ -514,13 +543,15 @@ def debug_output=(arg)
#
# +authtype+ is the SASL authentication mechanism.
#
# +user+ is the authentication or authorization identity.
# +username+ or +user+ is the authentication or authorization identity.
#
# +secret+ or +password+ is your password or other authentication token.
#
# These will be sent to #authenticate as positional arguments-the exact
# semantics are dependent on the +authtype+.
#
# +auth+ is an optional hash of keyword arguments for #authenticate.
#
# See the discussion of Net::SMTP@SMTP+Authentication in the overview notes.
#
# === Errors
Expand All @@ -538,15 +569,18 @@ def debug_output=(arg)
#
def SMTP.start(address, port = nil, *args, helo: nil,
user: nil, secret: nil, password: nil, authtype: nil,
username: nil,
auth: nil,
tls: false, starttls: :auto,
tls_verify: true, tls_hostname: nil, ssl_context_params: nil,
&block)
raise ArgumentError, "wrong number of arguments (given #{args.size + 2}, expected 1..6)" if args.size > 4
helo ||= args[0] || 'localhost'
user ||= args[1]
username ||= 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, username: username, secret: secret, authtype: authtype, auth: auth, &block)
end

# +true+ if the \SMTP session has been started.
Expand All @@ -556,8 +590,9 @@ 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', username: nil, secret: nil, authtype: nil) { |smtp| ... }
# start(helo = 'localhost', username = nil, secret = nil, authtype = nil) { |smtp| ... }
# start(helo = 'localhost', auth: {type: nil, **auth_kwargs}) { |smtp| ... }
#
# Opens a TCP connection and starts the SMTP session.
#
Expand All @@ -571,13 +606,15 @@ def started?
#
# +authtype+ is the SASL authentication mechanism.
#
# +user+ is the authentication or authorization identity.
# +username+ or +user+ is the authentication or authorization identity.
#
# +secret+ or +password+ is your password or other authentication token.
#
# These will be sent to #authenticate as positional arguments-the exact
# semantics are dependent on the +authtype+.
#
# +auth+ is an optional hash of keyword arguments for #authenticate.
#
# See the discussion of Net::SMTP@SMTP+Authentication in the overview notes.
#
# See also: Net::SMTP.start
Expand All @@ -595,7 +632,7 @@ def started?
#
# require 'net/smtp'
# smtp = Net::SMTP.new('smtp.mail.server', 25)
# smtp.start(helo: helo_domain, user: account, secret: password, authtype: authtype) do |smtp|
# smtp.start(helo: helo_domain, username: account, secret: password, authtype: authtype) do |smtp|
# smtp.send_message msgstr, 'from@example.com', ['dest@example.com']
# end
#
Expand All @@ -619,12 +656,15 @@ 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, username: 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]
username ||= user || args[1]
secret ||= password || args[2]
authtype ||= args[3]
auth ||= {}
if defined?(OpenSSL::VERSION)
ssl_context_params = @ssl_context_params || {}
unless ssl_context_params.has_key?(:verify_mode)
Expand All @@ -639,13 +679,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, username, secret, authtype, **auth
return yield(self)
ensure
do_finish
quit!
end
else
do_start helo, user, secret, authtype
do_start helo, username, secret, authtype, **auth
return self
end
end
Expand All @@ -654,7 +694,7 @@ def start(*args, helo: nil, user: nil, secret: nil, password: nil, authtype: nil
# Raises IOError if not started.
def finish
raise IOError, 'not yet started' unless started?
do_finish
quit!
end

private
Expand All @@ -663,10 +703,10 @@ 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)
raise IOError, 'SMTP session already started' if @started
if user || secret || authtype
check_auth_args authtype, user, secret
if user || secret || authtype || auth.any?
check_auth_args(authtype, user, secret, **auth)
end
s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do
tcp_socket(@address, @port)
Expand All @@ -684,7 +724,11 @@ def do_start(helo_domain, user, secret, authtype)
# helo response may be different after STARTTLS
do_helo helo_domain
end
authenticate user, secret, (authtype || DEFAULT_AUTH_TYPE) if user
if user or secret
authenticate(user, secret, authtype, **auth)
elsif authtype or auth.any?
authenticate(authtype, **auth)
end
@started = true
ensure
unless @started
Expand Down Expand Up @@ -728,15 +772,46 @@ def do_helo(helo_domain)
raise
end

def do_finish
public

# Calls #quit and ensures that #disconnect is called. Returns the result
# from #quit. Returns +nil+ when the client is already disconnected or when
# a prior error prevents the client from calling #quit. Unlike #finish,
# this an exception will not be raised when the client has not started.
#
# When <tt>exception: :warn</tt> is specified, when #quit raises a
# StandardError, a warning will be printed and the exception is returned,
# not re-raised. When <tt>exception: false</tt> is specified, a warning
# will not be printed. This is useful when the connection must be dropped,
# for example in a test suite or due to security concerns.
#
# Related: #finish, #quit, #disconnect
def quit!(exception: true)
quit if @socket and not @socket.closed? and not @error_occurred
rescue => ex
if exception == :warn
warn "%s during %p #%s: %s" % [ex.class, self, __method__, ex]
elsif exception
raise ex
end
ex
ensure
disconnect
end

# Disconnects the socket without checking if the connection has started yet,
# and without sending a final QUIT message to the server.
#
# Generally, either #finish or #quit! should be used instead.
def disconnect
@started = false
@error_occurred = false
@socket.close if @socket
@socket = nil
end

private

def requires_smtputf8(address)
if address.kind_of? Address
!address.address.ascii_only?
Expand Down Expand Up @@ -862,26 +937,56 @@ def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream

DEFAULT_AUTH_TYPE = :plain

# call-seq:
# authenticate(type: DEFAULT_AUTH_TYPE, **, &)
# authenticate(type = DEFAULT_AUTH_TYPE, **, &)
# authenticate(username, secret, type: DEFAULT_AUTH_TYPE, **, &)
# authenticate(username, secret, type = DEFAULT_AUTH_TYPE, **, &)
#
# Authenticates with the server, using the "AUTH" command.
#
# +authtype+ is the name of a SASL authentication mechanism.
# +type+ is the name of a SASL authentication mechanism.
#
# All arguments-other than +authtype+-are forwarded to the authenticator.
# Different authenticators may interpret the +user+ and +secret+
# Different authenticators may interpret the +username+ and +secret+
# arguments differently.
def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
check_auth_args authtype, user, secret
def authenticate(*args, **kwargs, &block)
case args.length
when 1, 3 then authtype = args.pop
when (4..)
raise ArgumentError, "wrong number of arguments " \
"(given %d, expected 0..3)" % [args.length]
end
auth(authtype, *args, **kwargs, &block)
end

# call-seq:
# auth(type = DEFAULT_AUTH_TYPE, ...)
# auth(type: DEFAULT_AUTH_TYPE, **kwargs, &block)
#
# All arguments besides +mechanism+ are forwarded directly to the
# authenticator. Alternatively, +mechanism+ can be provided by the +type+
# keyword parameter. Positional parameters cannot be used with +type+.
#
# Different authenticators take different options, but common options
# include +authcid+ for authentication identity, +authzid+ for authorization
# identity, +username+ for either "authentication identity" or
# "authorization identity" depending on the +mechanism+, and +password+.
def auth(authtype = DEFAULT_AUTH_TYPE, *args, **kwargs, &block)
authtype, args, kwargs = check_auth_args authtype, *args, **kwargs
authenticator = Authenticator.auth_class(authtype).new(self)
authenticator.auth(user, secret)
authenticator.auth(*args, **kwargs, &block)
end

private

def check_auth_args(type, *args, **kwargs)
type ||= DEFAULT_AUTH_TYPE
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)
[type, args, kwargs]
end

#
Expand Down Expand Up @@ -992,6 +1097,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)
Expand Down
Loading