From 95a2a119369d03a1aab03488643037389e6c71eb Mon Sep 17 00:00:00 2001 From: TOMITA Masahiro Date: Sun, 30 Jul 2023 22:55:12 +0900 Subject: [PATCH] add Net::SMTP::Authenticator class and auth_* methods are separated from the Net::SMTP class. This allows you to add a new authentication method to Net::SMTP. 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. --- lib/net/smtp.rb | 105 +++++----------------------------- lib/net/smtp/auth_cram_md5.rb | 48 ++++++++++++++++ lib/net/smtp/auth_loign.rb | 11 ++++ lib/net/smtp/auth_plain.rb | 9 +++ lib/net/smtp/authenticator.rb | 46 +++++++++++++++ test/net/smtp/test_smtp.rb | 14 +++-- 6 files changed, 136 insertions(+), 97 deletions(-) create mode 100644 lib/net/smtp/auth_cram_md5.rb create mode 100644 lib/net/smtp/auth_loign.rb create mode 100644 lib/net/smtp/auth_plain.rb create mode 100644 lib/net/smtp/authenticator.rb diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 5ac208a..cf6990b 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -21,10 +21,6 @@ begin require 'openssl' rescue LoadError - begin - require 'digest/md5' - rescue LoadError - end end module Net @@ -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 @@ -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 @@ -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 # @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/lib/net/smtp/auth_cram_md5.rb b/lib/net/smtp/auth_cram_md5.rb new file mode 100644 index 0000000..0490cd6 --- /dev/null +++ b/lib/net/smtp/auth_cram_md5.rb @@ -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 diff --git a/lib/net/smtp/auth_loign.rb b/lib/net/smtp/auth_loign.rb new file mode 100644 index 0000000..545c1f9 --- /dev/null +++ b/lib/net/smtp/auth_loign.rb @@ -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 diff --git a/lib/net/smtp/auth_plain.rb b/lib/net/smtp/auth_plain.rb new file mode 100644 index 0000000..7fa1198 --- /dev/null +++ b/lib/net/smtp/auth_plain.rb @@ -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 diff --git a/lib/net/smtp/authenticator.rb b/lib/net/smtp/authenticator.rb new file mode 100644 index 0000000..75350f0 --- /dev/null +++ b/lib/net/smtp/authenticator.rb @@ -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 diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index b418b3f..dc6b69c 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -138,7 +138,7 @@ 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 @@ -146,7 +146,7 @@ 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 @@ -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 @@ -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 @@ -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