Changeset 65:736d81819863 in ralphm-patches for client_listen_authenticator.patch


Ignore:
Timestamp:
Aug 19, 2012, 11:19:55 PM (8 years ago)
Author:
Ralph Meijer <ralphm@…>
Branch:
default
Message:

Fold c2s cred patch, rework authenticator with initializers.

This also marks some later patches as broken (using a different guard),
as they still depend on the previous architecture.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • client_listen_authenticator.patch

    r57 r65  
    11# HG changeset patch
    2 # Parent 75701188facc278f61a0dfb4bcfcd2232ee771ca
     2# Parent ad0f4165244b1c661023f03d8f7557fe5352337f
    33Add authenticator for accepting XMPP client connections.
    44
     
    77SASL PLAIN mechanism only.
    88
    9 This authenticator needs a backend service to hold the domain served and
    10 relay established connections. Upon binding a resource, its `bindResource`
    11 method is called with the local-part, domain and resource bound to the new
    12 stream.
     9This authenticator needs at least one Twisted Cred portal to hold the
     10domain served. After authenticating, an avatar and a logout callback are
     11returned. Upon binding a resource, the avatar's `bindResource` method is
     12called with the desired resource name. Upon stream disconnect, the
     13logout callback is called.
    1314
    1415TODO:
     
    1617 * Add tests.
    1718 * Add docstrings.
    18  * Readd stream namespace check?
    19  * Host checks.
    20  * Password checks.
    21  * Support for multiple domains?
    2219
    23 diff -r 75701188facc wokkel/client.py
    24 --- a/wokkel/client.py  Wed Nov 23 09:52:41 2011 +0100
    25 +++ b/wokkel/client.py  Wed Nov 30 09:31:07 2011 +0100
    26 @@ -10,14 +10,26 @@
     20diff --git a/wokkel/client.py b/wokkel/client.py
     21--- a/wokkel/client.py
     22+++ b/wokkel/client.py
     23@@ -10,14 +10,28 @@
    2724 that should probably eventually move there.
    2825 """
     
    3027+import base64
    3128+
     29+from zope.interface import Interface
     30+
    3231 from twisted.application import service
    33  from twisted.internet import reactor
     32-from twisted.internet import reactor
     33+from twisted.cred import credentials, error as ecred
     34+from twisted.internet import defer, reactor
    3435+from twisted.python import log
    3536 from twisted.names.srvconnect import SRVConnector
     
    4344+NS_CLIENT = 'jabber:client'
    4445+
    45 +XPATH_ALL = "/*"
    4646+XPATH_AUTH = "/auth[@xmlns='%s']" % sasl.NS_XMPP_SASL
    4747+XPATH_BIND = "/iq[@type='set']/bind[@xmlns='%s']" % client.NS_XMPP_BIND
     
    5252     """
    5353     Check what authentication methods are available.
    54 @@ -51,7 +63,7 @@
     54@@ -51,7 +65,7 @@
    5555     autentication.
    5656     """
     
    6161     def __init__(self, jid, password):
    6262         xmlstream.ConnectAuthenticator.__init__(self, jid.host)
    63 @@ -186,3 +198,152 @@
     63@@ -186,3 +200,210 @@
    6464     c = XMPPClientConnector(reactor, domain, factory)
    6565     c.connect()
     
    6868+
    6969+
    70 +class XMPPClientListenAuthenticator(xmlstream.ListenAuthenticator):
    71 +    namespace = NS_CLIENT
    72 +
    73 +    def __init__(self, service):
    74 +        self.service = service
     70+class InvalidMechanism(Exception):
     71+    """
     72+    The requested SASL mechanism is invalid.
     73+    """
     74+
     75+
     76+class IAccount(Interface):
     77+    pass
     78+
     79+
     80+
     81+class SASLReceivingInitializer(object):
     82+    required = True
     83+
     84+    def __init__(self, xs, portal):
     85+        self.xmlstream = xs
     86+        self.portal = portal
     87+        self.deferred = defer.Deferred()
    7588+        self.failureGrace = 3
    76 +        self.state = 'auth'
    77 +
    78 +
    79 +    def associateWithStream(self, xs):
    80 +        xmlstream.ListenAuthenticator.associateWithStream(self, xs)
    81 +        self.xmlstream.addObserver(XPATH_ALL, self.onElementFallback, -1)
    82 +
    83 +
    84 +    def onElementFallback(self, element):
    85 +        if element.handled:
    86 +            return
    87 +
    88 +        exc = error.StreamError('not-authorized')
    89 +        self.xmlstream.sendStreamError(exc)
    90 +
    91 +
    92 +    def streamStarted(self, rootElement):
    93 +        xmlstream.ListenAuthenticator.streamStarted(self, rootElement)
    94 +
    95 +        # check namespace
    96 +        #if self.xmlstream.namespace != self.namespace:
    97 +        #    self.xmlstream.namespace = self.namespace
    98 +        #    exc = error.StreamError('invalid-namespace')
    99 +        #    self.xmlstream.sendStreamError(exc)
    100 +        #    return
    101 +
    102 +        # TODO: check domain (self.service.domain)
    103 +
    104 +        self.xmlstream.sendHeader()
    105 +
    106 +        try:
    107 +            stateHandlerName = 'streamStarted_' + self.state
    108 +            stateHandler = getattr(self, stateHandlerName)
    109 +        except AttributeError:
    110 +            log.msg('streamStarted handler for', self.state, 'not found')
    111 +        else:
    112 +            stateHandler()
    113 +
    114 +
    115 +    def toState(self, state):
    116 +        self.state = state
    117 +        if state == 'initialized':
    118 +            self.xmlstream.removeObserver(XPATH_ALL, self.onElementFallback)
    119 +            self.xmlstream.addOnetimeObserver(XPATH_SESSION, self.onSession, 1)
    120 +            self.xmlstream.dispatch(self.xmlstream,
    121 +                                    xmlstream.STREAM_AUTHD_EVENT)
    122 +
    123 +
    124 +    def streamStarted_auth(self):
    125 +        features = domish.Element((xmlstream.NS_STREAMS, 'features'))
    126 +        features.addElement((sasl.NS_XMPP_SASL, 'mechanisms'))
    127 +        features.mechanisms.addElement('mechanism', content='PLAIN')
    128 +        self.xmlstream.send(features)
    129 +        self.xmlstream.addOnetimeObserver(XPATH_AUTH, self.onAuth)
     89+
     90+
     91+    def getFeatures(self):
     92+        feature = domish.Element((sasl.NS_XMPP_SASL, 'mechanisms'))
     93+        feature.addElement('mechanism', content='PLAIN')
     94+        return [feature]
     95+
     96+
     97+    def initialize(self):
     98+        self.xmlstream.addObserver(XPATH_AUTH, self.onAuth)
     99+        return self.deferred
    130100+
    131101+
     
    133103+        auth.handled = True
    134104+
    135 +        if auth.getAttribute('mechanism') != 'PLAIN':
    136 +            failure = domish.Element((sasl.NS_XMPP_SASL, 'failure'))
    137 +            failure.addElement('invalid-mechanism')
    138 +            self.xmlstream.send(failure)
     105+        def cb(_):
     106+            response = domish.Element((sasl.NS_XMPP_SASL, 'success'))
     107+            self.xmlstream.send(response)
     108+            self.xmlstream.reset()
     109+
     110+        def eb(failure):
     111+            if failure.check(ecred.UnauthorizedLogin):
     112+                condition = 'not-authorized'
     113+            elif failure.check(InvalidMechanism):
     114+                condition = 'invalid-mechanism'
     115+            else:
     116+                log.err(failure)
     117+                condition = 'temporary-auth-failure'
     118+
     119+            response = domish.Element((sasl.NS_XMPP_SASL, 'failure'))
     120+            response.addElement(condition)
     121+            self.xmlstream.send(response)
    139122+
    140123+            # Close stream on too many failing authentication attempts
    141124+            self.failureGrace -= 1
    142125+            if self.failureGrace == 0:
    143 +                self.xmlstream.sendFooter()
    144 +            else:
    145 +                self.xmlstream.addOnetimeObserver(XPATH_AUTH, self.onAuth)
    146 +
    147 +            return
    148 +
     126+                raise error.StreamError('policy-violation')
     127+                #self.xmlstream.sendStreamError(exc)
     128+
     129+        d = defer.maybeDeferred(self.doAuth, auth)
     130+        d.addCallbacks(cb, eb)
     131+        d.chainDeferred(self.deferred)
     132+
     133+
     134+    def credentialsFromPlain(self, auth):
    149135+        initialResponse = base64.b64decode(unicode(auth))
    150136+        authzid, authcid, passwd = initialResponse.split('\x00')
    151137+
    152 +        # TODO: check passwd
    153 +
    154 +        # authenticated
    155 +
    156 +        self.username = authcid
    157 +
    158 +        success = domish.Element((sasl.NS_XMPP_SASL, 'success'))
    159 +        self.xmlstream.send(success)
    160 +        self.xmlstream.reset()
    161 +
    162 +        self.toState('bind')
    163 +
    164 +
    165 +    def streamStarted_bind(self):
    166 +        features = domish.Element((xmlstream.NS_STREAMS, 'features'))
    167 +        features.addElement((client.NS_XMPP_BIND, 'bind'))
    168 +        features.addElement((client.NS_XMPP_SESSION, 'session'))
    169 +        self.xmlstream.send(features)
     138+        # FIXME: bail if authzid is set
     139+
     140+        creds = credentials.UsernamePassword(username=authcid.encode('utf-8'),
     141+                                             password=passwd)
     142+        return creds
     143+
     144+
     145+    def doAuth(self, auth):
     146+
     147+        if auth.getAttribute('mechanism') != 'PLAIN':
     148+            raise InvalidMechanism()
     149+
     150+        creds = self.credentialsFromPlain(auth)
     151+
     152+        def cb((iface, avatar, logout)):
     153+            self.xmlstream.avatar = avatar
     154+            self.xmlstream.addObserver(xmlstream.STREAM_END_EVENT,
     155+                                       lambda _: logout())
     156+
     157+        d = self.portal.login(creds, None, IAccount)
     158+        d.addCallback(cb)
     159+        return d
     160+
     161+
     162+
     163+class BindReceivingInitializer(object):
     164+    required = True
     165+
     166+    def __init__(self, xs):
     167+        self.xmlstream = xs
     168+        self.deferred = defer.Deferred()
     169+
     170+
     171+    def getFeatures(self):
     172+        feature = domish.Element((client.NS_XMPP_BIND, 'bind'))
     173+        return [feature]
     174+
     175+
     176+    def initialize(self):
    170177+        self.xmlstream.addOnetimeObserver(XPATH_BIND, self.onBind)
     178+        return self.deferred
    171179+
    172180+
     
    174182+        def cb(boundJID):
    175183+            self.xmlstream.otherEntity = boundJID
    176 +            self.toState('initialized')
    177184+
    178185+            response = xmlstream.toResponse(iq, 'result')
     
    194201+        iq.handled = True
    195202+        resource = unicode(iq.bind) or None
    196 +        d = self.service.bindResource(self.username,
    197 +                                      self.service.domain,
    198 +                                      resource)
     203+        d = self.xmlstream.avatar.bindResource(resource)
    199204+        d.addCallback(cb)
    200205+        d.addErrback(eb)
    201206+        d.addCallback(self.xmlstream.send)
     207+        d.chainDeferred(self.deferred)
     208+
     209+
     210+
     211+class SessionReceivingInitializer(object):
     212+    required = False
     213+
     214+    def __init__(self, xs):
     215+        self.xmlstream = xs
     216+        self.deferred = defer.Deferred()
     217+
     218+
     219+    def getFeatures(self):
     220+        feature = domish.Element((client.NS_XMPP_SESSION, 'session'))
     221+        return [feature]
     222+
     223+
     224+    def initialize(self):
     225+        self.xmlstream.addOnetimeObserver(XPATH_SESSION, self.onSession, 1)
     226+        return self.deferred
    202227+
    203228+
     
    206231+
    207232+        reply = domish.Element((None, 'iq'))
    208 +        reply['type'] = 'result'
    209 +        if iq.getAttribute('id'):
    210 +            reply['id'] = iq['id']
    211 +        reply.addElement((client.NS_XMPP_SESSION, 'session'))
     233+
     234+        if self.xmlstream.otherEntity:
     235+            reply = xmlstream.toResponse(iq)
     236+        else:
     237+            reply = error.StanzaError('forbidden').toResponse(iq)
    212238+        self.xmlstream.send(reply)
    213 +
    214 +
    215 +
     239+        self.deferred.callback(None)
     240+
     241+
     242+
     243+class XMPPClientListenAuthenticator(generic.FeatureListenAuthenticator):
     244+    namespace = NS_CLIENT
     245+
     246+    def __init__(self, portals):
     247+        generic.FeatureListenAuthenticator.__init__(self)
     248+        self.portals = portals
     249+        self.portal = None
     250+
     251+
     252+    def getInitializers(self):
     253+        if not self.completedInitializers:
     254+            return [SASLReceivingInitializer(self.xmlstream, self.portal)]
     255+        elif isinstance(self.completedInitializers[-1],
     256+                        SASLReceivingInitializer):
     257+            return [BindReceivingInitializer(self.xmlstream),
     258+                    SessionReceivingInitializer(self.xmlstream)]
     259+        else:
     260+            return []
     261+
     262+
     263+    def checkStream(self):
     264+        generic.FeatureListenAuthenticator.checkStream(self)
     265+
     266+        if not self.xmlstream.thisEntity:
     267+            raise error.StreamError('improper-addressing')
     268+
     269+        # Check if we serve the domain and use the associated portal.
     270+        try:
     271+            self.portal = self.portals[self.xmlstream.thisEntity]
     272+        except KeyError:
     273+            raise error.StreamError('host-unknown')
     274diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py
     275--- a/wokkel/test/test_client.py
     276+++ b/wokkel/test/test_client.py
     277@@ -5,14 +5,21 @@
     278 Tests for L{wokkel.client}.
     279 """
     280 
     281+from zope.interface import implements
     282+
     283+from twisted.cred.portal import IRealm, Portal
     284+from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
     285 from twisted.internet import defer
     286 from twisted.trial import unittest
     287 from twisted.words.protocols.jabber import xmlstream
     288 from twisted.words.protocols.jabber.client import XMPPAuthenticator
     289 from twisted.words.protocols.jabber.jid import JID
     290+from twisted.words.protocols.jabber.sasl import NS_XMPP_SASL
     291+from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT
     292+from twisted.words.protocols.jabber.xmlstream import NS_STREAMS
     293 from twisted.words.protocols.jabber.xmlstream import STREAM_AUTHD_EVENT
     294-from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT
     295 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
     296+from twisted.words.xish import xpath
     297 
     298 from wokkel import client
     299 
     300@@ -155,3 +162,167 @@
     301         self.assertEqual(factory.deferred, d2)
     302 
     303         return d1
     304+
     305+
     306+class TestAccount(object):
     307+    implements(client.IAccount)
     308+
     309+
     310+class TestRealm(object):
     311+
     312+    implements(IRealm)
     313+
     314+    logoutCalled = False
     315+
     316+    def requestAvatar(self, avatarId, mind, *interfaces):
     317+        return (client.IAccount, TestAccount(), self.logout)
     318+
     319+
     320+    def logout(self):
     321+        self.logoutCalled = True
     322+
     323+
     324+class TestableXmlStream(xmlstream.XmlStream):
     325+
     326+    def __init__(self, authenticator):
     327+        xmlstream.XmlStream.__init__(self, authenticator)
     328+        self.headerSent = False
     329+        self.footerSent = False
     330+        self.streamErrors = []
     331+        self.output = []
     332+
     333+
     334+    def reset(self):
     335+        xmlstream.XmlStream.reset(self)
     336+        self.headerSent = False
     337+
     338+
     339+    def sendHeader(self):
     340+        self.headerSent = True
     341+
     342+
     343+    def sendFooter(self):
     344+        self.footerSent = True
     345+
     346+
     347+    def sendStreamError(self, streamError):
     348+        self.streamErrors.append(streamError)
     349+
     350+
     351+    def send(self, obj):
     352+        self.output.append(obj)
     353+
     354+
     355+class XMPPClientListenAuthenticatorTest(unittest.TestCase):
     356+    """
     357+    Tests for L{client.XMPPClientListenAuthenticator}.
     358+    """
     359+
     360+    def setUp(self):
     361+        self.output = []
     362+        realm = TestRealm()
     363+        checker = InMemoryUsernamePasswordDatabaseDontUse(test='secret')
     364+        portal = Portal(realm, (checker,))
     365+        portals = {JID('example.org'): portal}
     366+        self.authenticator = client.XMPPClientListenAuthenticator(portals)
     367+        self.xmlstream = TestableXmlStream(self.authenticator)
     368+        self.xmlstream.makeConnection(self)
     369+
     370+
     371+    def loseConnection(self):
     372+        """
     373+        Stub loseConnection because we are a transport.
     374+        """
     375+        self.xmlstream.connectionLost("no reason")
     376+
     377+
     378+    def test_streamStarted(self):
     379+        xs = self.xmlstream
     380+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     381+                         "xmlns:stream='http://etherx.jabber.org/streams' "
     382+                         "to='example.org' "
     383+                         "version='1.0'>")
     384+
     385+        self.assertTrue(xs.headerSent)
     386+
     387+        # Extract SASL mechanisms
     388+        features = xs.output[-1]
     389+        self.assertEquals(NS_STREAMS, features.uri)
     390+        self.assertEquals('features', features.name)
     391+        parent = features.elements(NS_XMPP_SASL, 'mechanisms').next()
     392+        mechanisms = set()
     393+        for child in parent.elements(NS_XMPP_SASL, 'mechanism'):
     394+            mechanisms.add(unicode(child))
     395+
     396+        self.assertIn('PLAIN', mechanisms)
     397+
     398+
     399+    def test_streamStartedWrongNamespace(self):
     400+        """
     401+        An incorrect stream namespace causes a stream error.
     402+        """
     403+        xs = self.xmlstream
     404+        xs.dataReceived("<stream:stream xmlns='jabber:server' "
     405+                         "xmlns:stream='http://etherx.jabber.org/streams' "
     406+                         "to='example.org' "
     407+                         "version='1.0'>")
     408+        streamError = xs.streamErrors[-1]
     409+        self.assertEquals('invalid-namespace', streamError.condition)
     410+
     411+
     412+    def test_streamStartedNoTo(self):
     413+        """
     414+        A missing 'to' attribute on the stream header causes a stream error.
     415+        """
     416+        xs = self.xmlstream
     417+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     418+                         "xmlns:stream='http://etherx.jabber.org/streams' "
     419+                         "version='1.0'>")
     420+        streamError = xs.streamErrors[-1]
     421+        self.assertEquals('improper-addressing', streamError.condition)
     422+
     423+
     424+    def test_streamStartedUnknownHost(self):
     425+        """
     426+        An unknown 'to' on the stream header causes a stream error.
     427+        """
     428+        xs = self.xmlstream
     429+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     430+                         "xmlns:stream='http://etherx.jabber.org/streams' "
     431+                         "to='example.com' "
     432+                         "version='1.0'>")
     433+        streamError = xs.streamErrors[-1]
     434+        self.assertEquals('host-unknown', streamError.condition)
     435+
     436+
     437+    def test_auth(self):
     438+        """
     439+        Authenticating causes an avatar to be set on the authenticator.
     440+        """
     441+        xs = self.xmlstream
     442+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     443+                         "xmlns:stream='http://etherx.jabber.org/streams' "
     444+                         "to='example.org' "
     445+                         "version='1.0'>")
     446+        xs.output = []
     447+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
     448+                         "mechanism='PLAIN'>AHRlc3QAc2VjcmV0</auth>")
     449+        self.assertTrue(client.IAccount.providedBy(self.xmlstream.avatar))
     450+
     451+
     452+    def test_authInvalidMechanism(self):
     453+        """
     454+        Authenticating with an invalid SASL mechanism causes a streamError.
     455+        """
     456+        xs = self.xmlstream
     457+        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     458+                         "xmlns:stream='http://etherx.jabber.org/streams' "
     459+                         "to='example.org' "
     460+                         "version='1.0'>")
     461+        xs.output = []
     462+        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
     463+                         "mechanism='unknown'/>")
     464+        xpath.matches(("/failure[@xmlns='%s']"
     465+                       "/invalid-mechanism[@xmlns='%s']" %
     466+                       (NS_XMPP_SASL, NS_XMPP_SASL)),
     467+                      xs.output[-1])
Note: See TracChangeset for help on using the changeset viewer.