From 1a270dc05cc368000f3545975befa0589031b684 Mon Sep 17 00:00:00 2001 From: Lance Stout Date: Fri, 28 Jan 2011 00:49:37 -0500 Subject: [PATCH] First pass at re-worked stream features. Stream features now use stanza objects! Features are given a ranking that expresses the dependency relationships (since only one feature is negotiated at a time, the dependency graph can be replaced by a line). >>> xmpp.register_feature('my_feature', _my_handler, >>> restart=True, # Requires stream restart >>> order=600) # Ranking (out of ~ 10,000, >>> # lower #'s executed first) SASL mechanisms may now be added or disabled as needed. Each mechanism is given a priority value indicating the order in which the client wishes for mechanisms to be tried. Higher priority numbers are executed first. >>> xmpp.register_sasl_mechanism('SASL-MECH', _mech_handler, >>> priority=0) Disabling a SASL mechanism: >>> xmpp.remove_sasl_mechanism('ANONYMOUS') --- sleekxmpp/clientxmpp.py | 355 ++++++++++++++++------------ sleekxmpp/stanza/__init__.py | 3 + sleekxmpp/stanza/bind.py | 26 ++ sleekxmpp/stanza/sasl.py | 104 ++++++++ sleekxmpp/stanza/session.py | 25 ++ sleekxmpp/stanza/stream_features.py | 52 ++++ sleekxmpp/stanza/tls.py | 50 ++++ 7 files changed, 470 insertions(+), 145 deletions(-) create mode 100644 sleekxmpp/stanza/bind.py create mode 100644 sleekxmpp/stanza/sasl.py create mode 100644 sleekxmpp/stanza/session.py create mode 100644 sleekxmpp/stanza/stream_features.py create mode 100644 sleekxmpp/stanza/tls.py diff --git a/sleekxmpp/clientxmpp.py b/sleekxmpp/clientxmpp.py index a1813985..e05f8e7b 100644 --- a/sleekxmpp/clientxmpp.py +++ b/sleekxmpp/clientxmpp.py @@ -18,9 +18,11 @@ import threading from sleekxmpp import plugins from sleekxmpp import stanza from sleekxmpp.basexmpp import BaseXMPP -from sleekxmpp.stanza import Message, Presence, Iq +from sleekxmpp.stanza import * +from sleekxmpp.stanza import tls +from sleekxmpp.stanza import sasl from sleekxmpp.xmlstream import XMLStream, RestartStream -from sleekxmpp.xmlstream import StanzaBase, ET +from sleekxmpp.xmlstream import StanzaBase, ET, register_stanza_plugin from sleekxmpp.xmlstream.matcher import * from sleekxmpp.xmlstream.handler import * @@ -92,14 +94,24 @@ class ClientXMPP(BaseXMPP): self.stream_footer = "" self.features = [] - self.registered_features = [] + self._stream_feature_handlers = {} + self._stream_feature_order = [] + self._sasl_mechanism_handlers = {} + self._sasl_mechanism_priorities = [] #TODO: Use stream state here self.authenticated = False self.sessionstarted = False self.bound = False self.bindfail = False - self.add_event_handler('connected', self.handle_connected) + + self.add_event_handler('connected', self._handle_connected) + + self.register_stanza(StreamFeatures) + self.register_stanza(tls.Proceed) + self.register_stanza(sasl.Success) + self.register_stanza(sasl.Failure) + self.register_stanza(sasl.Auth) self.register_handler( Callback('Stream Features', @@ -112,32 +124,25 @@ class ClientXMPP(BaseXMPP): 'jabber:iq:roster')), self._handle_roster)) - self.register_feature( - "", - self._handle_starttls, True) - self.register_feature( - "", - self._handle_sasl_auth, True) - self.register_feature( - "", - self._handle_bind_resource) - self.register_feature( - "", - self._handle_start_session) + self.register_feature('starttls', self._handle_starttls, + restart=True, + order=0) + self.register_feature('mechanisms', self._handle_sasl_auth, + restart=True, + order=100) + self.register_feature('bind', self._handle_bind_resource, + restart=False, + order=10000) + self.register_feature('session', self._handle_start_session, + restart=False, + order=10001) - def handle_connected(self, event=None): - #TODO: Use stream state here - self.authenticated = False - self.sessionstarted = False - self.bound = False - self.bindfail = False - self.schedule("session timeout checker", 15, - self._session_timeout_check) - - def _session_timeout_check(self): - if not self.session_started_event.isSet(): - log.debug("Session start has taken more than 15 seconds") - self.disconnect(reconnect=self.auto_reconnect) + self.register_sasl_mechanism('PLAIN', + self._handle_sasl_plain, + priority=1) + self.register_sasl_mechanism('ANONYMOUS', + self._handle_sasl_plain, + priority=0) def connect(self, address=tuple(), reattempt=True): """ @@ -197,19 +202,54 @@ class ClientXMPP(BaseXMPP): return XMLStream.connect(self, address[0], address[1], use_tls=True, reattempt=reattempt) - def register_feature(self, mask, pointer, breaker=False): + def register_feature(self, name, handler, restart=False, order=5000): """ Register a stream feature. Arguments: - mask -- An XML string matching the feature's element. - pointer -- The function to execute if the feature is received. - breaker -- Indicates if feature processing should halt with + name -- The name of the stream feature. + handler -- The function to execute if the feature is received. + restart -- Indicates if feature processing should halt with this feature. Defaults to False. + order -- The relative ordering in which the feature should + be negotiated. Lower values will be attempted + earlier when available. """ - self.registered_features.append((MatchXMLMask(mask), - pointer, - breaker)) + self._stream_feature_handlers[name] = (handler, restart) + self._stream_feature_order.append((order, name)) + self._stream_feature_order.sort() + + def register_sasl_mechanism(self, name, handler, priority=0): + """ + Register a handler for a SASL authentication mechanism. + + Arguments: + name -- The name of the mechanism (all caps) + handler -- The function that will perform the + authentication. The function must + return True if it is able to carry + out the authentication, False if + a required condition is not met. + priority -- An integer value indicating the + preferred ordering for the mechanism. + High values will be attempted first. + """ + self._sasl_mechanism_handlers[name] = handler + self._sasl_mechanism_priorities.append((priority, name)) + self._sasl_mechanism_priorities.sort(reverse=True) + + def remove_sasl_mechanism(self, name): + """ + Remove support for a given SASL authentication mechanism. + + Arguments: + name -- The name of the mechanism to remove (all caps) + """ + if name in self._sasl_mechanism_handlers: + del self._sasl_mechanism_handlers[name] + + p = self._sasl_mechanism_priorities + self._sasl_mechanism_priorities = [i for i in p if i[1] != name] def update_roster(self, jid, name=None, subscription=None, groups=[]): """ @@ -223,7 +263,8 @@ class ClientXMPP(BaseXMPP): to 'remove', the entry will be deleted. groups -- The roster groups that contain this item. """ - iq = self.Iq()._set_stanza_values({'type': 'set'}) + iq = self.Iq() + iq['type'] = 'set' iq['roster']['items'] = {jid: {'name': name, 'subscription': subscription, 'groups': groups}} @@ -242,10 +283,27 @@ class ClientXMPP(BaseXMPP): def get_roster(self): """Request the roster from the server.""" - iq = self.Iq()._set_stanza_values({'type': 'get'}).enable('roster') + iq = self.Iq() + iq['type'] = 'get' + iq.enable('roster') response = iq.send() self._handle_roster(response, request=True) + def _handle_connected(self, event=None): + #TODO: Use stream state here + self.authenticated = False + self.sessionstarted = False + self.bound = False + self.bindfail = False + self.features = [] + + def session_timeout(): + if not self.session_started_event.isSet(): + log.debug("Session start has taken more than 15 seconds") + self.disconnect(reconnect=self.auto_reconnect) + + self.schedule("session timeout checker", 15, session_timeout) + def _handle_stream_features(self, features): """ Process the received stream features. @@ -253,167 +311,174 @@ class ClientXMPP(BaseXMPP): Arguments: features -- The features stanza. """ - # Record all of the features. - self.features = [] - for sub in features.xml: - self.features.append(sub.tag) + for order, name in self._stream_feature_order: + if name in features['features']: + handler, restart = self._stream_feature_handlers[name] + if handler(features) and restart: + # Don't continue if the feature requires + # restarting the XML stream. + return True - # Process the features. - for sub in features.xml: - for feature in self.registered_features: - mask, handler, halt = feature - if mask.match(sub): - if handler(sub) and halt: - # Don't continue if the feature was - # marked as a breaker. - return True - - def _handle_starttls(self, xml): + def _handle_starttls(self, features): """ Handle notification that the server supports TLS. Arguments: - xml -- The STARTLS proceed element. + features -- The stream:features element. """ - if not self.authenticated and self.ssl_support: - tls_ns = 'urn:ietf:params:xml:ns:xmpp-tls' - self.add_handler("" % tls_ns, - self._handle_tls_start, - name='TLS Proceed', - instream=True) - self.send_xml(xml) + + def tls_proceed(proceed): + """Restart the XML stream when TLS is accepted.""" + log.debug("Starting TLS") + if self.start_tls(): + self.features.append('starttls') + raise RestartStream() + + if self.ssl_support: + self.register_handler( + Callback('STARTTLS Proceed', + MatchXPath(tls.Proceed.tag_name()), + tls_proceed, + instream=True)) + self.send(features['starttls']) return True else: log.warning("The module tlslite is required to log in" +\ " to some servers, and has not been found.") return False - def _handle_tls_start(self, xml): - """ - Handle encrypting the stream using TLS. - - Restarts the stream. - """ - log.debug("Starting TLS") - if self.start_tls(): - raise RestartStream() - - def _handle_sasl_auth(self, xml): + def _handle_sasl_auth(self, features): """ Handle authenticating using SASL. Arguments: - xml -- The SASL mechanisms stanza. + features -- The stream features stanza. """ - if '{urn:ietf:params:xml:ns:xmpp-tls}starttls' in self.features: - return False - log.debug("Starting SASL Auth") - sasl_ns = 'urn:ietf:params:xml:ns:xmpp-sasl' - self.add_handler("" % sasl_ns, - self._handle_auth_success, - name='SASL Sucess', - instream=True) - self.add_handler("" % sasl_ns, - self._handle_auth_fail, - name='SASL Failure', - instream=True) + def sasl_success(stanza): + """SASL authentication succeeded. Restart the stream.""" + self.authenticated = True + self.features.append('mechanisms') + raise RestartStream() - sasl_mechs = xml.findall('{%s}mechanism' % sasl_ns) - if sasl_mechs: - for sasl_mech in sasl_mechs: - self.features.append("sasl:%s" % sasl_mech.text) - if 'sasl:PLAIN' in self.features and self.boundjid.user: - if sys.version_info < (3, 0): - user = bytes(self.boundjid.user) - password = bytes(self.password) - else: - user = bytes(self.boundjid.user, 'utf-8') - password = bytes(self.password, 'utf-8') + def sasl_fail(stanza): + """SASL authentication failed. Disconnect and shutdown.""" + log.info("Authentication failed.") + self.event("failed_auth", direct=True) + self.disconnect() + log.debug("Starting SASL Auth") + return True - auth = base64.b64encode(b'\x00' + user + \ - b'\x00' + password).decode('utf-8') + self.register_handler( + Callback('SASL Success', + MatchXPath(sasl.Success.tag_name()), + sasl_success, + instream=True, + once=True)) + + self.register_handler( + Callback('SASL Failure', + MatchXPath(sasl.Failure.tag_name()), + sasl_fail, + instream=True, + once=True)) + + for priority, mech in self._sasl_mechanism_priorities: + if mech in self._sasl_mechanism_handlers: + handler = self._sasl_mechanism_handlers[mech] + if handler(self): + break + else: + log.error("No appropriate login method.") + self.disconnect() - self.send("%s" % ( - sasl_ns, - auth)) - elif 'sasl:ANONYMOUS' in self.features and not self.boundjid.user: - self.send("" % ( - sasl_ns, - 'ANONYMOUS')) - else: - log.error("No appropriate login method.") - self.disconnect() return True - def _handle_auth_success(self, xml): + def _handle_sasl_plain(self, xmpp): """ - SASL authentication succeeded. Restart the stream. + Attempt to authenticate using SASL PLAIN. Arguments: - xml -- The SASL authentication success element. + xmpp -- The SleekXMPP connection instance. """ - self.authenticated = True - self.features = [] - raise RestartStream() + if not xmpp.boundjid.user: + return False - def _handle_auth_fail(self, xml): + if sys.version_info < (3, 0): + user = bytes(self.boundjid.user) + password = bytes(self.password) + else: + user = bytes(self.boundjid.user, 'utf-8') + password = bytes(self.password, 'utf-8') + + auth = base64.b64encode(b'\x00' + user + \ + b'\x00' + password).decode('utf-8') + + resp = sasl.Auth(xmpp) + resp['mechanism'] = 'PLAIN' + resp['value'] = auth + resp.send() + + return True + + def _handle_sasl_anonymous(self, xmpp): """ - SASL authentication failed. Disconnect and shutdown. + Attempt to authenticate using SASL ANONYMOUS. Arguments: - xml -- The SASL authentication failure element. + xmpp -- The SleekXMPP connection instance. """ - log.info("Authentication failed.") - self.event("failed_auth", direct=True) - self.disconnect() + if xmpp.boundjid.user: + return False - def _handle_bind_resource(self, xml): + resp = sasl.Auth(xmpp) + resp['mechanism'] = 'ANONYMOUS' + resp.send() + + return True + + def _handle_bind_resource(self, features): """ Handle requesting a specific resource. Arguments: - xml -- The bind feature element. + features -- The stream features stanza. """ log.debug("Requesting resource: %s" % self.boundjid.resource) - xml.clear() - iq = self.Iq(stype='set') + iq = self.Iq() + iq['type'] = 'set' + iq.enable('bind') if self.boundjid.resource: - res = ET.Element('resource') - res.text = self.boundjid.resource - xml.append(res) - iq.append(xml) + iq['bind']['resource'] = self.boundjid.resource response = iq.send() - bind_ns = 'urn:ietf:params:xml:ns:xmpp-bind' - self.set_jid(response.xml.find('{%s}bind/{%s}jid' % (bind_ns, - bind_ns)).text) + self.set_jid(response['bind']['jid']) self.bound = True + log.info("Node set to: %s" % self.boundjid.full) - session_ns = 'urn:ietf:params:xml:ns:xmpp-session' - if "{%s}session" % session_ns not in self.features or self.bindfail: + + if 'session' not in features['features']: log.debug("Established Session") self.sessionstarted = True self.session_started_event.set() self.event("session_start") - def _handle_start_session(self, xml): + def _handle_start_session(self, features): """ Handle the start of the session. Arguments: - xml -- The session feature element. + feature -- The stream features element. """ - if self.authenticated and self.bound: - iq = self.makeIqSet(xml) - response = iq.send() - log.debug("Established Session") - self.sessionstarted = True - self.session_started_event.set() - self.event("session_start") - else: - # Bind probably hasn't happened yet. - self.bindfail = True + iq = self.Iq() + iq['type'] = 'set' + iq.enable('session') + response = iq.send() + + log.debug("Established Session") + self.sessionstarted = True + self.session_started_event.set() + self.event("session_start") def _handle_roster(self, iq, request=False): """ diff --git a/sleekxmpp/stanza/__init__.py b/sleekxmpp/stanza/__init__.py index dbf7b86f..ef44dfb2 100644 --- a/sleekxmpp/stanza/__init__.py +++ b/sleekxmpp/stanza/__init__.py @@ -12,3 +12,6 @@ from sleekxmpp.stanza.stream_error import StreamError from sleekxmpp.stanza.iq import Iq from sleekxmpp.stanza.message import Message from sleekxmpp.stanza.presence import Presence +from sleekxmpp.stanza.stream_features import StreamFeatures +from sleekxmpp.stanza.bind import Bind + diff --git a/sleekxmpp/stanza/bind.py b/sleekxmpp/stanza/bind.py new file mode 100644 index 00000000..ae1f96f0 --- /dev/null +++ b/sleekxmpp/stanza/bind.py @@ -0,0 +1,26 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Bind(ElementBase): + + """ + """ + + name = 'bind' + namespace = 'urn:ietf:params:xml:ns:xmpp-bind' + interfaces = set(('resource', 'jid')) + sub_interfaces = interfaces + plugin_attrib = 'bind' + + +register_stanza_plugin(Iq, Bind) +register_stanza_plugin(StreamFeatures, Bind) diff --git a/sleekxmpp/stanza/sasl.py b/sleekxmpp/stanza/sasl.py new file mode 100644 index 00000000..e55a72ad --- /dev/null +++ b/sleekxmpp/stanza/sasl.py @@ -0,0 +1,104 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Mechanisms(ElementBase): + + """ + """ + + name = 'mechanisms' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('mechanisms', 'required')) + plugin_attrib = name + is_extension = True + + def get_required(self): + """ + """ + return True + + def get_mechanisms(self): + """ + """ + results = [] + mechs = self.findall('{%s}mechanism' % self.namespace) + if mechs: + for mech in mechs: + results.append(mech.text) + return results + + def set_mechanisms(self, values): + """ + """ + self.del_mechanisms() + for val in values: + mech = ET.Element('{%s}mechanism' % self.namespace) + mech.text = val + self.append(mech) + + def del_mechanisms(self): + """ + """ + mechs = self.findall('{%s}mechanism' % self.namespace) + if mechs: + for mech in mechs: + self.xml.remove(mech) + + +class Success(StanzaBase): + + """ + """ + + name = 'success' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set() + plugin_attrib = name + + +class Failure(StanzaBase): + + """ + """ + + name = 'failure' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set() + plugin_attrib = name + + +class Auth(StanzaBase): + + """ + """ + + name = 'auth' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('mechanism', 'value')) + plugin_attrib = name + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def set_value(self, value): + self.xml.text = value + + def get_value(self): + return self.xml.text + + def del_value(self): + self.xml.text = '' + + +register_stanza_plugin(StreamFeatures, Mechanisms) diff --git a/sleekxmpp/stanza/session.py b/sleekxmpp/stanza/session.py new file mode 100644 index 00000000..b7b175d2 --- /dev/null +++ b/sleekxmpp/stanza/session.py @@ -0,0 +1,25 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Session(ElementBase): + + """ + """ + + name = 'bind' + namespace = 'urn:ietf:params:xml:ns:xmpp-session' + interfaces = set() + plugin_attrib = 'session' + + +register_stanza_plugin(Iq, Session) +register_stanza_plugin(StreamFeatures, Session) diff --git a/sleekxmpp/stanza/stream_features.py b/sleekxmpp/stanza/stream_features.py new file mode 100644 index 00000000..5be2e55f --- /dev/null +++ b/sleekxmpp/stanza/stream_features.py @@ -0,0 +1,52 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class StreamFeatures(StanzaBase): + + """ + """ + + name = 'features' + namespace = 'http://etherx.jabber.org/streams' + interfaces = set(('features', 'required', 'optional')) + sub_interfaces = interfaces + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.values = self.values + + def get_features(self): + """ + """ + return self.plugins + + def set_features(self, value): + """ + """ + pass + + def del_features(self): + """ + """ + pass + + def get_required(self): + """ + """ + features = self['features'] + return [f for n, f in features.items() if f['required']] + + def get_optional(self): + """ + """ + features = self['features'] + return [f for n, f in features.items() if not f['required']] diff --git a/sleekxmpp/stanza/tls.py b/sleekxmpp/stanza/tls.py new file mode 100644 index 00000000..d85f9b49 --- /dev/null +++ b/sleekxmpp/stanza/tls.py @@ -0,0 +1,50 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import StanzaBase, ElementBase +from sleekxmpp.xmlstream import register_stanza_plugin + + +class STARTTLS(ElementBase): + + """ + """ + + name = 'starttls' + namespace = 'urn:ietf:params:xml:ns:xmpp-tls' + interfaces = set(('required',)) + plugin_attrib = name + + def get_required(self): + """ + """ + return True + + +class Proceed(StanzaBase): + + """ + """ + + name = 'proceed' + namespace = 'urn:ietf:params:xml:ns:xmpp-tls' + interfaces = set() + + +class Failure(StanzaBase): + + """ + """ + + name = 'failure' + namespace = 'urn:ietf:params:xml:ns:xmpp-tls' + interfaces = set() + + +register_stanza_plugin(StreamFeatures, STARTTLS)