Skip to content

Commit 2be1fa7

Browse files
committed
♻️ SASL: Add Authenticator base class w/📚docs [🚧TODO: test base class]
* Add many many docs * Add properties, events, and callbacks * Add initial_response? * Add AuthenticationFailure for failures detected by the SASL mechanism. This is different from failures detected by the application protocol, such as when the IMAP server responds with "NO" or "BAD". Currently unused, but existing authenticators will be refactored based on this, and new authenticators will be writte on it.
1 parent 7d99663 commit 2be1fa7

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed

lib/net/imap/sasl.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ module SASL
6060
autoload :BidiStringError, stringprep_path
6161

6262
sasl_dir = File.expand_path("sasl", __dir__)
63+
autoload :Authenticator, "#{sasl_dir}/authenticator"
6364
autoload :Authenticators, "#{sasl_dir}/authenticators"
6465

6566
# Authenticators are all lazy loaded
@@ -79,6 +80,16 @@ def self.authenticator(...) authenticators.authenticator(...) end
7980
# See SASL::add_authenticator
8081
def self.add_authenticator(...) authenticators.add_authenticator(...) end
8182

83+
# Error raised when the client SASL::Authenticator determines that it
84+
# cannot complete successfully during a call to Authenticator#process.
85+
#
86+
# Note that most \SASL mechanisms cannot detect or report errors until the
87+
# protocol-specific outcome message, e.g. a tagged response in \IMAP.
88+
# Those authentication errors will be handled or raised by the protocol
89+
# client, e.g. a Net::IMAP::NoResponseError.
90+
class AuthenticationFailure < Error
91+
end
92+
8293
module_function
8394

8495
# See Net::IMAP::StringPrep::SASLprep#saslprep.

