-
Notifications
You must be signed in to change notification settings - Fork 29
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
🔀 Merge pull request #183 from nevans/sasl/abstract-protocol
🔒⚗️ Add experimental SASL::ClientAdapter
- Loading branch information
Showing
7 changed files
with
274 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
# frozen_string_literal: true | ||
|
||
module Net | ||
class IMAP | ||
module SASL | ||
|
||
# This API is *experimental*, and may change. | ||
# | ||
# TODO: catch exceptions in #process and send #cancel_response. | ||
# TODO: raise an error if the command succeeds after being canceled. | ||
# TODO: use with more clients, to verify the API can accommodate them. | ||
# | ||
# Create an AuthenticationExchange from a client adapter and a mechanism | ||
# authenticator: | ||
# def authenticate(mechanism, ...) | ||
# authenticator = SASL.authenticator(mechanism, ...) | ||
# SASL::AuthenticationExchange.new( | ||
# sasl_adapter, mechanism, authenticator | ||
# ).authenticate | ||
# end | ||
# | ||
# private | ||
# | ||
# def sasl_adapter = MyClientAdapter.new(self, &method(:send_command)) | ||
# | ||
# Or delegate creation of the authenticator to ::build: | ||
# def authenticate(...) | ||
# SASL::AuthenticationExchange.build(sasl_adapter, ...) | ||
# .authenticate | ||
# end | ||
# | ||
# As a convenience, ::authenticate combines ::build and #authenticate: | ||
# def authenticate(...) | ||
# SASL::AuthenticationExchange.authenticate(sasl_adapter, ...) | ||
# end | ||
# | ||
# Likewise, ClientAdapter#authenticate delegates to #authenticate: | ||
# def authenticate(...) = sasl_adapter.authenticate(...) | ||
# | ||
class AuthenticationExchange | ||
# Convenience method for <tt>build(...).authenticate</tt> | ||
def self.authenticate(...) build(...).authenticate end | ||
|
||
# Use +registry+ to override the global Authenticators registry. | ||
def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block) | ||
authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block) | ||
new(client, mechanism, authenticator, sasl_ir: sasl_ir) | ||
end | ||
|
||
attr_reader :mechanism, :authenticator | ||
|
||
def initialize(client, mechanism, authenticator, sasl_ir: true) | ||
@client = client | ||
@mechanism = -mechanism.to_s.upcase.tr(?_, ?-) | ||
@authenticator = authenticator | ||
@sasl_ir = sasl_ir | ||
@processed = false | ||
end | ||
|
||
# Call #authenticate to execute an authentication exchange for #client | ||
# using #authenticator. Authentication failures will raise an | ||
# exception. Any exceptions other than those in RESPONSE_ERRORS will | ||
# drop the connection. | ||
def authenticate | ||
client.run_command(mechanism, initial_response) { process _1 } | ||
.tap { raise AuthenticationIncomplete, _1 unless done? } | ||
rescue *client.response_errors | ||
raise # but don't drop the connection | ||
rescue | ||
client.drop_connection | ||
raise | ||
rescue Exception # rubocop:disable Lint/RescueException | ||
client.drop_connection! | ||
raise | ||
end | ||
|
||
def send_initial_response? | ||
@sasl_ir && | ||
authenticator.respond_to?(:initial_response?) && | ||
authenticator.initial_response? && | ||
client.sasl_ir_capable? && | ||
client.auth_capable?(mechanism) | ||
end | ||
|
||
def done? | ||
authenticator.respond_to?(:done?) ? authenticator.done? : @processed | ||
end | ||
|
||
private | ||
|
||
attr_reader :client | ||
|
||
def initial_response | ||
return unless send_initial_response? | ||
client.encode_ir authenticator.process nil | ||
end | ||
|
||
def process(challenge) | ||
client.encode authenticator.process client.decode challenge | ||
ensure | ||
@processed = true | ||
end | ||
|
||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
# frozen_string_literal: true | ||
|
||
module Net | ||
class IMAP | ||
module SASL | ||
|
||
# This API is *experimental*, and may change. | ||
# | ||
# TODO: use with more clients, to verify the API can accommodate them. | ||
# | ||
# An abstract base class for implementing a SASL authentication exchange. | ||
# Different clients will each have their own adapter subclass, overridden | ||
# to match their needs. | ||
# | ||
# Although the default implementations _may_ be sufficient, subclasses | ||
# will probably need to override some methods. Additionally, subclasses | ||
# may need to include a protocol adapter mixin, if the default | ||
# ProtocolAdapters::Generic isn't sufficient. | ||
class ClientAdapter | ||
include ProtocolAdapters::Generic | ||
|
||
attr_reader :client, :command_proc | ||
|
||
# +command_proc+ can used to avoid exposing private methods on #client. | ||
# It should run a command with the arguments sent to it, yield each | ||
# continuation payload, respond to the server with the result of each | ||
# yield, and return the result. Non-successful results *MUST* raise an | ||
# exception. Exceptions in the block *MUST* cause the command to fail. | ||
# | ||
# Subclasses that override #run_command may use #command_proc for | ||
# other purposes. | ||
def initialize(client, &command_proc) | ||
@client, @command_proc = client, command_proc | ||
end | ||
|
||
# Delegates to AuthenticationExchange.authenticate. | ||
def authenticate(...) AuthenticationExchange.authenticate(self, ...) end | ||
|
||
# Do the protocol and server both support an initial response? | ||
def sasl_ir_capable?; client.sasl_ir_capable? end | ||
|
||
# Does the server advertise support for the mechanism? | ||
def auth_capable?(mechanism); client.auth_capable?(mechanism) end | ||
|
||
# Runs the authenticate command with +mechanism+ and +initial_response+. | ||
# When +initial_response+ is nil, an initial response must NOT be sent. | ||
# | ||
# Yields each continuation payload, responds to the server with the | ||
# result of each yield, and returns the result. Non-successful results | ||
# *MUST* raise an exception. Exceptions in the block *MUST* cause the | ||
# command to fail. | ||
# | ||
# Subclasses that override this may use #command_proc differently. | ||
def run_command(mechanism, initial_response = nil, &block) | ||
command_proc or raise Error, "initialize with block or override" | ||
args = [command_name, mechanism, initial_response].compact | ||
command_proc.call(*args, &block) | ||
end | ||
|
||
# Returns an array of server responses errors raised by run_command. | ||
# Exceptions in this array won't drop the connection. | ||
def response_errors; [] end | ||
|
||
# Drop the connection gracefully. | ||
def drop_connection; client.drop_connection end | ||
|
||
# Drop the connection abruptly. | ||
def drop_connection!; client.drop_connection! end | ||
end | ||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
# frozen_string_literal: true | ||
|
||
module Net | ||
class IMAP | ||
module SASL | ||
|
||
module ProtocolAdapters | ||
# This API is experimental, and may change. | ||
module Generic | ||
def command_name; "AUTHENTICATE" end | ||
def service; raise "Implement in subclass or module" end | ||
def host; client.host end | ||
def port; client.port end | ||
def encode_ir(string) string.empty? ? "=" : encode(string) end | ||
def encode(string) [string].pack("m0") end | ||
def decode(string) string.unpack1("m0") end | ||
def cancel_response; "*" end | ||
end | ||
|
||
# See RFC-3501 (IMAP4rev1), RFC-4959 (SASL-IR capability), | ||
# and RFC-9051 (IMAP4rev2). | ||
module IMAP | ||
include Generic | ||
def service; "imap" end | ||
end | ||
|
||
# See RFC-4954 (AUTH capability). | ||
module SMTP | ||
include Generic | ||
def command_name; "AUTH" end | ||
def service; "smtp" end | ||
end | ||
|
||
# See RFC-5034 (SASL capability). | ||
module POP | ||
include Generic | ||
def command_name; "AUTH" end | ||
def service; "pop" end | ||
end | ||
|
||
end | ||
|
||
end | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
# frozen_string_literal: true | ||
|
||
module Net | ||
class IMAP | ||
|
||
# Experimental | ||
class SASLAdapter < SASL::ClientAdapter | ||
include SASL::ProtocolAdapters::IMAP | ||
|
||
RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError] | ||
.freeze | ||
|
||
def response_errors; RESPONSE_ERRORS end | ||
def sasl_ir_capable?; client.capable?("SASL-IR") end | ||
def auth_capable?(mechanism); client.auth_capable?(mechanism) end | ||
def drop_connection; client.logout! end | ||
def drop_connection!; client.disconnect end | ||
end | ||
|
||
end | ||
end |