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

Forward-compatible SASL API #68

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 71 additions & 26 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ class SMTPUnsupportedCommand < ProtocolError
#
# # 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.
Expand Down Expand Up @@ -459,15 +459,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 +515,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 +541,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 +562,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 +578,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 +604,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, '[email protected]', ['[email protected]']
# end
#
Expand All @@ -619,12 +628,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 +651,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
end
else
do_start helo, user, secret, authtype
do_start helo, username, secret, authtype, **auth
return self
end
end
Expand All @@ -663,10 +675,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 +696,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 @@ -862,26 +878,55 @@ 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, **kwargs)
type ||= type_arg || DEFAULT_AUTH_TYPE
klass = Authenticator.auth_class(type) or
raise ArgumentError, "wrong authentication type #{type}"
klass.check_args(*args, **kwargs)
[type, args, kwargs]
end

#
Expand Down
7 changes: 6 additions & 1 deletion lib/net/smtp/auth_cram_md5.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ class Net::SMTP
class AuthCramMD5 < Net::SMTP::Authenticator
auth_type :cram_md5

def auth(user, secret)
def auth(user_arg = nil, secret_arg = nil,
authcid: nil, username: nil, user: nil,
secret: nil, password: nil,
**)
user = req_param authcid, username, user, user_arg, "username (authcid)"
secret = req_param password, secret, secret_arg, "secret (password)"
challenge = continue('AUTH CRAM-MD5')
crammed = cram_md5_response(secret, challenge.unpack1('m'))
finish(base64_encode("#{user} #{crammed}"))
Expand Down
7 changes: 6 additions & 1 deletion lib/net/smtp/auth_login.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ class Net::SMTP
class AuthLogin < Net::SMTP::Authenticator
auth_type :login

def auth(user, secret)
def auth(user_arg = nil, secret_arg = nil,
authcid: nil, username: nil, user: nil,
secret: nil, password: nil,
**)
user = req_param authcid, username, user, user_arg, "username (authcid)"
secret = req_param password, secret, secret_arg, "secret (password)"
continue('AUTH LOGIN')
continue(base64_encode(user))
finish(base64_encode(secret))
Expand Down
7 changes: 6 additions & 1 deletion lib/net/smtp/auth_plain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ class Net::SMTP
class AuthPlain < Net::SMTP::Authenticator
auth_type :plain

def auth(user, secret)
def auth(user_arg = nil, secret_arg = nil,
authcid: nil, username: nil, user: nil,
secret: nil, password: nil,
**)
user = req_param authcid, username, user, user_arg, "username (authcid)"
secret = req_param password, secret, secret_arg, "secret (password)"
finish('AUTH PLAIN ' + base64_encode("\0#{user}\0#{secret}"))
end
end
Expand Down
15 changes: 12 additions & 3 deletions lib/net/smtp/authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ def self.auth_class(type)
Authenticator.auth_classes[type]
end

def self.check_args(user_arg = nil, secret_arg = nil, *, **)
unless user_arg
def self.check_args(user_arg = nil, secret_arg = nil, *,
authcid: nil, username: nil, user: nil,
secret: nil, password: nil,
**)
unless authcid || username || user || user_arg
raise ArgumentError, 'SMTP-AUTH requested but missing user name'
end
unless secret_arg
unless password || secret || secret_arg
raise ArgumentError, 'SMTP-AUTH requested but missing secret phrase'
end
end
Expand Down Expand Up @@ -52,6 +55,12 @@ def base64_encode(str)
# expects "str" may not become too long
[str].pack('m0')
end

def req_param(*args, name)
args.compact.first or
raise ArgumentError, "SMTP-AUTH requested but missing #{name}"
end

end
end
end
34 changes: 34 additions & 0 deletions test/net/smtp/test_smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ def test_auth_plain
smtp = Net::SMTP.start 'localhost', server.port
assert smtp.authenticate("account", "password", :plain).success?
assert_equal "AUTH PLAIN AGFjY291bnQAcGFzc3dvcmQ=\r\n", server.commands.last

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

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

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

def test_unsucessful_auth_plain
Expand All @@ -120,10 +135,20 @@ def test_unsucessful_auth_plain
assert_equal "535", err.response.status
end

def test_auth_cram_md5
server = FakeServer.start(auth: 'CRAM-MD5')
smtp = Net::SMTP.start 'localhost', server.port
assert smtp.auth(:cram_md5, "account", password: "password").success?
end

def test_auth_login
server = FakeServer.start(auth: 'login')
smtp = Net::SMTP.start 'localhost', server.port
assert smtp.authenticate("account", "password", :login).success?

server = FakeServer.start(auth: 'login')
smtp = Net::SMTP.start 'localhost', server.port
assert smtp.auth("LOGIN", username: "account", secret: "password").success?
end

def test_unsucessful_auth_login
Expand Down Expand Up @@ -470,6 +495,15 @@ def test_start_auth_plain
port = fake_server_start(auth: 'plain')
Net::SMTP.start('localhost', port, user: 'account', password: 'password', authtype: :plain){}

port = fake_server_start(auth: 'plain')
Net::SMTP.start('localhost', port, authtype: "PLAIN",
auth: {username: 'account', password: 'password'}){}

port = fake_server_start(auth: 'plain')
Net::SMTP.start('localhost', port, auth: {username: 'account',
password: 'password',
type: :plain}){}

port = fake_server_start(auth: 'plain')
assert_raise Net::SMTPAuthenticationError do
Net::SMTP.start('localhost', port, user: 'account', password: 'invalid', authtype: :plain){}
Expand Down