lib/net/imap/sasl/authenticator.rb

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP < Protocol
5+
module SASL
6+
7+
# A base class for authentication exchange sessions by a SASL client. A new
8+
# authenticator session object must be created for every authentication
9+
# attempt. Specific SASL mechanisms are implemented by subclasses.
10+
# Although it isn't necessary to inherit from this base class, it enables
11+
# useful functionality that is common to all authenticators, e.g. property
12+
# callbacks.
13+
#
14+
# Each mechanism requires and allows different properties and callbacks;
15+
# please consult the documentation for the specific mechanisms you are
16+
# using. See the SASL module documentation for the list of mechanism
17+
# subclasses.
18+
#
19+
# == Properties
20+
#
21+
# When inheriting classes use rdoc-ref:Authenticator.property and
22+
# #propinit, every property will be configurable by calling
23+
# new[rdoc-ref:.new] with the matching keyword argument. Common
24+
# properties might also allowed as positional arguments. See
25+
# Authenticator@Typical+properties and the documentation on each
26+
# authenticator subclass.
27+
#
28+
# Assigning a Proc or Method will set a callback to lazy load the property
29+
# value the first time it is used. A block sent to new[rdoc-ref:.new]
30+
# will be the default callback for all unassigned properties. See
31+
# Authenticator@Callbacks.
32+
#
33+
# As a special type of "property", some authenticators may support event
34+
# callbacks. These might be assigned by a keyword parameter with the same
35+
# name, or they might only be sent to the default callback. Unlike
36+
# properties, the callback might be called more than once. Authenticator
37+
# implementations should also document all events that are sent.
38+
#
39+
# === Typical properties
40+
#
41+
# The following is not an exhaustive list of properties, and each \SASL
42+
# mechanism only uses a few of these. More detailed descriptions may be
43+
# found in the specific authenticators' documentation. <em>Some of the
44+
# mechanisms that use these properties are not implemented yet.</em>
45+
#
46+
# ==== Auth identity properties
47+
# * +authcid+ --- authentication identity, owner of the credentials;
48+
# sometimes aliased as +username+
49+
# * +authzid+ --- authorization identity, to act as or on behalf of;
50+
# often identical to +authcid+ or inferred from credentials
51+
# * +realm+ --- namespace for identities, e.g. a domain name
52+
# * +anonymous_message+ --- optional token for the +ANONYMOUS+ mechanism
53+
#
54+
# ==== Credentials properties
55+
#
56+
# * +password+ --- password or passphrase, used by many mechanisms
57+
# * +oauth2_token+ --- OAuth2.0 access token, used by +XOAUTH2+ and
58+
# +OAUTHBEARER+
59+
# * +scram_sha1_salted_passwords+, +scram_sha256_salted_password+ ---
60+
# Salted password(s) (with salt and iteration count) for the +SCRAM-*+
61+
# mechanism family. <tt>[salt, iterations, pbkdf2_hmac]</tt> tuple.
62+
# <em>(not implemented yet...)</em>
63+
# * +passcode+ --- passcode for SecurID 2FA <em>(not implemented)</em>
64+
# * +pin+ --- Personal Identification number, e.g. for SecurID 2FA
65+
# <em>(not implemented)</em>
66+
#
67+
# ==== Requested service properties
68+
#
69+
# Some mechanisms need to know more about the service that the
70+
# authentication is for.
71+
#
72+
# * +service+ --- {registered GSSAPI service
73+
# name}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml],
74+
# e.g. "imap", "smtp", "ldap", "xmpp"
75+
# * +host+ --- FQDN used to access the requested service
76+
# * +port+ --- port number used to access the requested service
77+
#
78+
#--
79+
# TODO: Authenticators#authenticator should:
80+
# * use #sasl_service, #host, and #port (maybe make these configurable,
81+
# e.g. #hostname vs #host) to automatically fill in the correct values,
82+
# per connection.
83+
# * inspect the authenticator's parameters and automatically send these
84+
# parameters IFF they are explicit keyword arguments.
85+
#++
86+
#
87+
# === Callbacks
88+
#
89+
# Callback procs might be used for interactivity, e.g. to present the user
90+
# with a choice from a server-provided list of realms, or to ask the user
91+
# for a password interactively only after securely connecting to the
92+
# server and selecting a password-based mechanism. It might also be used
93+
# to fetch credentials from a KMS only when necessary, or to audit every
94+
# use of a secret.
95+
#
96+
# Callbacks must return +nil+ for unhandled properties or events.
97+
#
98+
# ==== Example: Interactive password from password manager
99+
# service = { service: "imap", host: "imap.example.com", port: 993 }
100+
# auth = authenticator("DIGEST-MD5", "user", **service) {|ctx, attr|
101+
# case attr
102+
# when :password then password_manager.prompt(**service)
103+
# else
104+
# # etc...
105+
# end
106+
# }
107+
#
108+
# ==== Example: Log every time sensitive fields are accessed
109+
# auth = authenticator(mechanism, **nonsecret_attrs) do |attr, ctx|
110+
# case attr
111+
# when :password, :pin, :oauth2_token, :passphrase,
112+
# :scram_salted_password
113+
# log_sensitive ctx.authcid, attr
114+
# secrets_for(ctx.authcid).fetch(attr)
115+
# end
116+
# nil
117+
# end
118+
#
119+
# ==== Example: some mechanisms need event handlers to finish the exchange
120+
# auth = authenticator("OPENID20", **user_auth_attrs) do |attr, ctx|
121+
# case attr
122+
# when :openid20_authenticate_in_browser
123+
# # an OPENID20 mechanism (not implemented yet) would need to
124+
# # update ctx with values sent by the server challenge:
125+
# browser.open ctx.openid20_redirect_url
126+
# wait_for browser_auth_completion
127+
# end
128+
# end
129+
#
130+
# == Initial Response
131+
#
132+
# Some protocols and some mechanisms support sending an "initial response"
133+
# from the client, before any server challenge. When both the protocol
134+
# client and the authenticator agree to an initial response, the
135+
# authenticator should be called the first time with +nil+ as the "server
136+
# challenge". See rdoc-ref:Authenticator.initial_response?,
137+
# #initial_response?, and #process for more details.
138+
#
139+
class Authenticator
140+
141+
class << self
142+
private
143+
144+
# Defines an authenticator "property"
145+
def property(name) # :doc:
146+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
147+
def #{name} # def password
148+
propget(:#{name}) # propget(:password)
149+
end # end
150+
151+
def #{name}=(value) # def password=(value)
152+
propset(:#{name}, value) # propset(:password, value)
153+
end # end
154+
RUBY
155+
end
156+
end
157+
158+
# A Hash containing all _resolved_ properties for an Authenticator.
159+
# Lazy loaded properties will have an entry in #callbacks. Use the
160+
# named property method to load that property using #callbacks.
161+
attr_reader :properties
162+
163+
# A Hash of callback procs to lazy-load properties or handle events.
164+
# The hash's default value is #callback.
165+
#
166+
# Callbacks are sent the property (or event) name and the authenticator
167+
# object. The authenticator can use itself to pass more information to
168+
# event handlers (e.g. a list of server-sent realms or an authentication
169+
# URL).
170+
attr_reader :callbacks
171+
172+
# The generic callback, called for otherwise unconfigured properties and
173+
# events. Used as the default value for #callbacks.
174+
attr_reader :callback
175+
176+
# :call-seq:
177+
# new -> authenticator
178+
# new(username, password) -> authenticator
179+
# new(authcid, secret, authzid) -> authenticator
180+
# new(*credentials) -> authenticator
181+
# new(**properties, **callbacks) -> authenticator
182+
# new {|name, authenticator| prop_value } -> authenticator
183+
# new(*creds, **props) {|name, authenticator| v } -> authenticator
184+
#
185+
# Creates a new SASL client authenticator. The call signatures listed
186+
# here are the _recommended_ styles, but each mechanism requires and
187+
# allows different arguments. <em>Each authenticator class must
188+
# document its specific properties and callbacks.</em> See
189+
# Authenticator@Properties for generic property descriptions.
190+
#
191+
# Client users should not create authenticators directly, but should
192+
# instead use their client's authentication command, e.g.
193+
# Net::IMAP#authenticate for Net::IMAP.
194+
#
195+
# Client authors should not create authenticators directly, but should
196+
# instead use Authenticators#authenticator, which delegates to the
197+
# authenticator that has been registered for the requested mechanism.
198+
#
199+
# ==== Inheriting
200+
#
201+
# <em>Each authenticator class must document its specific properties and
202+
# callbacks.</em>
203+
#
204+
# Inheriting classes must call +super+ before initializing or using
205+
# properties. \Authenticators should have a keyword parameter for every
206+
# accepted property or callback and should ignore any unused keyword
207+
# parameters. \Authenticators may also have one or more positional
208+
# parameters for the most common properties---especially credentials
209+
# like username and password. \Authenticators should raise an
210+
# ArgumentError for unhandled positional arguments or when a property is
211+
# set through multiple arguments.
212+
#
213+
def initialize(*, **, &callback)
214+
@callback = callback
215+
@callbacks = Hash.new { @callback }
216+
@properties = {}
217+
end
218+
219+
# :call-seq:
220+
# obj.process(nil) -> initial_response_string
221+
# obj.process(server_challenge_string) -> client_response_string
222+
#
223+
# Generates a \SASL client reply to a server challenge, as part of a
224+
# \SASL authentication exchange. Protocol clients will call +#process+
225+
# with every server challenge and send the result back to the server,
226+
# until the \SASL exchange is done.
227+
#
228+
# If the protocol client supports an initial client response, it will
229+
# check if the authenticator responds to +initial_response?+ with
230+
# +true+. When the mechanism and the protocol both support an initial
231+
# client response, the client should send +nil+ as the first "server
232+
# challenge".
233+
#
234+
# The protocol client is responsible for protocol-specific decoding of the
235+
# +server_challenge_string+ sent from the server and encoding of the
236+
# +client_response_string+ sent to the server. E.g. \IMAP uses Base64.
237+
#
238+
# If a failed exchange can be detected from the server challenge,
239+
# +#process+ should raise an exception. The Authenticator alone cannot
240+
# determine if an exchange was successful. The protocol client must be
241+
# responsible for handling protocol-level errors (e.g. a +NO+ or +BAD+
242+
# response in IMAP). The \SASL exchange is unsuccessful if _either_ the
243+
# Authenticator or the protocol client report an error.
244+
#
245+
# See PlainAuthenticator or DigestMD5Authenticator for example
246+
# authenticator implementations.
247+
def process(server_challenge_string)
248+
raise NotImplementedError, "#{__method__} is defined by subclasses"
249+
end
250+
251+
# :call-seq:
252+
# initial_response? -> true or false
253+
#
254+
# Returns +true+ when the authenticator supports an "initial response"
255+
# from the client. If this is +true+ and the protocol client also
256+
# supports an initial response, then #process should be called with
257+
# +nil+ the first time.
258+
#
259+
# Automatically returns +true+ when the object responds to
260+
# +initial_client_response+.
261+
#
262+
# Note::
263+
# Implementing #initial_response? or #initial_client_response or
264+
# inheriting from Authenticator are all optional. Clients should
265+
# check that the authenticator responds to +initial_response?+ before
266+
# calling it.
267+
def initial_response?; respond_to?(:initial_client_response) end
268+
269+
private
270+
271+
# Use this instead of #propset when a property may be set by multiple
272+
# sources, e.g. a positional parameter or a keyword parameter or a
273+
# keyword alias, etc.
274+
#
275+
# If +values+ has multiple non-nil members then an ArgumentError will be
276+
# raised, so the order is not semantically significant. With no non-nil
277+
# members, the property will be left unset.
278+
#
279+
# Send +required: true+ to raise an ArgumentError when all of the
280+
# +values+ are +nil+.
281+
def propinit(name, *values, required: false) # :doc:
282+
values.compact!
283+
if values.length > 1
284+
raise ArgumentError, "multiple conflicting arguments for #{name}"
285+
elsif !values.empty?
286+
send :"#{name}=", values.first
287+
elsif required && !callback
288+
raise ArgumentError, "%s needs %s" % [self.class, name]
289+
end
290+
end
291+
292+
# Used internally to assign properties. The autogenerated property
293+
# writers use #propset.
294+
#
295+
# Subclasses can override this to add validation, coercion, etc
296+
def propset(name, val) # :doc:
297+
case val
298+
when nil then properties.delete name; callbacks.delete name
299+
when Proc, Method then properties.delete name; callbacks[name] = val
300+
else callbacks.delete name; properties[name] = val
301+
end
302+
val
303+
end
304+
305+
# Returns whether the property has been set, either to a value or a
306+
# callback.
307+
def propset?(name) # :doc:
308+
properties.key?(name) || callbacks.key?(name)
309+
end
310+
311+
# Used internally to fetch or lazy load properties, using #event and
312+
# #propset. The autogenerated property readers use #propget.
313+
def propget(name, &default) # :doc:
314+
properties.fetch(name) {
315+
val = event(name)
316+
propset(name, val.nil? ? default&.call : val)
317+
}
318+
end
319+
320+
# Use #event to trigger a specific callback, without updating the
321+
# authenticator's state. Accepts no other arguments, but "event
322+
# parameters" can be passed via authenticator attributes or properties.
323+
#
324+
# #propget uses #event internally for lazy-loaded properties, and then
325+
# assigns the return value to the property with that +name+.
326+
def event(name) # :doc:
327+
callbacks[name]&.call(name, self)
328+
end
329+
330+
end
331+
end
332+
333+
end
334+
end

0 commit comments

Comments
 (0)