Skip to content
Merged
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
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# NEWS

## Version 0.4.0

* add Net::SMTP::Authenticator class and auth_* methods are separated from the Net::SMTP class. <https://github.com/ruby/net-smtp/pull/53/files>

## Version 0.3.3 (2022-10-29)

* No timeout library required <https://github.com/ruby/net-smtp/pull/44>
Expand Down
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tmtm it seems the filename has a typo.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!
It was fixed by #54

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