source: ralphm-patches/client_listen_authenticator.patch

Last change on this file was 72:727b4d29c48e, checked in by Ralph Meijer <ralphm@…>, 9 years ago

Major reworking of avatars, session manager and stanza handlers.

File size: 35.4 KB
RevLine 
[54]1# HG changeset patch
[72]2# Parent c22caa54600c4f85db2a400c7fbea5497f943aa1
[54]3Add authenticator for accepting XMPP client connections.
4
[72]5The new authenticator XMPPClientListenAuthenticator is to be used
6together with an `XmlStream` created for an incoming XMPP stream. It
7uses the new initializers for SASL (PLAIN only), resource binding and
8session establishement.
[54]9
[65]10This authenticator needs at least one Twisted Cred portal to hold the
11domain served. After authenticating, an avatar and a logout callback are
12returned. Upon binding a resource, the avatar's `bindResource` method is
13called with the desired resource name. Upon stream disconnect, the
14logout callback is called.
[54]15
[65]16diff --git a/wokkel/client.py b/wokkel/client.py
17--- a/wokkel/client.py
18+++ b/wokkel/client.py
[66]19@@ -10,14 +10,27 @@
[54]20 that should probably eventually move there.
21 """
22 
23+import base64
24+
25 from twisted.application import service
[65]26-from twisted.internet import reactor
27+from twisted.cred import credentials, error as ecred
28+from twisted.internet import defer, reactor
[57]29+from twisted.python import log
[54]30 from twisted.names.srvconnect import SRVConnector
31-from twisted.words.protocols.jabber import client, sasl, xmlstream
32+from twisted.words.protocols.jabber import client, error, sasl, xmlstream
[57]33+from twisted.words.xish import domish
[54]34 
35 from wokkel import generic
[66]36+from wokkel.iwokkel import IUserSession
[54]37 from wokkel.subprotocols import StreamManager
38 
39+NS_CLIENT = 'jabber:client'
40+
41+XPATH_AUTH = "/auth[@xmlns='%s']" % sasl.NS_XMPP_SASL
42+XPATH_BIND = "/iq[@type='set']/bind[@xmlns='%s']" % client.NS_XMPP_BIND
43+XPATH_SESSION = "/iq[@type='set']/session[@xmlns='%s']" % \
44+                client.NS_XMPP_SESSION
45+
46 class CheckAuthInitializer(object):
47     """
48     Check what authentication methods are available.
[66]49@@ -51,7 +64,7 @@
[54]50     autentication.
51     """
52 
53-    namespace = 'jabber:client'
54+    namespace = NS_CLIENT
55 
56     def __init__(self, jid, password):
57         xmlstream.ConnectAuthenticator.__init__(self, jid.host)
[72]58@@ -186,3 +199,284 @@
[54]59     c = XMPPClientConnector(reactor, domain, factory)
60     c.connect()
61     return factory.deferred
62+
63+
64+
[65]65+class InvalidMechanism(Exception):
66+    """
67+    The requested SASL mechanism is invalid.
68+    """
[54]69+
[65]70+
71+
[66]72+class AuthorizationIdentifierNotSupported(Exception):
73+    """
74+    Authorization Identifiers are not supported.
75+    """
[65]76+
77+
[66]78+
79+class SASLReceivingInitializer(generic.BaseReceivingInitializer):
80+    """
81+    Stream initializer for SASL authentication, receiving side.
[72]82+
83+    This authenticator uses L{Twisted Cred<twisted.cred>}, the pluggable
84+    authentication system. As such it takes a
85+    L{Portal<twisted.cred.portal.Portal>} to select authentication mechanisms,
86+    creates a credential object for the selected authentication mechanism and
87+    passes it to the portal to login and acquire an avatar.
88+
89+    The avatar will be set on the C{avatar} attribute of the
90+    L{xmlstream.XmlStream}.
91+
92+    Currently, only the C{PLAIN} SASL mechanism is supported.
[66]93+    """
94+
[65]95+    required = True
[72]96+    _mechanisms = None
97+    __credentialsMap = {
98+        credentials.IAnonymous: 'ANONYMOUS',
99+        credentials.IUsernamePassword: 'PLAIN',
100+        }
[65]101+
[66]102+    def __init__(self, name, xs, portal):
103+        generic.BaseReceivingInitializer.__init__(self, name, xs)
[65]104+        self.portal = portal
[54]105+        self.failureGrace = 3
106+
107+
[65]108+    def getFeatures(self):
109+        feature = domish.Element((sasl.NS_XMPP_SASL, 'mechanisms'))
[72]110+
111+        # Advertise supported SASL mechanisms that have corresponding
112+        # checkers in the Portal.
113+        self._mechanisms = set()
114+        for interface in self.portal.listCredentialsInterfaces():
115+            try:
116+                mechanism = self.__credentialsMap[interface]
117+            except KeyError:
118+                pass
119+            else:
120+                self._mechanisms.add(mechanism)
121+                feature.addElement('mechanism', content=mechanism)
122+
[65]123+        return [feature]
[54]124+
125+
[65]126+    def initialize(self):
[66]127+        self.xmlstream.avatar = None
128+        self.xmlstream.addObserver(XPATH_AUTH, self._onAuth)
[65]129+        return self.deferred
[54]130+
131+
[66]132+    def _onAuth(self, auth):
133+        """
134+        Called when the start of the SASL negotiation is received.
135+
136+        @type auth: L{domish.Element}.
137+        """
[54]138+        auth.handled = True
139+
[65]140+        def cb(_):
141+            response = domish.Element((sasl.NS_XMPP_SASL, 'success'))
142+            self.xmlstream.send(response)
143+            self.xmlstream.reset()
[66]144+            self.deferred.callback(xmlstream.Reset)
[65]145+
146+        def eb(failure):
147+            if failure.check(ecred.UnauthorizedLogin):
148+                condition = 'not-authorized'
149+            elif failure.check(InvalidMechanism):
150+                condition = 'invalid-mechanism'
[66]151+            elif failure.check(AuthorizationIdentifierNotSupported):
152+                condition = 'invalid-authz'
[65]153+            else:
154+                log.err(failure)
155+                condition = 'temporary-auth-failure'
156+
157+            response = domish.Element((sasl.NS_XMPP_SASL, 'failure'))
158+            response.addElement(condition)
159+            self.xmlstream.send(response)
[54]160+
161+            # Close stream on too many failing authentication attempts
162+            self.failureGrace -= 1
163+            if self.failureGrace == 0:
[66]164+                self.deferred.errback(error.StreamError('policy-violation'))
165+            else:
166+                return
[54]167+
[66]168+        d = defer.maybeDeferred(self._doAuth, auth)
[65]169+        d.addCallbacks(cb, eb)
[54]170+
[65]171+
[72]172+    def _credentialsFrom_PLAIN(self, auth):
[66]173+        """
174+        Create credentials from the initial response for PLAIN.
175+        """
[54]176+        initialResponse = base64.b64decode(unicode(auth))
177+        authzid, authcid, passwd = initialResponse.split('\x00')
178+
[66]179+        if authzid:
180+            raise AuthorizationIdentifierNotSupported()
[54]181+
[66]182+        creds = credentials.UsernamePassword(username=authcid,
[65]183+                                             password=passwd)
184+        return creds
[54]185+
186+
[72]187+    def _credentialsFrom_ANONYMOUS(self, auth):
188+        """
189+        Create credentials from the initial response for ANONYMOUS.
190+        """
191+        return credentials.Anonymous()
192+
193+
[66]194+    def _doAuth(self, auth):
195+        """
196+        Start authentication.
197+        """
[72]198+        mechanism = auth.getAttribute('mechanism')
199+
200+        if mechanism not in self._mechanisms:
[65]201+            raise InvalidMechanism()
[54]202+
[72]203+        creds = getattr(self, '_credentialsFrom_' + mechanism)(auth)
[54]204+
[65]205+        def cb((iface, avatar, logout)):
206+            self.xmlstream.avatar = avatar
207+            self.xmlstream.addObserver(xmlstream.STREAM_END_EVENT,
208+                                       lambda _: logout())
209+
[66]210+        d = self.portal.login(creds, self.xmlstream, IUserSession)
[65]211+        d.addCallback(cb)
212+        return d
213+
214+
215+
[66]216+class BindReceivingInitializer(generic.BaseReceivingInitializer):
217+    """
218+    Stream initializer for resource binding, receiving side.
[72]219+
220+    Upon a request for resource binding, this will call C{bindResource} on
221+    the stream's avatar.
[66]222+    """
223+
[65]224+    required = True
225+
226+    def getFeatures(self):
227+        feature = domish.Element((client.NS_XMPP_BIND, 'bind'))
228+        return [feature]
229+
230+
231+    def initialize(self):
[54]232+        self.xmlstream.addOnetimeObserver(XPATH_BIND, self.onBind)
[65]233+        return self.deferred
[54]234+
235+
236+    def onBind(self, iq):
237+        def cb(boundJID):
238+            self.xmlstream.otherEntity = boundJID
239+
240+            response = xmlstream.toResponse(iq, 'result')
241+            response.addElement((client.NS_XMPP_BIND, 'bind'))
242+            response.bind.addElement((client.NS_XMPP_BIND, 'jid'),
[66]243+                                     content=boundJID.full())
[54]244+
245+            return response
246+
247+        iq.handled = True
248+        resource = unicode(iq.bind) or None
[65]249+        d = self.xmlstream.avatar.bindResource(resource)
[54]250+        d.addCallback(cb)
251+        d.addCallback(self.xmlstream.send)
[65]252+        d.chainDeferred(self.deferred)
253+
254+
255+
[66]256+class SessionReceivingInitializer(generic.BaseReceivingInitializer):
257+    """
258+    Stream initializer for session establishment, receiving side.
259+
260+    This is mostly a no-op and just returns a result stanza. If resource
261+    binding hasn't yet completed, this will return a stanza error with the
262+    condition C{'forbidden'}.
263+
264+    Note that RFC 6120 deprecated the session establishment protocol. This
265+    is provided for backwards compatibility.
266+    """
267+
[65]268+    required = False
269+
270+    def getFeatures(self):
271+        feature = domish.Element((client.NS_XMPP_SESSION, 'session'))
272+        return [feature]
273+
274+
275+    def initialize(self):
276+        self.xmlstream.addOnetimeObserver(XPATH_SESSION, self.onSession, 1)
277+        return self.deferred
[54]278+
279+
280+    def onSession(self, iq):
281+        iq.handled = True
282+
283+        reply = domish.Element((None, 'iq'))
[65]284+
285+        if self.xmlstream.otherEntity:
[66]286+            reply = xmlstream.toResponse(iq, 'result')
[65]287+        else:
288+            reply = error.StanzaError('forbidden').toResponse(iq)
[54]289+        self.xmlstream.send(reply)
[65]290+        self.deferred.callback(None)
[54]291+
292+
293+
[65]294+class XMPPClientListenAuthenticator(generic.FeatureListenAuthenticator):
[66]295+    """
296+    XML Stream authenticator for XMPP clients, server side.
297+
298+    @ivar portals: Mapping of server JIDs to Cred Portals.
299+    @type portals: C{dict} of L{twisted.words.protocols.jabber.jid.JID} to
300+        L{twisted.cred.portal.Portal}.
301+    """
302+
[65]303+    namespace = NS_CLIENT
304+
305+    def __init__(self, portals):
306+        generic.FeatureListenAuthenticator.__init__(self)
307+        self.portals = portals
308+        self.portal = None
309+
310+
311+    def getInitializers(self):
[66]312+        """
313+        Return initializers based on previously completed initializers.
314+
315+        This has three stages: 1. SASL, 2. Resource binding and session
316+        establishment. 3. Completed. Note that session establishment
317+        is optional.
318+        """
[65]319+        if not self.completedInitializers:
[66]320+            return [SASLReceivingInitializer('sasl', self.xmlstream, self.portal)]
321+        elif self.completedInitializers[-1] == 'sasl':
322+            return [BindReceivingInitializer('bind', self.xmlstream),
323+                    SessionReceivingInitializer('session', self.xmlstream)]
[65]324+
325+
326+    def checkStream(self):
[66]327+        """
328+        Check that the stream header has proper addressing.
329+
330+        The C{'to'} attribute must be present and there should have a matching
331+        portal in L{portals}.
332+        """
[65]333+        generic.FeatureListenAuthenticator.checkStream(self)
334+
335+        if not self.xmlstream.thisEntity:
336+            raise error.StreamError('improper-addressing')
337+
338+        # Check if we serve the domain and use the associated portal.
339+        try:
340+            self.portal = self.portals[self.xmlstream.thisEntity]
341+        except KeyError:
342+            raise error.StreamError('host-unknown')
[66]343diff --git a/wokkel/generic.py b/wokkel/generic.py
344--- a/wokkel/generic.py
345+++ b/wokkel/generic.py
[72]346@@ -467,6 +467,7 @@
[66]347 
348     def __init__(self):
349         self.completedInitializers = []
350+        self._initialized = False
351 
352 
353     def _onElementFallback(self, element):
[72]354@@ -558,11 +559,12 @@
[66]355 
356         self.xmlstream.send(features)
357 
358-        if not required:
359+        if not required and not self._initialized:
360             # There are no required initializers anymore. This stream is
361             # now ready for the exchange of stanzas.
362             self.xmlstream.removeObserver(XPATH_ALL, self._onElementFallback)
363             self.xmlstream.dispatch(self.xmlstream, xmlstream.STREAM_AUTHD_EVENT)
364+            self._initialized = True
365 
366         if ds:
367             d = defer.DeferredList(ds, fireOnOneCallback=True,
368diff --git a/wokkel/iwokkel.py b/wokkel/iwokkel.py
369--- a/wokkel/iwokkel.py
370+++ b/wokkel/iwokkel.py
[72]371@@ -985,6 +985,55 @@
[66]372 
373 
374 
375+class IUserSession(Interface):
[72]376+    """
377+    Interface for a XMPP user client session avatar.
378+    """
379+
380+    entity = Attribute(
381+        """
382+        The JID for this session.
383+        """)
384+
385+
[66]386+    def loggedIn(realm, mind):
387+        """
388+        Called by the realm when login occurs.
389+
390+        @param realm: The realm though which login is occurring.
391+        @param mind: The mind object.
392+        """
393+
394+
395+    def bindResource(resource):
396+        """
397+        Bind a resource to this session.
398+
399+        @type resource: C{unicode}.
400+        """
401+
402+
403+    def logout():
404+        """
405+        End this session.
406+
407+        This is called when the stream is disconnected.
408+        """
409+
410+
411+    def send(element):
412+        """
413+        Called when the client sends a stanza.
414+        """
415+
416+
417+    def receive(element):
418+        """
419+        Have the client receive a stanza.
420+        """
421+
422+
423+
424 class IReceivingInitializer(Interface):
425     """
426     Interface for XMPP stream initializers for receiving entities.
[65]427diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py
428--- a/wokkel/test/test_client.py
429+++ b/wokkel/test/test_client.py
[66]430@@ -5,16 +5,29 @@
[65]431 Tests for L{wokkel.client}.
432 """
433 
[66]434+from base64 import b64encode
435+
[65]436+from zope.interface import implements
437+
438+from twisted.cred.portal import IRealm, Portal
439+from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
440 from twisted.internet import defer
[66]441+from twisted.test import proto_helpers
[65]442 from twisted.trial import unittest
[66]443-from twisted.words.protocols.jabber import xmlstream
444+from twisted.words.protocols.jabber import error, xmlstream
445+from twisted.words.protocols.jabber.client import NS_XMPP_BIND
446+from twisted.words.protocols.jabber.client import NS_XMPP_SESSION
[65]447 from twisted.words.protocols.jabber.client import XMPPAuthenticator
448 from twisted.words.protocols.jabber.jid import JID
449+from twisted.words.protocols.jabber.sasl import NS_XMPP_SASL
450+from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT
451+from twisted.words.protocols.jabber.xmlstream import NS_STREAMS
452 from twisted.words.protocols.jabber.xmlstream import STREAM_AUTHD_EVENT
453-from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT
454 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
455+from twisted.words.xish import xpath
456 
[66]457-from wokkel import client
458+from wokkel import client, iwokkel
459+from wokkel.generic import TestableXmlStream, FeatureListenAuthenticator
[65]460 
[66]461 class XMPPClientTest(unittest.TestCase):
462     """
[72]463@@ -164,3 +177,505 @@
[65]464         self.assertEqual(factory.deferred, d2)
465 
466         return d1
467+
468+
[66]469+
470+class TestSession(object):
471+    implements(iwokkel.IUserSession)
472+
473+    def __init__(self, domain, user):
474+        self.domain = domain
475+        self.user = user
476+
477+
478+    def bindResource(self, resource):
479+        return defer.succeed(JID(tuple=(self.user, self.domain, resource)))
480+
[65]481+
482+
483+class TestRealm(object):
484+
485+    implements(IRealm)
486+
487+    logoutCalled = False
488+
[66]489+    def __init__(self, domain):
490+        self.domain = domain
491+
492+
[65]493+    def requestAvatar(self, avatarId, mind, *interfaces):
[66]494+        return (iwokkel.IUserSession,
495+                TestSession(self.domain, avatarId.decode('utf-8')),
496+                self.logout)
[65]497+
498+
499+    def logout(self):
500+        self.logoutCalled = True
501+
502+
503+
[66]504+class TestableFeatureListenAuthenticator(FeatureListenAuthenticator):
505+    namespace = 'jabber:client'
[65]506+
[66]507+    initialized = None
[65]508+
[66]509+    def __init__(self, getInitializers):
510+        """
511+        Set up authenticator.
[65]512+
[66]513+        @param getInitializers: Function to override the getInitializers
514+            method. It will receive C{self} as the only argument.
515+        """
516+        FeatureListenAuthenticator.__init__(self)
[65]517+
[66]518+        import types
519+        self.getInitializers = types.MethodType(getInitializers, self)
[65]520+
[66]521+        xs = TestableXmlStream(self)
522+        xs.makeConnection(proto_helpers.StringTransport())
[65]523+
524+
[66]525+    def streamStarted(self, rootElement):
526+        """
527+        Set up observers for authentication events.
528+        """
529+        def authenticated(_):
530+            self.initialized = True
[65]531+
[66]532+        self.xmlstream.addObserver(STREAM_AUTHD_EVENT, authenticated)
533+        FeatureListenAuthenticator.streamStarted(self, rootElement)
[65]534+
535+
536+
[66]537+class SASLReceivingInitializerTest(unittest.TestCase):
[65]538+    """
[66]539+    Tests for L{client.SASLReceivingInitializer}.
[65]540+    """
541+
542+    def setUp(self):
[66]543+        realm = TestRealm(u'example.org')
[65]544+        checker = InMemoryUsernamePasswordDatabaseDontUse(test='secret')
[66]545+        self.portal = portal = Portal(realm, (checker,))
[65]546+
[66]547+        def getInitializers(self):
548+            self.initializer = client.SASLReceivingInitializer('sasl',
549+                                                               self.xmlstream,
550+                                                               portal)
551+            return [self.initializer]
[65]552+
[66]553+        self.authenticator = TestableFeatureListenAuthenticator(getInitializers)
554+        self.xmlstream = self.authenticator.xmlstream
555+
556+
557+    def test_getFeatures(self):
[65]558+        """
[66]559+        The stream features list SASL with the PLAIN mechanism.
[65]560+        """
561+        xs = self.xmlstream
562+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
563+                         "xmlns:stream='http://etherx.jabber.org/streams' "
564+                         "to='example.org' "
565+                         "version='1.0'>")
566+
567+        self.assertTrue(xs.headerSent)
568+
[66]569+        # Check SASL mechanisms
[65]570+        features = xs.output[-1]
[66]571+        self.assertTrue(xpath.matches("/features[@xmlns='%s']"
572+                                          "/mechanisms[@xmlns='%s']"
573+                                          "/mechanism[@xmlns='%s' and "
574+                                                     "text()='PLAIN']" %
575+                                          (NS_STREAMS, NS_XMPP_SASL, NS_XMPP_SASL),
576+                                      features))
[65]577+
578+
579+    def test_auth(self):
580+        """
581+        Authenticating causes an avatar to be set on the authenticator.
582+        """
583+        xs = self.xmlstream
584+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
585+                         "xmlns:stream='http://etherx.jabber.org/streams' "
586+                         "to='example.org' "
587+                         "version='1.0'>")
588+        xs.output = []
[66]589+        response = b64encode('\x00'.join(['', 'test', 'secret']))
[65]590+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
[66]591+                         "mechanism='PLAIN'>%s</auth>" % response)
592+        self.assertTrue(iwokkel.IUserSession.providedBy(self.xmlstream.avatar))
593+        self.assertFalse(xs.headerSent)
594+        self.assertEqual(1, len(xs.output))
595+        self.assertFalse(self.authenticator.initialized)
[65]596+
597+
598+    def test_authInvalidMechanism(self):
599+        """
600+        Authenticating with an invalid SASL mechanism causes a streamError.
601+        """
602+        xs = self.xmlstream
603+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
604+                         "xmlns:stream='http://etherx.jabber.org/streams' "
605+                         "to='example.org' "
606+                         "version='1.0'>")
607+        xs.output = []
608+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
609+                         "mechanism='unknown'/>")
[66]610+        self.assertTrue(xpath.matches(("/failure[@xmlns='%s']"
611+                                       "/invalid-mechanism[@xmlns='%s']" %
612+                                       (NS_XMPP_SASL, NS_XMPP_SASL)),
613+                                      xs.output[-1]))
614+
615+
616+    def test_authFail(self):
617+        """
618+        Authenticating causes an avatar to be set on the authenticator.
619+        """
620+        xs = self.xmlstream
621+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
622+                         "xmlns:stream='http://etherx.jabber.org/streams' "
623+                         "to='example.org' "
624+                         "version='1.0'>")
625+        xs.output = []
626+        response = b64encode('\x00'.join(['', 'test', 'bad']))
627+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
628+                         "mechanism='PLAIN'>%s</auth>" % response)
629+        self.assertIdentical(None, self.xmlstream.avatar)
630+        self.assertTrue(xs.headerSent)
631+
632+        self.assertTrue(xpath.matches(("/failure[@xmlns='%s']"
633+                                       "/not-authorized[@xmlns='%s']" %
634+                                       (NS_XMPP_SASL, NS_XMPP_SASL)),
635+                                      xs.output[-1]))
636+
637+        self.assertFalse(self.authenticator.initialized)
638+
639+
640+    def test_authFailMultiple(self):
641+        """
642+        Authenticating causes an avatar to be set on the authenticator.
643+        """
644+        xs = self.xmlstream
645+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
646+                         "xmlns:stream='http://etherx.jabber.org/streams' "
647+                         "to='example.org' "
648+                         "version='1.0'>")
649+
650+        xs.output = []
651+        response = b64encode('\x00'.join(['', 'test', 'bad']))
652+
653+        attempts = self.authenticator.initializer.failureGrace
654+        for attempt in xrange(attempts):
655+            xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
656+                             "mechanism='PLAIN'>%s</auth>" % response)
657+            self.assertTrue(xpath.matches(("/failure[@xmlns='%s']"
658+                                           "/not-authorized[@xmlns='%s']" %
659+                                           (NS_XMPP_SASL, NS_XMPP_SASL)),
660+                                          xs.output[-1]))
661+        self.xmlstream.assertStreamError(self, condition='policy-violation')
662+        self.assertFalse(self.authenticator.initialized)
663+
664+
665+    def test_authException(self):
666+        """
667+        Other authentication exceptions yield temporary-auth-failure.
668+        """
669+        class Error(Exception):
670+            pass
671+
672+        def login(credentials, mind, *interfaces):
673+            raise Error()
674+
675+        self.portal.login = login
676+
677+        xs = self.xmlstream
678+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
679+                         "xmlns:stream='http://etherx.jabber.org/streams' "
680+                         "to='example.org' "
681+                         "version='1.0'>")
682+        xs.output = []
683+        response = b64encode('\x00'.join(['', 'test', 'bad']))
684+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
685+                         "mechanism='PLAIN'>%s</auth>" % response)
686+        self.assertIdentical(None, self.xmlstream.avatar)
687+        self.assertTrue(xs.headerSent)
688+
689+        self.assertTrue(xpath.matches(("/failure[@xmlns='%s']"
690+                                       "/temporary-auth-failure[@xmlns='%s']" %
691+                                       (NS_XMPP_SASL, NS_XMPP_SASL)),
692+                                      xs.output[-1]))
693+        self.assertFalse(self.authenticator.initialized)
694+        self.assertEqual(1, len(self.flushLoggedErrors(Error)))
695+
696+
697+    def test_authNonAsciiUsername(self):
698+        """
699+        Authenticating causes an avatar to be set on the authenticator.
700+        """
701+        xs = self.xmlstream
702+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
703+                         "xmlns:stream='http://etherx.jabber.org/streams' "
704+                         "to='example.org' "
705+                         "version='1.0'>")
706+        xs.output = []
707+        response = b64encode('\x00'.join(['', 'test\xa1', 'secret']))
708+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
709+                         "mechanism='PLAIN'>%s</auth>" % response)
710+        self.assertIdentical(None, self.xmlstream.avatar)
711+        self.assertTrue(xs.headerSent)
712+
713+        self.assertEqual(1, len(xs.output))
714+        failure = xs.output[-1]
715+        condition = failure.elements().next()
716+        self.assertEqual('not-authorized', condition.name)
717+
718+
719+    def test_authAuthorizationIdentifier(self):
720+        """
721+        Authorization Identifiers are not supported.
722+        """
723+        xs = self.xmlstream
724+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
725+                         "xmlns:stream='http://etherx.jabber.org/streams' "
726+                         "to='example.org' "
727+                         "version='1.0'>")
728+        xs.output = []
729+        response = b64encode('\x00'.join(['other', 'test', 'secret']))
730+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
731+                         "mechanism='PLAIN'>%s</auth>" % response)
732+        self.assertIdentical(None, self.xmlstream.avatar)
733+        self.assertTrue(xs.headerSent)
734+
735+        self.assertEqual(1, len(xs.output))
736+        failure = xs.output[-1]
737+        condition = failure.elements().next()
738+        self.assertEqual('invalid-authz', condition.name)
739+
740+
741+
742+class BindReceivingInitializerTest(unittest.TestCase):
743+    """
744+    Tests for L{client.BindReceivingInitializer}.
745+    """
746+
747+    def setUp(self):
748+        def getInitializers(self):
749+            self.initializer = client.BindReceivingInitializer('bind',
750+                                                               self.xmlstream)
751+            return [self.initializer]
752+
753+        self.authenticator = TestableFeatureListenAuthenticator(getInitializers)
754+        self.xmlstream = self.authenticator.xmlstream
755+        self.xmlstream.avatar = TestSession('example.org', 'test')
756+
757+
758+    def test_getFeatures(self):
759+        """
760+        The stream features include resource binding.
761+        """
762+        xs = self.xmlstream
763+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
764+                         "xmlns:stream='http://etherx.jabber.org/streams' "
765+                         "to='example.org' "
766+                         "version='1.0'>")
767+
768+        features = xs.output[-1]
769+        self.assertTrue(xpath.matches("/features[@xmlns='%s']"
770+                                          "/bind[@xmlns='%s']" %
771+                                          (NS_STREAMS, NS_XMPP_BIND),
772+                                      features))
773+
774+
775+    def test_bind(self):
776+        """
777+        To bind a resource, the avatar is requested one and a JID is returned.
778+        """
779+        xs = self.xmlstream
780+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
781+                         "xmlns:stream='http://etherx.jabber.org/streams' "
782+                         "to='example.org' "
783+                         "version='1.0'>")
784+
785+        # This initializer is required.
786+        self.assertFalse(self.authenticator.initialized)
787+
788+        xs.output = []
789+        xs.dataReceived("""<iq type='set'>
790+                             <bind xmlns='%s'>Home</bind>
791+                           </iq>""" % NS_XMPP_BIND)
792+
793+        self.assertTrue(xs.headerSent, "Unexpected stream restart")
794+
795+        # In response to the bind request, a result iq and the new stream
796+        # features are sent
797+        response = xs.output[-2]
798+        self.assertTrue(xpath.matches("/iq[@type='result']"
799+                                          "/bind[@xmlns='%s']"
800+                                          "/jid[@xmlns='%s' and "
801+                                               "text()='%s']" %
802+                                          (NS_XMPP_BIND,
803+                                           NS_XMPP_BIND,
804+                                           'test@example.org/Home'),
805+                                      response))
806+
807+        self.assertTrue(self.authenticator.initialized)
808+
809+
810+
811+class SessionReceivingInitializerTest(unittest.TestCase):
812+    """
813+    Tests for L{client.SessionReceivingInitializer}.
814+    """
815+
816+    def setUp(self):
817+        def getInitializers(self):
818+            self.initializer = client.SessionReceivingInitializer('session',
819+                                                                  self.xmlstream)
820+            return [self.initializer]
821+
822+        self.authenticator = TestableFeatureListenAuthenticator(getInitializers)
823+        self.xmlstream = self.authenticator.xmlstream
824+
825+
826+    def test_getFeatures(self):
827+        """
828+        The stream features include session establishment.
829+        """
830+        xs = self.xmlstream
831+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
832+                         "xmlns:stream='http://etherx.jabber.org/streams' "
833+                         "to='example.org' "
834+                         "version='1.0'>")
835+
836+        features = xs.output[-1]
837+        self.assertTrue(xpath.matches("/features[@xmlns='%s']"
838+                                          "/session[@xmlns='%s']" %
839+                                          (NS_STREAMS, NS_XMPP_SESSION),
840+                                      features))
841+
842+    def test_session(self):
843+        """
844+        Session establishment is a no-op iq exchange.
845+        """
846+        xs = self.xmlstream
847+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
848+                         "xmlns:stream='http://etherx.jabber.org/streams' "
849+                         "to='example.org' "
850+                         "version='1.0'>")
851+
852+        # This initializer is not required.
853+        self.assertTrue(self.authenticator.initialized)
854+
855+        # If resource binding has completed, xs.otherEntity has been set.
856+        xs.otherEntity = JID('test@example.org/Home')
857+
858+        xs.output = []
859+        xs.dataReceived("""<iq type='set'>
860+                             <session xmlns='%s'/>
861+                           </iq>""" % NS_XMPP_SESSION)
862+
863+        self.assertTrue(xs.headerSent, "Unexpected stream restart")
864+
865+        # In response to the session request, a result iq and the new stream
866+        # features are sent
867+        response = xs.output[-2]
868+        self.assertTrue(xpath.matches("/iq[@type='result']", response))
869+
870+
871+
872+    def test_sessionNoBind(self):
873+        """
874+        Session establishment requires resource binding being completed.
875+        """
876+        xs = self.xmlstream
877+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
878+                         "xmlns:stream='http://etherx.jabber.org/streams' "
879+                         "to='example.org' "
880+                         "version='1.0'>")
881+
882+        # This initializer is not required.
883+        self.assertTrue(self.authenticator.initialized)
884+
885+        xs.output = []
886+        xs.dataReceived("""<iq type='set'>
887+                             <session xmlns='%s'/>
888+                           </iq>""" % NS_XMPP_SESSION)
889+
890+        self.assertTrue(xs.headerSent, "Unexpected stream restart")
891+
892+        # In response to the session request, a result iq and the new stream
893+        # features are sent
894+        response = xs.output[-2]
895+        stanzaError = error.exceptionFromStanza(response)
896+        self.assertEqual('forbidden', stanzaError.condition)
897+
898+
899+
900+class XMPPClientListenAuthenticatorTest(unittest.TestCase):
901+    """
902+    Tests for L{client.XMPPClientListenAuthenticator}.
903+    """
904+
905+    def setUp(self):
906+        portals = {JID('example.org'): None}
907+        self.authenticator = client.XMPPClientListenAuthenticator(portals)
908+        self.xmlstream = TestableXmlStream(self.authenticator)
909+        self.xmlstream.makeConnection(self)
910+
911+
912+    def test_getInitializersStart(self):
913+        """
914+        Upon the start of negotation, only the SASL initializer is available.
915+        """
916+        inits = self.authenticator.getInitializers()
917+        (init,) = inits
918+        self.assertEqual('sasl', init.name)
919+        self.assertIsInstance(init, client.SASLReceivingInitializer)
920+
921+
922+    def test_getInitializersPostSASL(self):
923+        """
924+        After SASL, the resource binding and session establishment initializers
925+        are available.
926+        """
927+        self.authenticator.completedInitializers = ['sasl']
928+        inits = self.authenticator.getInitializers()
929+        (bind, session) = inits
930+        self.assertEqual('bind', bind.name)
931+        self.assertIsInstance(bind, client.BindReceivingInitializer)
932+        self.assertEqual('session', session.name)
933+        self.assertIsInstance(session, client.SessionReceivingInitializer)
934+
935+
936+    def test_streamStartedWrongNamespace(self):
937+        """
938+        An incorrect stream namespace causes a stream error.
939+        """
940+        xs = self.xmlstream
941+        xs.dataReceived("<stream:stream xmlns='jabber:server' "
942+                         "xmlns:stream='http://etherx.jabber.org/streams' "
943+                         "to='example.org' "
944+                         "version='1.0'>")
945+        self.xmlstream.assertStreamError(self, condition='invalid-namespace')
946+
947+
948+    def test_streamStartedNoTo(self):
949+        """
950+        A missing 'to' attribute on the stream header causes a stream error.
951+        """
952+        xs = self.xmlstream
953+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
954+                         "xmlns:stream='http://etherx.jabber.org/streams' "
955+                         "version='1.0'>")
956+        self.xmlstream.assertStreamError(self, condition='improper-addressing')
957+
958+
959+    def test_streamStartedUnknownHost(self):
960+        """
961+        An unknown 'to' on the stream header causes a stream error.
962+        """
963+        xs = self.xmlstream
964+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
965+                         "xmlns:stream='http://etherx.jabber.org/streams' "
966+                         "to='example.com' "
967+                         "version='1.0'>")
968+        self.xmlstream.assertStreamError(self, condition='host-unknown')
969diff --git a/wokkel/test/test_generic.py b/wokkel/test/test_generic.py
970--- a/wokkel/test/test_generic.py
971+++ b/wokkel/test/test_generic.py
972@@ -331,15 +331,12 @@
973     """
974 
975     def setUp(self):
976-        self.gotAuthenticated = False
977-        self.initFailure = None
978+        self.gotAuthenticated = 0
979         self.authenticator = generic.FeatureListenAuthenticator()
980         self.authenticator.namespace = 'jabber:server'
981         self.xmlstream = generic.TestableXmlStream(self.authenticator)
982         self.xmlstream.addObserver('//event/stream/authd',
983                                    self.onAuthenticated)
984-        self.xmlstream.addObserver('//event/xmpp/initfailed',
985-                                   self.onInitFailed)
986 
987         self.init = TestableReceivingInitializer('init', self.xmlstream,
988                                                  'testns', 'test')
989@@ -351,11 +348,7 @@
990 
991 
992     def onAuthenticated(self, obj):
993-        self.gotAuthenticated = True
994-
995-
996-    def onInitFailed(self, failure):
997-        self.initFailure = failure
998+        self.gotAuthenticated += 1
999 
1000 
1001     def test_getInitializers(self):
1002@@ -537,6 +530,38 @@
1003                         "  <query xmlns='jabber:iq:version'/>"
1004                         "</iq>")
1005 
1006+    def test_streamStartedInitializerNotRequired(self):
1007+        """
1008+        If no initializers are required, initialization is done.
1009+        """
1010+        self.init.required = False
1011+        xs = self.xmlstream
1012+        xs.makeConnection(proto_helpers.StringTransport())
1013+        xs.dataReceived("<stream:stream xmlns='jabber:server' "
1014+                         "xmlns:stream='http://etherx.jabber.org/streams' "
1015+                         "from='example.com' to='example.org' id='12345' "
1016+                         "version='1.0'>")
1017+
1018+        self.assertEqual(1, self.gotAuthenticated)
1019+
1020+
1021+    def test_streamStartedInitializerNotRequiredDoneOnce(self):
1022+        """
1023+        If no initializers are required, the authd event is not sent again.
1024+        """
1025+        self.init.required = False
1026+        xs = self.xmlstream
1027+        xs.makeConnection(proto_helpers.StringTransport())
1028+        xs.dataReceived("<stream:stream xmlns='jabber:server' "
1029+                         "xmlns:stream='http://etherx.jabber.org/streams' "
1030+                         "from='example.com' to='example.org' id='12345' "
1031+                         "version='1.0'>")
1032+
1033+        self.assertEqual(1, self.gotAuthenticated)
1034+        xs.output = []
1035+        self.init.deferred.callback(None)
1036+        self.assertEqual(1, self.gotAuthenticated)
1037+
1038 
1039     def test_streamStartedXmlStanzasHandledIgnored(self):
1040         """
Note: See TracBrowser for help on using the repository browser.