Skip to content

Commit

Permalink
add Net::SMTP::Authenticator class and auth_* methods are separated f…
Browse files Browse the repository at this point in the history
…rom the Net::SMTP class.

This allows you to create an arbitrary authentication plugin without a monkey patch.
Create a class with an `auth` method that inherits Net::SMTP::Authenticator.
The `auth` method has two arguments, `user` and `secret`.
Send an instruction to the SMTP server by using the `continue` or `finish` method.
For more information, see lib/net/smtp/auto _*.rb.
  • Loading branch information
tmtm committed Jul 30, 2023
1 parent 9e44412 commit 48febbf
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 97 deletions.
105 changes: 14 additions & 91 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@
begin
require 'openssl'
rescue LoadError
begin
require 'digest/md5'
rescue LoadError
end
end

module Net
Expand Down Expand Up @@ -628,16 +624,6 @@ def finish

private

def digest_class
@digest_class ||= if defined?(OpenSSL::Digest)
OpenSSL::Digest
elsif defined?(::Digest)
::Digest
else
raise '"openssl" or "digest" library is required'
end
end

def tcp_socket(address, port)
TCPSocket.open address, port
end
Expand Down Expand Up @@ -831,45 +817,14 @@ def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream
def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
check_auth_method authtype
check_auth_args user, secret
public_send auth_method(authtype), user, secret
end

def auth_plain(user, secret)
check_auth_args user, secret
res = critical {
get_response('AUTH PLAIN ' + base64_encode("\0#{user}\0#{secret}"))
}
check_auth_response res
res
end

def auth_login(user, secret)
check_auth_args user, secret
res = critical {
check_auth_continue get_response('AUTH LOGIN')
check_auth_continue get_response(base64_encode(user))
get_response(base64_encode(secret))
}
check_auth_response res
res
end

def auth_cram_md5(user, secret)
check_auth_args user, secret
res = critical {
res0 = get_response('AUTH CRAM-MD5')
check_auth_continue res0
crammed = cram_md5_response(secret, res0.cram_md5_challenge)
get_response(base64_encode("#{user} #{crammed}"))
}
check_auth_response res
res
authenticator = Authenticator.auth_class(authtype).new(self)
authenticator.auth(user, secret)
end

private

def check_auth_method(type)
unless respond_to?(auth_method(type), true)
unless Authenticator.auth_class(type)
raise ArgumentError, "wrong authentication type #{type}"
end
end
Expand All @@ -887,31 +842,6 @@ def check_auth_args(user, secret, authtype = DEFAULT_AUTH_TYPE)
end
end

def base64_encode(str)
# expects "str" may not become too long
[str].pack('m0')
end

IMASK = 0x36
OMASK = 0x5c

# CRAM-MD5: [RFC2195]
def cram_md5_response(secret, challenge)
tmp = digest_class::MD5.digest(cram_secret(secret, IMASK) + challenge)
digest_class::MD5.hexdigest(cram_secret(secret, OMASK) + tmp)
end

CRAM_BUFSIZE = 64

def cram_secret(secret, mask)
secret = digest_class::MD5.digest(secret) if secret.size > CRAM_BUFSIZE
buf = secret.ljust(CRAM_BUFSIZE, "\0")
0.upto(buf.size - 1) do |i|
buf[i] = (buf[i].ord ^ mask).chr
end
buf
end

#
# SMTP command dispatcher
#
Expand Down Expand Up @@ -1023,6 +953,12 @@ def quit
getok('QUIT')
end

def get_response(reqline)
validate_line reqline
@socket.writeline reqline
recv_response()
end

private

def validate_line(line)
Expand All @@ -1042,12 +978,6 @@ def getok(reqline)
res
end

def get_response(reqline)
validate_line reqline
@socket.writeline reqline
recv_response()
end

def recv_response
buf = ''.dup
while true
Expand Down Expand Up @@ -1080,18 +1010,6 @@ def check_continue(res)
end
end

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

def check_auth_continue(res)
unless res.continue?
raise res.exception_class.new(res)
end
end

# This class represents a response received by the SMTP server. Instances
# of this class are created by the SMTP class; they should not be directly
# created by the user. For more information on SMTP responses, view
Expand Down Expand Up @@ -1207,3 +1125,8 @@ def to_s
SMTPSession = SMTP # :nodoc:

end

require_relative 'smtp/authenticator'
Dir.glob("#{__dir__}/smtp/auth_*.rb") do |r|
require_relative r
end
48 changes: 48 additions & 0 deletions lib/net/smtp/auth_cram_md5.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
unless defined? OpenSSL
begin
require 'digest/md5'
rescue LoadError
end
end

class Net::SMTP
class AuthCramMD5 < Net::SMTP::Authenticator
auth_type :cram_md5

def auth(user, secret)
challenge = continue('AUTH CRAM-MD5')
crammed = cram_md5_response(secret, challenge.unpack1('m'))
finish(base64_encode("#{user} #{crammed}"))
end

IMASK = 0x36
OMASK = 0x5c

# CRAM-MD5: [RFC2195]
def cram_md5_response(secret, challenge)
tmp = digest_class::MD5.digest(cram_secret(secret, IMASK) + challenge)
digest_class::MD5.hexdigest(cram_secret(secret, OMASK) + tmp)
end

