[54] | 1 | # HG changeset patch |
---|
[72] | 2 | # Parent c22caa54600c4f85db2a400c7fbea5497f943aa1 |
---|
[54] | 3 | Add authenticator for accepting XMPP client connections. |
---|
| 4 | |
---|
[72] | 5 | The new authenticator XMPPClientListenAuthenticator is to be used |
---|
| 6 | together with an `XmlStream` created for an incoming XMPP stream. It |
---|
| 7 | uses the new initializers for SASL (PLAIN only), resource binding and |
---|
| 8 | session establishement. |
---|
[54] | 9 | |
---|
[65] | 10 | This authenticator needs at least one Twisted Cred portal to hold the |
---|
| 11 | domain served. After authenticating, an avatar and a logout callback are |
---|
| 12 | returned. Upon binding a resource, the avatar's `bindResource` method is |
---|
| 13 | called with the desired resource name. Upon stream disconnect, the |
---|
| 14 | logout callback is called. |
---|
[54] | 15 | |
---|
[65] | 16 | diff --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] | 343 | diff --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, |
---|
| 368 | diff --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] | 427 | diff --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') |
---|
| 969 | diff --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 | """ |
---|