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
73 changes: 48 additions & 25 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ module Net
#
# == Capabilities
#
# Net::IMAP does not _currently_ modify its behaviour according to the
# server's advertised #capabilities. Users of this class must check that the
# server is capable of extension commands or command arguments before
# Most Net::IMAP methods do not _currently_ modify their behaviour according
# to the server's advertised #capabilities. Users of this class must check
# that the server is capable of extension commands or command arguments before
# sending them. Special care should be taken to follow the #capabilities
# requirements for #starttls, #login, and #authenticate.
#
Expand Down Expand Up @@ -404,14 +404,14 @@ module Net
#
# Although IMAP4rev2[https://tools.ietf.org/html/rfc9051] is not supported
# yet, Net::IMAP supports several extensions that have been folded into it:
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +UIDPLUS+, and +UNSELECT+. Commands
# for these extensions are listed with the
# {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+, and +UNSELECT+.
# Commands for these extensions are listed with the {Core IMAP
# commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
#
# >>>
# <em>The following are folded into +IMAP4rev2+ but are currently
# unsupported or incompletely supported by</em> Net::IMAP<em>: RFC4466
# extensions, +ESEARCH+, +SEARCHRES+, +SASL-IR+, +LIST-EXTENDED+,
# extensions, +ESEARCH+, +SEARCHRES+, +LIST-EXTENDED+,
# +LIST-STATUS+, +LITERAL-+, +BINARY+ fetch, and +SPECIAL-USE+. The
# following extensions are implicitly supported, but will be updated with
# more direct support: RFC5530 response codes, <tt>STATUS=SIZE</tt>, and
Expand Down Expand Up @@ -457,6 +457,10 @@ module Net
# - Updates #append with the +APPENDUID+ ResponseCode
# - Updates #copy, #move with the +COPYUID+ ResponseCode
#
# ==== RFC4959: +SASL-IR+
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051].
# - Updates #authenticate with the option to send an initial response.
#
# ==== RFC5161: +ENABLE+
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051] and also included
# above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands].
Expand Down Expand Up @@ -983,19 +987,17 @@ def starttls(options = {}, verify = true)
end

# :call-seq:
# authenticate(mechanism, ...) -> ok_resp
# authenticate(mech, *creds, **props) {|prop, auth| val } -> ok_resp
# authenticate(mechanism, authnid, credentials, authzid=nil) -> ok_resp
# authenticate(mechanism, **properties) -> ok_resp
# authenticate(mechanism) {|propname, authctx| prop_value } -> ok_resp
# authenticate(mechanism, ...) -> ok_resp
# authenticate(mech, *creds, sasl_ir: true, **attrs, &callback) -> ok_resp
#
# Sends an {AUTHENTICATE command [IMAP4rev1 §6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2]
# to authenticate the client. If successful, the connection enters the
# "_authenticated_" state.
#
# +mechanism+ is the name of the \SASL authentication mechanism to be used.
# All other arguments are forwarded to the authenticator for the requested
# mechanism. The listed call signatures are suggestions. <em>The
# +sasl_ir+ allows or disallows sending an "initial response" (see the
# +SASL-IR+ capability, below). All other arguments are forwarded to the
# registered SASL authenticator for the requested mechanism. <em>The
# documentation for each individual mechanism must be consulted for its
# specific parameters.</em>
#
Expand Down Expand Up @@ -1048,19 +1050,40 @@ def starttls(options = {}, verify = true)
# raise "No acceptable authentication mechanism is available"
# end
#
# Server capabilities may change after #starttls, #login, and #authenticate.
# Cached #capabilities will be cleared when this method completes.
# If the TaggedResponse to #authenticate includes updated capabilities, they
# will be cached.
# The SASL exchange provides a method for server challenges and client
# responses, but many mechanisms expect the client to "respond" first. When
# the server's capabilities include +SASL-IR+
# [RFC4959[https://tools.ietf.org/html/rfc4959]], this "initial response"
# may be sent as an argument to the +AUTHENTICATE+ command, saving a
# round-trip. The initial response will _only_ be sent when it is supported
# by both the mechanism and the server. Set +sasl_ir+ to +false+ to prevent
# sending an initial response, even when it is supported.
#
def authenticate(mechanism, ...)
authenticator = self.class.authenticator(mechanism, ...)
send_command("AUTHENTICATE", mechanism) do |resp|
# Although servers _should_ advertise all supported auth mechanisms, it is
# possible to attempt to authenticate with a +mechanism+ that isn't listed.
# However the initial response will not be sent unless the appropriate
# <tt>"AUTH=#{mechanism}"</tt> capability is also present.
#
# Server capabilities may change after #starttls, #login, and #authenticate.
# Previously cached #capabilities will be cleared when this method
# completes. If the TaggedResponse to #authenticate includes updated
# capabilities, they will be cached.
def authenticate(mechanism, *creds, sasl_ir: true, **props, &callback)
authenticator = self.class.authenticator(mechanism,
*creds,
**props,
&callback)
cmdargs = ["AUTHENTICATE", mechanism]
if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) &&
SASL.initial_response?(authenticator)
cmdargs << [authenticator.process(nil)].pack("m0")
end
send_command(*cmdargs) do |resp|
if resp.instance_of?(ContinuationRequest)
data = authenticator.process(resp.data.text.unpack("m")[0])
s = [data].pack("m0")
send_string_data(s)
put_string(CRLF)
challenge = resp.data.text.unpack1("m")
response = authenticator.process(challenge)
response = [response].pack("m0")
put_string(response + CRLF)
end
end
.tap { @capabilities = capabilities_from_resp_code _1 }
Expand Down
2 changes: 2 additions & 0 deletions lib/net/imap/authenticators/plain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
# can be secured by TLS encryption.
class Net::IMAP::PlainAuthenticator

