source: ralphm-patches/client_listen_authenticator.patch @ 65:736d81819863

Last change on this file since 65:736d81819863 was 65:736d81819863, checked in by Ralph Meijer <ralphm@…>, 8 years ago

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 size: 14.3 KB
  • wokkel/client.py

    # HG changeset patch
    # Parent ad0f4165244b1c661023f03d8f7557fe5352337f
    Add authenticator for accepting XMPP client connections.
    
    The new authenticator XMPPClientListenAuthenticator is to be used together
    with an `XmlStream` created for an incoming XMPP stream. It handles the
    SASL PLAIN mechanism only.
    
    This authenticator needs at least one Twisted Cred portal to hold the
    domain served. After authenticating, an avatar and a logout callback are
    returned. Upon binding a resource, the avatar's `bindResource` method is
    called with the desired resource name. Upon stream disconnect, the
    logout callback is called.
    
    TODO:
    
     * Add tests.
     * Add docstrings.
    
    diff --git a/wokkel/client.py b/wokkel/client.py
    a b  
    1010that should probably eventually move there.
    1111"""
    1212
     13import base64
     14
     15from zope.interface import Interface
     16
    1317from twisted.application import service
    14 from twisted.internet import reactor
     18from twisted.cred import credentials, error as ecred
     19from twisted.internet import defer, reactor
     20from twisted.python import log
    1521from twisted.names.srvconnect import SRVConnector
    16 from twisted.words.protocols.jabber import client, sasl, xmlstream
     22from twisted.words.protocols.jabber import client, error, sasl, xmlstream
     23from twisted.words.xish import domish
    1724
    1825from wokkel import generic
    1926from wokkel.subprotocols import StreamManager
    2027
     28NS_CLIENT = 'jabber:client'
     29
     30XPATH_AUTH = "/auth[@xmlns='%s']" % sasl.NS_XMPP_SASL
     31XPATH_BIND = "/iq[@type='set']/bind[@xmlns='%s']" % client.NS_XMPP_BIND
     32XPATH_SESSION = "/iq[@type='set']/session[@xmlns='%s']" % \
     33                client.NS_XMPP_SESSION
     34
    2135class CheckAuthInitializer(object):
    2236    """
    2337    Check what authentication methods are available.
     
    5165    autentication.
    5266    """
    5367
    54     namespace = 'jabber:client'
     68    namespace = NS_CLIENT
    5569
    5670    def __init__(self, jid, password):
    5771        xmlstream.ConnectAuthenticator.__init__(self, jid.host)
     
    186200    c = XMPPClientConnector(reactor, domain, factory)
    187201    c.connect()
    188202    return factory.deferred
     203
     204
     205
     206class InvalidMechanism(Exception):
     207    """
     208    The requested SASL mechanism is invalid.
     209    """
     210
     211
     212class IAccount(Interface):
     213    pass
     214
     215
     216
     217class SASLReceivingInitializer(object):
     218    required = True
     219
     220    def __init__(self, xs, portal):
     221        self.xmlstream = xs
     222        self.portal = portal
     223        self.deferred = defer.Deferred()
     224        self.failureGrace = 3
     225
     226
     227    def getFeatures(self):
     228        feature = domish.Element((sasl.NS_XMPP_SASL, 'mechanisms'))
     229        feature.addElement('mechanism', content='PLAIN')
     230        return [feature]
     231
     232
     233    def initialize(self):
     234        self.xmlstream.addObserver(XPATH_AUTH, self.onAuth)
     235        return self.deferred
     236
     237
     238    def onAuth(self, auth):
     239        auth.handled = True
     240
     241        def cb(_):
     242            response = domish.Element((sasl.NS_XMPP_SASL, 'success'))
     243            self.xmlstream.send(response)
     244            self.xmlstream.reset()
     245
     246        def eb(failure):
     247            if failure.check(ecred.UnauthorizedLogin):
     248                condition = 'not-authorized'
     249            elif failure.check(InvalidMechanism):
     250                condition = 'invalid-mechanism'
     251            else:
     252                log.err(failure)
     253                condition = 'temporary-auth-failure'
     254
     255            response = domish.Element((sasl.NS_XMPP_SASL, 'failure'))
     256            response.addElement(condition)
     257            self.xmlstream.send(response)
     258
     259            # Close stream on too many failing authentication attempts
     260            self.failureGrace -= 1
     261            if self.failureGrace == 0:
     262                raise error.StreamError('policy-violation')
     263                #self.xmlstream.sendStreamError(exc)
     264
     265        d = defer.maybeDeferred(self.doAuth, auth)
     266        d.addCallbacks(cb, eb)
     267        d.chainDeferred(self.deferred)
     268
     269
     270    def credentialsFromPlain(self, auth):
     271        initialResponse = base64.b64decode(unicode(auth))
     272        authzid, authcid, passwd = initialResponse.split('\x00')
     273
     274        # FIXME: bail if authzid is set
     275
     276        creds = credentials.UsernamePassword(username=authcid.encode('utf-8'),
     277                                             password=passwd)
     278        return creds
     279
     280
     281    def doAuth(self, auth):
     282
     283        if auth.getAttribute('mechanism') != 'PLAIN':
     284            raise InvalidMechanism()
     285
     286        creds = self.credentialsFromPlain(auth)
     287
     288        def cb((iface, avatar, logout)):
     289            self.xmlstream.avatar = avatar
     290            self.xmlstream.addObserver(xmlstream.STREAM_END_EVENT,
     291                                       lambda _: logout())
     292
     293        d = self.portal.login(creds, None, IAccount)
     294        d.addCallback(cb)
     295        return d
     296
     297
     298
     299class BindReceivingInitializer(object):
     300    required = True
     301
     302    def __init__(self, xs):
     303        self.xmlstream = xs
     304        self.deferred = defer.Deferred()
     305
     306
     307    def getFeatures(self):
     308        feature = domish.Element((client.NS_XMPP_BIND, 'bind'))
     309        return [feature]
     310
     311
     312    def initialize(self):
     313        self.xmlstream.addOnetimeObserver(XPATH_BIND, self.onBind)
     314        return self.deferred
     315
     316
     317    def onBind(self, iq):
     318        def cb(boundJID):
     319            self.xmlstream.otherEntity = boundJID
     320
     321            response = xmlstream.toResponse(iq, 'result')
     322            response.addElement((client.NS_XMPP_BIND, 'bind'))
     323            response.bind.addElement((client.NS_XMPP_BIND, 'jid'),
     324                                  content=boundJID.full())
     325
     326            return response
     327
     328        def eb(failure):
     329            if not isinstance(failure, error.StanzaError):
     330                log.msg(failure)
     331                exc = error.StanzaError('internal-server-error')
     332            else:
     333                exc = failure.value
     334
     335            return exc.toResponse(iq)
     336
     337        iq.handled = True
     338        resource = unicode(iq.bind) or None
     339        d = self.xmlstream.avatar.bindResource(resource)
     340        d.addCallback(cb)
     341        d.addErrback(eb)
     342        d.addCallback(self.xmlstream.send)
     343        d.chainDeferred(self.deferred)
     344
     345
     346
     347class SessionReceivingInitializer(object):
     348    required = False
     349
     350    def __init__(self, xs):
     351        self.xmlstream = xs
     352        self.deferred = defer.Deferred()
     353
     354
     355    def getFeatures(self):
     356        feature = domish.Element((client.NS_XMPP_SESSION, 'session'))
     357        return [feature]
     358
     359
     360    def initialize(self):
     361        self.xmlstream.addOnetimeObserver(XPATH_SESSION, self.onSession, 1)
     362        return self.deferred
     363
     364
     365    def onSession(self, iq):
     366        iq.handled = True
     367
     368        reply = domish.Element((None, 'iq'))
     369
     370        if self.xmlstream.otherEntity:
     371            reply = xmlstream.toResponse(iq)
     372        else:
     373            reply = error.StanzaError('forbidden').toResponse(iq)
     374        self.xmlstream.send(reply)
     375        self.deferred.callback(None)
     376
     377
     378
     379class XMPPClientListenAuthenticator(generic.FeatureListenAuthenticator):
     380    namespace = NS_CLIENT
     381
     382    def __init__(self, portals):
     383        generic.FeatureListenAuthenticator.__init__(self)
     384        self.portals = portals
     385        self.portal = None
     386
     387
     388    def getInitializers(self):
     389        if not self.completedInitializers:
     390            return [SASLReceivingInitializer(self.xmlstream, self.portal)]
     391        elif isinstance(self.completedInitializers[-1],
     392                        SASLReceivingInitializer):
     393            return [BindReceivingInitializer(self.xmlstream),
     394                    SessionReceivingInitializer(self.xmlstream)]
     395        else:
     396            return []
     397
     398
     399    def checkStream(self):
     400        generic.FeatureListenAuthenticator.checkStream(self)
     401
     402        if not self.xmlstream.thisEntity:
     403            raise error.StreamError('improper-addressing')
     404
     405        # Check if we serve the domain and use the associated portal.
     406        try:
     407            self.portal = self.portals[self.xmlstream.thisEntity]
     408        except KeyError:
     409            raise error.StreamError('host-unknown')
  • wokkel/test/test_client.py

    diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py
    a b  
    55Tests for L{wokkel.client}.
    66"""
    77
     8from zope.interface import implements
     9
     10from twisted.cred.portal import IRealm, Portal
     11from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse
    812from twisted.internet import defer
    913from twisted.trial import unittest
    1014from twisted.words.protocols.jabber import xmlstream
    1115from twisted.words.protocols.jabber.client import XMPPAuthenticator
    1216from twisted.words.protocols.jabber.jid import JID
     17from twisted.words.protocols.jabber.sasl import NS_XMPP_SASL
     18from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT
     19from twisted.words.protocols.jabber.xmlstream import NS_STREAMS
    1320from twisted.words.protocols.jabber.xmlstream import STREAM_AUTHD_EVENT
    14 from twisted.words.protocols.jabber.xmlstream import INIT_FAILED_EVENT
    1521from twisted.words.protocols.jabber.xmlstream import XMPPHandler
     22from twisted.words.xish import xpath
    1623
    1724from wokkel import client
    1825
     
    155162        self.assertEqual(factory.deferred, d2)
    156163
    157164        return d1
     165
     166
     167class TestAccount(object):
     168    implements(client.IAccount)
     169
     170
     171class TestRealm(object):
     172
     173    implements(IRealm)
     174
     175    logoutCalled = False
     176
     177    def requestAvatar(self, avatarId, mind, *interfaces):
     178        return (client.IAccount, TestAccount(), self.logout)
     179
     180
     181    def logout(self):
     182        self.logoutCalled = True
     183
     184
     185class TestableXmlStream(xmlstream.XmlStream):
     186
     187    def __init__(self, authenticator):
     188        xmlstream.XmlStream.__init__(self, authenticator)
     189        self.headerSent = False
     190        self.footerSent = False
     191        self.streamErrors = []
     192        self.output = []
     193
     194
     195    def reset(self):
     196        xmlstream.XmlStream.reset(self)
     197        self.headerSent = False
     198
     199
     200    def sendHeader(self):
     201        self.headerSent = True
     202
     203
     204    def sendFooter(self):
     205        self.footerSent = True
     206
     207
     208    def sendStreamError(self, streamError):
     209        self.streamErrors.append(streamError)
     210
     211
     212    def send(self, obj):
     213        self.output.append(obj)
     214
     215
     216class XMPPClientListenAuthenticatorTest(unittest.TestCase):
     217    """
     218    Tests for L{client.XMPPClientListenAuthenticator}.
     219    """
     220
     221    def setUp(self):
     222        self.output = []
     223        realm = TestRealm()
     224        checker = InMemoryUsernamePasswordDatabaseDontUse(test='secret')
     225        portal = Portal(realm, (checker,))
     226        portals = {JID('example.org'): portal}
     227        self.authenticator = client.XMPPClientListenAuthenticator(portals)
     228        self.xmlstream = TestableXmlStream(self.authenticator)
     229        self.xmlstream.makeConnection(self)
     230
     231
     232    def loseConnection(self):
     233        """
     234        Stub loseConnection because we are a transport.
     235        """
     236        self.xmlstream.connectionLost("no reason")
     237
     238
     239    def test_streamStarted(self):
     240        xs = self.xmlstream
     241        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     242                         "xmlns:stream='http://etherx.jabber.org/streams' "
     243                         "to='example.org' "
     244                         "version='1.0'>")
     245
     246        self.assertTrue(xs.headerSent)
     247
     248        # Extract SASL mechanisms
     249        features = xs.output[-1]
     250        self.assertEquals(NS_STREAMS, features.uri)
     251        self.assertEquals('features', features.name)
     252        parent = features.elements(NS_XMPP_SASL, 'mechanisms').next()
     253        mechanisms = set()
     254        for child in parent.elements(NS_XMPP_SASL, 'mechanism'):
     255            mechanisms.add(unicode(child))
     256
     257        self.assertIn('PLAIN', mechanisms)
     258
     259
     260    def test_streamStartedWrongNamespace(self):
     261        """
     262        An incorrect stream namespace causes a stream error.
     263        """
     264        xs = self.xmlstream
     265        xs.dataReceived("<stream:stream xmlns='jabber:server' "
     266                         "xmlns:stream='http://etherx.jabber.org/streams' "
     267                         "to='example.org' "
     268                         "version='1.0'>")
     269        streamError = xs.streamErrors[-1]
     270        self.assertEquals('invalid-namespace', streamError.condition)
     271
     272
     273    def test_streamStartedNoTo(self):
     274        """
     275        A missing 'to' attribute on the stream header causes a stream error.
     276        """
     277        xs = self.xmlstream
     278        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     279                         "xmlns:stream='http://etherx.jabber.org/streams' "
     280                         "version='1.0'>")
     281        streamError = xs.streamErrors[-1]
     282        self.assertEquals('improper-addressing', streamError.condition)
     283
     284
     285    def test_streamStartedUnknownHost(self):
     286        """
     287        An unknown 'to' on the stream header causes a stream error.
     288        """
     289        xs = self.xmlstream
     290        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     291                         "xmlns:stream='http://etherx.jabber.org/streams' "
     292                         "to='example.com' "
     293                         "version='1.0'>")
     294        streamError = xs.streamErrors[-1]
     295        self.assertEquals('host-unknown', streamError.condition)
     296
     297
     298    def test_auth(self):
     299        """
     300        Authenticating causes an avatar to be set on the authenticator.
     301        """
     302        xs = self.xmlstream
     303        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     304                         "xmlns:stream='http://etherx.jabber.org/streams' "
     305                         "to='example.org' "
     306                         "version='1.0'>")
     307        xs.output = []
     308        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
     309                         "mechanism='PLAIN'>AHRlc3QAc2VjcmV0</auth>")
     310        self.assertTrue(client.IAccount.providedBy(self.xmlstream.avatar))
     311
     312
     313    def test_authInvalidMechanism(self):
     314        """
     315        Authenticating with an invalid SASL mechanism causes a streamError.
     316        """
     317        xs = self.xmlstream
     318        xs.dataReceived("<stream:stream xmlns='jabber:client' "
     319                         "xmlns:stream='http://etherx.jabber.org/streams' "
     320                         "to='example.org' "
     321                         "version='1.0'>")
     322        xs.output = []
     323        xs.dataReceived("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
     324                         "mechanism='unknown'/>")
     325        xpath.matches(("/failure[@xmlns='%s']"
     326                       "/invalid-mechanism[@xmlns='%s']" %
     327                       (NS_XMPP_SASL, NS_XMPP_SASL)),
     328                      xs.output[-1])
Note: See TracBrowser for help on using the repository browser.