CRAM_BUFSIZE = 64

def cram_secret(secret, mask)
secret = digest_class::MD5.digest(secret) if secret.size > CRAM_BUFSIZE
buf = secret.ljust(CRAM_BUFSIZE, "\0")
0.upto(buf.size - 1) do |i|
buf[i] = (buf[i].ord ^ mask).chr
end
buf
end

def digest_class
@digest_class ||= if defined?(OpenSSL::Digest)
OpenSSL::Digest
elsif defined?(::Digest)
::Digest
else
raise '"openssl" or "digest" library is required'
end
end
end
end
11 changes: 11 additions & 0 deletions lib/net/smtp/auth_loign.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class Net::SMTP
class AuthLogin < Net::SMTP::Authenticator
auth_type :login

def auth(user, secret)
continue('AUTH LOGIN')
continue(base64_encode(user))
finish(base64_encode(secret))
end
end
end
9 changes: 9 additions & 0 deletions lib/net/smtp/auth_plain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Net::SMTP
class AuthPlain < Net::SMTP::Authenticator
auth_type :plain

def auth(user, secret)
finish('AUTH PLAIN ' + base64_encode("\0#{user}\0#{secret}"))
end
end
end
46 changes: 46 additions & 0 deletions lib/net/smtp/authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
module Net
class SMTP
class Authenticator
def self.auth_classes
@classes ||= {}
end

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

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

attr_reader :smtp

def initialize(smtp)
@smtp = smtp
end

# @param arg [String] message to server
# @return [String] message from server
def continue(arg)
res = smtp.get_response arg
raise res.exception_class.new(res) unless res.continue?
res.string.split[1]
end

# @param arg [String] message to server
# @return [Net::SMTP::Response] response from server
def finish(arg)
res = smtp.get_response arg
raise SMTPAuthenticationError.new(res) unless res.success?
res
end

# @param str [String]
# @return [String] Base64 encoded string
def base64_encode(str)
# expects "str" may not become too long
[str].pack('m0')
end
end
end
end
14 changes: 8 additions & 6 deletions test/net/smtp/test_smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,15 @@ def test_auth_plain
sock = FakeSocket.new
smtp = Net::SMTP.new 'localhost', 25
smtp.instance_variable_set :@socket, sock
assert smtp.auth_plain("foo", "bar").success?
assert smtp.authenticate("foo", "bar", :plain).success?
assert_equal "AUTH PLAIN AGZvbwBiYXI=\r\n", sock.write_io.string
end

def test_unsucessful_auth_plain
sock = FakeSocket.new("535 Authentication failed: FAIL\r\n")
smtp = Net::SMTP.new 'localhost', 25
smtp.instance_variable_set :@socket, sock
err = assert_raise(Net::SMTPAuthenticationError) { smtp.auth_plain("foo", "bar") }
err = assert_raise(Net::SMTPAuthenticationError) { smtp.authenticate("foo", "bar", :plain) }
assert_equal "535 Authentication failed: FAIL\n", err.message
assert_equal "535", err.response.status
end
Expand All @@ -155,14 +155,14 @@ def test_auth_login
sock = FakeSocket.new("334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n235 2.7.0 Authentication successful\r\n")
smtp = Net::SMTP.new 'localhost', 25
smtp.instance_variable_set :@socket, sock
assert smtp.auth_login("foo", "bar").success?
assert smtp.authenticate("foo", "bar", :login).success?
end

def test_unsucessful_auth_login
sock = FakeSocket.new("334 VXNlcm5hbWU6\r\n334 UGFzc3dvcmQ6\r\n535 Authentication failed: FAIL\r\n")
smtp = Net::SMTP.new 'localhost', 25
smtp.instance_variable_set :@socket, sock
err = assert_raise(Net::SMTPAuthenticationError) { smtp.auth_login("foo", "bar") }
err = assert_raise(Net::SMTPAuthenticationError) { smtp.authenticate("foo", "bar", :login) }
assert_equal "535 Authentication failed: FAIL\n", err.message
assert_equal "535", err.response.status
end
Expand All @@ -171,7 +171,7 @@ def test_non_continue_auth_login
sock = FakeSocket.new("334 VXNlcm5hbWU6\r\n235 2.7.0 Authentication successful\r\n")
smtp = Net::SMTP.new 'localhost', 25
smtp.instance_variable_set :@socket, sock
err = assert_raise(Net::SMTPUnknownError) { smtp.auth_login("foo", "bar") }
err = assert_raise(Net::SMTPUnknownError) { smtp.authenticate("foo", "bar", :login) }
assert_equal "235 2.7.0 Authentication successful\n", err.message
assert_equal "235", err.response.status
end
Expand Down Expand Up @@ -517,7 +517,9 @@ def test_start_auth_cram_md5

port = fake_server_start(user: 'account', password: 'password', authtype: 'CRAM-MD5')
smtp = Net::SMTP.new('localhost', port)
smtp.define_singleton_method(:digest_class) { raise '"openssl" or "digest" library is required' }
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
Expand Down

0 comments on commit 48febbf

Please sign in to comment.