def initial_response?; true end

def process(data)
return "#@authzid\0#@username\0#@password"
end
Expand Down
3 changes: 3 additions & 0 deletions lib/net/imap/authenticators/xoauth2.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

class Net::IMAP::XOauth2Authenticator

def initial_response?; true end

def process(_data)
build_oauth2_string(@user, @oauth2_token)
end
Expand Down
4 changes: 4 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def saslprep(string, **opts)
Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts)
end

def initial_response?(mechanism)
mechanism.respond_to?(:initial_response?) && mechanism.initial_response?
end

end
end

Expand Down
2 changes: 1 addition & 1 deletion test/net/imap/fake_server/command_reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def get_command
def parse(buf)
/\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request"
case $2.upcase
when "LOGIN", "SELECT", "ENABLE"
when "LOGIN", "SELECT", "ENABLE", "AUTHENTICATE"
Command.new $1, $2, scan_astrings($3), buf
else
Command.new $1, $2, $3, buf # TODO...
Expand Down
22 changes: 19 additions & 3 deletions test/net/imap/fake_server/command_router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,17 @@ def handler_for(command)
resp.args.nil? or return resp.fail_bad_args
resp.bye
state.logout
resp.done_ok
begin
resp.done_ok
rescue IOError
# TODO: fix whatever is causing this!
warn "connection issue after bye but before LOGOUT could complete"
if $!.respond_to :detailed_message
warn $!.detailed_message highlight: true, order: :bottom
else
warn $!.full_message highlight: true, order: :bottom
end
end
end

on "STARTTLS" do |resp|
Expand All @@ -79,8 +89,14 @@ def handler_for(command)
on "AUTHENTICATE" do |resp|
state.not_authenticated? or return resp.fail_bad_state(state)
args = resp.command.args
args == "PLAIN" or return resp.fail_no "unsupported"
response_b64 = resp.request_continuation("") || ""
(1..2) === args.length or return resp.fail_bad_args
args.first == "PLAIN" or return resp.fail_no "unsupported"
if args.length == 2
response_b64 = args.last
else
response_b64 = resp.request_continuation("") || ""
state.commands << {continuation: response_b64}
end
response = Base64.decode64(response_b64)
response.empty? and return resp.fail_bad "canceled"
# TODO: support mechanisms other than PLAIN.
Expand Down
4 changes: 4 additions & 0 deletions test/net/imap/fake_server/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Configuration
encrypted_login: true,
cleartext_auth: false,
sasl_mechanisms: %i[PLAIN].freeze,
sasl_ir: false,

rev1: true,
rev2: false,
Expand Down Expand Up @@ -66,6 +67,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block)
alias cleartext_auth? cleartext_auth
alias greeting_bye? greeting_bye
alias greeting_capabilities? greeting_capabilities
alias sasl_ir? sasl_ir

def on(event, &handler)
handler or raise ArgumentError
Expand Down Expand Up @@ -104,13 +106,15 @@ def capabilities_pre_tls
capa << "STARTTLS" if starttls?
capa << "LOGINDISABLED" unless cleartext_login?
capa.concat auth_capabilities if cleartext_auth?
capa << "SASL-IR" if sasl_ir? && cleartext_auth?
capa
end

def capabilities_pre_auth
capa = basic_capabilities
capa << "LOGINDISABLED" unless encrypted_login?
capa.concat auth_capabilities
capa << "SASL-IR" if sasl_ir?
capa
end

Expand Down
90 changes: 90 additions & 0 deletions test/net/imap/test_imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,96 @@ def test_id
end
end

test("#authenticate sends an initial response " \
"when supported by both the mechanism and the server") do
with_fake_server(
preauth: false, cleartext_auth: true, sasl_ir: true
) do |server, imap|
imap.authenticate("PLAIN", "test_user", "test-password")
cmd = server.commands.pop
assert_equal "AUTHENTICATE", cmd.name
assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
cmd.args)
assert_empty server.commands
end
end

test("#authenticate never sends an initial response " \
"when the server doesn't explicitly support the mechanism") do
with_fake_server(
preauth: false, cleartext_auth: true,
sasl_ir: true, sasl_mechanisms: %i[SCRAM-SHA-1 SCRAM-SHA-256],
) do |server, imap|
imap.authenticate("PLAIN", "test_user", "test-password")
cmd, cont = 2.times.map { server.commands.pop }
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
cont[:continuation].strip)
assert_empty server.commands
end
end

test("#authenticate never sends an initial response " \
"when the server isn't capable") do
with_fake_server(
preauth: false, cleartext_auth: true, sasl_ir: false
) do |server, imap|
imap.authenticate("PLAIN", "test_user", "test-password")
cmd, cont = 2.times.map { server.commands.pop }
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
cont[:continuation].strip)
assert_empty server.commands
end
end

test("#authenticate never sends an initial response " \
"when sasl_ir: false") do
[true, false].each do |server_support|
with_fake_server(
preauth: false, cleartext_auth: true, sasl_ir: server_support
) do |server, imap|
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: false)
cmd, cont = 2.times.map { server.commands.pop }
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
cont[:continuation].strip)
assert_empty server.commands
end
end
end

test("#authenticate never sends an initial response " \
"when the mechanism does not support client-first") do
with_fake_server(
preauth: false, cleartext_auth: true,
sasl_ir: true, sasl_mechanisms: %i[DIGEST-MD5]
) do |server, imap|
server.on "AUTHENTICATE" do |cmd|
response_b64 = cmd.request_continuation(
[
%w[
realm="somerealm"
nonce="OA6MG9tEQGm2hh"
qop="auth"
charset=utf-8
algorithm=md5-sess
].join(",")
].pack("m0")
)
state.commands << {continuation: response_b64}
server.state.authenticate(server.config.user)
cmd.done_ok
end
imap.authenticate("DIGEST-MD5", "test_user", "test-password",
warn_deprecation: false)
cmd, cont = 2.times.map { server.commands.pop }
assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args]
assert_match(%r{\A[a-z0-9+/]+=*\z}i, cont[:continuation].strip)
assert_empty server.commands
end
end

def test_uidplus_uid_expunge
with_fake_server(select: "INBOX",
extensions: %i[UIDPLUS]) do |server, imap|
Expand Down
Loading