Skip to content

Commit

Permalink
Component errors, component auth (#934)
Browse files Browse the repository at this point in the history
* propogate errors on no_auth_method up through component

* populate self._session (for stop())

* add anonymous auth

* add ticket authentication

* basic tests for Cryptosign

* Propagate last error (if any) and improve auth error-handling

* default => event

* fail on unknown errors

* dead code

* make the serializer
  • Loading branch information
meejah authored and oberstet committed Dec 9, 2017
1 parent d47af84 commit 6bcca8e
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 27 deletions.
19 changes: 6 additions & 13 deletions autobahn/asyncio/component.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ def start(self, loop=None):


if loop is None: if loop is None:
self.log.warn("Using default loop") self.log.warn("Using default loop")
loop = asyncio.get_default_loop() loop = asyncio.get_event_loop()


# this future will be returned, and thus has the semantics # this future will be returned, and thus has the semantics
# specified in the docstring. # specified in the docstring.
Expand Down Expand Up @@ -391,23 +391,16 @@ def connect_error(fail):
u'Connection failed: {error}', u'Connection failed: {error}',
error=txaio.failure_message(fail), error=txaio.failure_message(fail),
) )
# some types of errors should probably have # This is some unknown failure, e.g. could
# stacktraces logged immediately at error # be SyntaxError etc so we're aborting the
# level, e.g. SyntaxError? # whole mission
self.log.debug(u'{tb}', tb=txaio.failure_format_traceback(fail)) txaio.reject(done_f, fail)
return one_reconnect_loop(None) return


txaio.add_callbacks(f, session_done, connect_error) txaio.add_callbacks(f, session_done, connect_error)


txaio.add_callbacks(delay_f, actual_connect, error) txaio.add_callbacks(delay_f, actual_connect, error)


if False:
# check if there is any transport left we can use
# to connect
if not self._can_reconnect():
self.log.info("No remaining transports to try")
reconnect[0] = False

def error(fail): def error(fail):
self.log.info("Internal error {msg}", msg=txaio.failure_message(fail)) self.log.info("Internal error {msg}", msg=txaio.failure_message(fail))
self.log.debug("{tb}", tb=txaio.failure_format_traceback(fail)) self.log.debug("{tb}", tb=txaio.failure_format_traceback(fail))
Expand Down
2 changes: 1 addition & 1 deletion autobahn/asyncio/rawsocket.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ class WampRawSocketClientProtocol(WampRawSocketMixinGeneral, WampRawSocketMixinA
@property @property
def serializer_id(self): def serializer_id(self):
if not hasattr(self, '_serializer'): if not hasattr(self, '_serializer'):
self._serializer = self.factory._serializer self._serializer = self.factory._serializer()
return self._serializer.RAWSOCKET_SERIALIZER_ID return self._serializer.RAWSOCKET_SERIALIZER_ID


def get_channel_id(self, channel_id_type=u'tls-unique'): def get_channel_id(self, channel_id_type=u'tls-unique'):
Expand Down
6 changes: 5 additions & 1 deletion autobahn/twisted/component.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ def start(self, reactor=None):
transport_gen = itertools.cycle(self._transports) transport_gen = itertools.cycle(self._transports)


reconnect = True reconnect = True
last_failure = None


self.log.debug('Entering re-connect loop') self.log.debug('Entering re-connect loop')


Expand All @@ -373,12 +374,13 @@ def start(self, reactor=None):
yield self._connect_once(reactor, transport) yield self._connect_once(reactor, transport)
except Exception as e: except Exception as e:
f = txaio.create_failure() f = txaio.create_failure()
last_failure = f
self.log.error(u'component failed: {error}', error=txaio.failure_message(f)) self.log.error(u'component failed: {error}', error=txaio.failure_message(f))
self.log.debug(u'{tb}', tb=txaio.failure_format_traceback(f)) self.log.debug(u'{tb}', tb=txaio.failure_format_traceback(f))
# If this is a "fatal error" that will never work, # If this is a "fatal error" that will never work,
# we bail out now # we bail out now
if isinstance(e, ApplicationError): if isinstance(e, ApplicationError):
if e.error in [u'wamp.error.no_such_realm']: if e.error in [u'wamp.error.no_such_realm', u'wamp.error.no_auth_method']:
reconnect = False reconnect = False
self.log.error(u"Fatal error, not reconnecting") self.log.error(u"Fatal error, not reconnecting")
# The thinking here is that we really do # The thinking here is that we really do
Expand Down Expand Up @@ -420,6 +422,8 @@ def start(self, reactor=None):
if not self._can_reconnect(): if not self._can_reconnect():
self.log.info("No remaining transports to try") self.log.info("No remaining transports to try")
reconnect = False reconnect = False
if last_failure is not None:
last_failure.raiseException()


def stop(self): def stop(self):
return self._session.leave() return self._session.leave()
Expand Down
66 changes: 64 additions & 2 deletions autobahn/twisted/test/test_component.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
if os.environ.get('USE_TWISTED', False): if os.environ.get('USE_TWISTED', False):
from autobahn.twisted.component import Component from autobahn.twisted.component import Component
from zope.interface import directlyProvides from zope.interface import directlyProvides
from autobahn.wamp.message import Welcome, Goodbye from autobahn.wamp.message import Welcome, Goodbye, Hello, Abort
from autobahn.wamp.exception import ApplicationError
from autobahn.wamp.serializer import JsonSerializer from autobahn.wamp.serializer import JsonSerializer
from twisted.internet.interfaces import IStreamClientEndpoint from twisted.internet.interfaces import IStreamClientEndpoint
from twisted.internet.defer import inlineCallbacks, succeed from twisted.internet.defer import inlineCallbacks, succeed
Expand Down Expand Up @@ -112,11 +113,72 @@ def connect(factory, **kw):
# makes this "hard". # makes this "hard".
reactor = Clock() reactor = Clock()
with replace_loop(reactor): with replace_loop(reactor):
yield component.start() yield component.start(reactor=reactor)
self.assertTrue(len(joins), 1) self.assertTrue(len(joins), 1)
# make sure we fire all our time-outs # make sure we fire all our time-outs
reactor.advance(3600) reactor.advance(3600)


@patch('autobahn.twisted.component.sleep', return_value=succeed(None))
@inlineCallbacks
def test_connect_no_auth_method(self, fake_sleep):
endpoint = Mock()

directlyProvides(endpoint, IStreamClientEndpoint)
component = Component(
transports={
"type": "websocket",
"url": "ws://127.0.0.1/ws",
"endpoint": endpoint,
}
)

def connect(factory, **kw):
proto = factory.buildProtocol('boom')
proto.makeConnection(Mock())

from autobahn.websocket.protocol import WebSocketProtocol
from base64 import b64encode
from hashlib import sha1
key = proto.websocket_key + WebSocketProtocol._WS_MAGIC
proto.data = (
b"HTTP/1.1 101 Switching Protocols\x0d\x0a"
b"Upgrade: websocket\x0d\x0a"
b"Connection: upgrade\x0d\x0a"
b"Sec-Websocket-Protocol: wamp.2.json\x0d\x0a"
b"Sec-Websocket-Accept: " + b64encode(sha1(key).digest()) + b"\x0d\x0a\x0d\x0a"
)
proto.processHandshake()

from autobahn.wamp import role
subrole = role.RoleSubscriberFeatures()

msg = Hello(u"realm", roles=dict(subscriber=subrole), authmethods=[u"anonymous"])
serializer = JsonSerializer()
data, is_binary = serializer.serialize(msg)
proto.onMessage(data, is_binary)

msg = Abort(reason=u"wamp.error.no_auth_method")
proto.onMessage(*serializer.serialize(msg))
proto.onClose(False, 100, u"wamp.error.no_auth_method")

return succeed(proto)
endpoint.connect = connect

# XXX it would actually be nicer if we *could* support
# passing a reactor in here, but the _batched_timer =
# make_batched_timer() stuff (slash txaio in general)
# makes this "hard".
reactor = Clock()
with replace_loop(reactor):
with self.assertRaises(ApplicationError) as ctx:
yield component.start(reactor=reactor)
# make sure we fire all our time-outs
reactor.advance(3600)
self.assertIn(
"no_auth_method",
str(ctx.exception)
)

class InvalidTransportConfigs(unittest.TestCase): class InvalidTransportConfigs(unittest.TestCase):


def test_invalid_key(self): def test_invalid_key(self):
Expand Down
45 changes: 45 additions & 0 deletions autobahn/wamp/auth.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ def create_authenticator(name, **kwargs):
klass = { klass = {
AuthWampCra.name: AuthWampCra, AuthWampCra.name: AuthWampCra,
AuthCryptoSign.name: AuthCryptoSign, AuthCryptoSign.name: AuthCryptoSign,
AuthAnonymous.name: AuthAnonymous,
AuthTicket.name: AuthTicket,
}[name] }[name]
except KeyError: except KeyError:
raise ValueError( raise ValueError(
Expand All @@ -78,6 +80,49 @@ def create_authenticator(name, **kwargs):




# experimental authentication API # experimental authentication API
class AuthAnonymous(object):
name = u'anonymous'

def __init__(self, **kw):
self._args = kw

@property
def authextra(self):
return self._args.get(u'authextra', dict())

def on_challenge(self, session, challenge):
raise RuntimeError(
"on_challenge called on anonymous authentication"
)


IAuthenticator.register(AuthAnonymous)


class AuthTicket(object):
name = u'ticket'

def __init__(self, **kw):
self._args = kw
try:
self._ticket = self._args.pop(u'ticket')
except KeyError:
raise ValueError(
"ticket authentication requires 'ticket=' kwarg"
)

@property
def authextra(self):
return self._args.get(u'authextra', dict())

def on_challenge(self, session, challenge):
assert challenge.method == u"ticket"
return self._ticket


IAuthenticator.register(AuthTicket)


class AuthCryptoSign(object): class AuthCryptoSign(object):
name = u'cryptosign' name = u'cryptosign'


Expand Down
15 changes: 11 additions & 4 deletions autobahn/wamp/component.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from autobahn.util import ObservableMixin from autobahn.util import ObservableMixin
from autobahn.websocket.util import parse_url from autobahn.websocket.util import parse_url
from autobahn.wamp.types import ComponentConfig, SubscribeOptions, RegisterOptions from autobahn.wamp.types import ComponentConfig, SubscribeOptions, RegisterOptions
from autobahn.wamp.exception import SessionNotReady from autobahn.wamp.exception import SessionNotReady, ApplicationError
from autobahn.wamp.auth import create_authenticator from autobahn.wamp.auth import create_authenticator




Expand Down Expand Up @@ -478,7 +478,7 @@ def _connect_once(self, reactor, transport):
def create_session(): def create_session():
cfg = ComponentConfig(self._realm, self._extra) cfg = ComponentConfig(self._realm, self._extra)
try: try:
session = self.session_factory(cfg) self._session = session = self.session_factory(cfg)
for auth_name, auth_config in self._authentication.items(): for auth_name, auth_config in self._authentication.items():
authenticator = create_authenticator(auth_name, **auth_config) authenticator = create_authenticator(auth_name, **auth_config)
session.add_authenticator(authenticator) session.add_authenticator(authenticator)
Expand All @@ -505,8 +505,15 @@ def on_leave(session, details):
"session leaving '{details.reason}'", "session leaving '{details.reason}'",
details=details, details=details,
) )
if self._entry and not txaio.is_called(done): if not txaio.is_called(done):
txaio.resolve(done, None) if details.reason in [u"wamp.error.no_auth_method"]:
txaio.resolve(done, txaio.create_failure(
ApplicationError(
u"wamp.error.no_auth_method"
)
))
else:
txaio.resolve(done, None)
session.on('leave', on_leave) session.on('leave', on_leave)


# if we were given a "main" procedure, we run through # if we were given a "main" procedure, we run through
Expand Down
11 changes: 10 additions & 1 deletion autobahn/wamp/cryptosign.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -548,11 +548,20 @@ def from_ssh_key(cls, filename):
key (from a SSH private key file) or a (public) verification key (from a SSH key (from a SSH private key file) or a (public) verification key (from a SSH
public key file). A private key file must be passphrase-less. public key file). A private key file must be passphrase-less.
""" """
SSH_BEGIN = u'-----BEGIN OPENSSH PRIVATE KEY-----'


with open(filename, 'rb') as f: with open(filename, 'rb') as f:
keydata = f.read().decode('utf-8').strip() keydata = f.read().decode('utf-8').strip()
return cls.from_ssh_data(keydata)


@util.public
@classmethod
def from_ssh_data(cls, keydata):
"""
Load an Ed25519 key from SSH key file. The key file can be a (private) signing
key (from a SSH private key file) or a (public) verification key (from a SSH
public key file). A private key file must be passphrase-less.
"""
SSH_BEGIN = u'-----BEGIN OPENSSH PRIVATE KEY-----'
if keydata.startswith(SSH_BEGIN): if keydata.startswith(SSH_BEGIN):
# OpenSSH private key # OpenSSH private key
keydata, comment = _read_ssh_ed25519_privkey(keydata) keydata, comment = _read_ssh_ed25519_privkey(keydata)
Expand Down
5 changes: 2 additions & 3 deletions autobahn/wamp/protocol.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def onOpen(self, transport):
txaio.add_callbacks( txaio.add_callbacks(
d, d,
lambda _: txaio.as_future(self.onConnect), lambda _: txaio.as_future(self.onConnect),
None, lambda fail: self._swallow_error(fail, "While calling 'onConnect'")
) )


@public @public
Expand Down Expand Up @@ -511,7 +511,6 @@ def onMessage(self, msg):
) )


elif isinstance(msg, message.Abort): elif isinstance(msg, message.Abort):

# fire callback and close the transport # fire callback and close the transport
details = types.CloseDetails(msg.reason, msg.message) details = types.CloseDetails(msg.reason, msg.message)
d = txaio.as_future(self.onLeave, details) d = txaio.as_future(self.onLeave, details)
Expand Down Expand Up @@ -1654,7 +1653,7 @@ def at_most_one(name):
# here we check that any duplicate keys have the same values # here we check that any duplicate keys have the same values
authextra = authenticator.authextra authextra = authenticator.authextra
merged = self._merged_authextra() merged = self._merged_authextra()
for k, v in merged: for k, v in merged.items():
if k in authextra and authextra[k] != v: if k in authextra and authextra[k] != v:
raise ValueError( raise ValueError(
"Inconsistent authextra values for '{}': '{}' vs '{}'".format( "Inconsistent authextra values for '{}': '{}' vs '{}'".format(
Expand Down
44 changes: 42 additions & 2 deletions autobahn/wamp/test/test_cryptosign.py
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@
############################################################################### ###############################################################################


from __future__ import absolute_import from __future__ import absolute_import
import hashlib
from mock import Mock


from autobahn.wamp.cryptosign import _makepad, HAS_CRYPTOSIGN from autobahn.wamp.cryptosign import _makepad, HAS_CRYPTOSIGN
from autobahn.wamp import types
from autobahn.wamp.auth import create_authenticator


if HAS_CRYPTOSIGN: if HAS_CRYPTOSIGN:
from autobahn.wamp.cryptosign import SigningKey from autobahn.wamp.cryptosign import SigningKey
from nacl.encoding import HexEncoder


import tempfile import tempfile


Expand All @@ -41,13 +46,48 @@
gQAAAAtzc2gtZWQyNTUxOQAAACAa38i/4dNWFuZN/72QAJbyOwZvkUyML/u2b2B1uW4RbQ gQAAAAtzc2gtZWQyNTUxOQAAACAa38i/4dNWFuZN/72QAJbyOwZvkUyML/u2b2B1uW4RbQ
AAAEBNV9l6aPVVaWYgpthJwM5YJWhRjXKet1PcfHMt4oBFEBrfyL/h01YW5k3/vZAAlvI7 AAAEBNV9l6aPVVaWYgpthJwM5YJWhRjXKet1PcfHMt4oBFEBrfyL/h01YW5k3/vZAAlvI7
Bm+RTIwv+7ZvYHW5bhFtAAAAFXNvbWV1c2VyQGZ1bmt0aGF0LmNvbQ== Bm+RTIwv+7ZvYHW5bhFtAAAAFXNvbWV1c2VyQGZ1bmt0aGF0LmNvbQ==
-----END OPENSSH PRIVATE KEY----- -----END OPENSSH PRIVATE KEY-----'''
'''


pubkey = '''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJVp3hjHwIQyEladzd8mFcf0YSXcmyKS3qMLB7VqTQKm someuser@example.com pubkey = '''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJVp3hjHwIQyEladzd8mFcf0YSXcmyKS3qMLB7VqTQKm someuser@example.com
''' '''




@unittest.skipIf(not HAS_CRYPTOSIGN, 'nacl library not present')
class TestAuth(unittest.TestCase):

def setUp(self):
self.key = SigningKey.from_ssh_data(keybody)
self.privkey_hex = self.key._key.encode(encoder=HexEncoder)
m = hashlib.sha256()
m.update("some TLS message")
self.channel_id = m.digest()

def test_valid(self):
session = Mock()
session._transport.get_channel_id = Mock(return_value=self.channel_id)
challenge = types.Challenge(u"ticket", dict(challenge="ff" * 32))
signed = self.key.sign_challenge(session, challenge)
self.assertEqual(
u'9b6f41540c9b95b4b7b281c3042fa9c54cef43c842d62ea3fd6030fcb66e70b3e80d49d44c29d1635da9348d02ec93f3ed1ef227dfb59a07b580095c2b82f80f9d16ca518aa0c2b707f2b2a609edeca73bca8dd59817a633f35574ac6fd80d00',
signed.result,
)

def test_authenticator(self):
authenticator = create_authenticator(
u"cryptosign",
authid="someone",
privkey=self.privkey_hex,
)
session = Mock()
session._transport.get_channel_id = Mock(return_value=self.channel_id)
challenge = types.Challenge(u"cryptosign", dict(challenge="ff" * 32))
reply = authenticator.on_challenge(session, challenge)
self.assertEqual(
reply.result,
u'9b6f41540c9b95b4b7b281c3042fa9c54cef43c842d62ea3fd6030fcb66e70b3e80d49d44c29d1635da9348d02ec93f3ed1ef227dfb59a07b580095c2b82f80f9d16ca518aa0c2b707f2b2a609edeca73bca8dd59817a633f35574ac6fd80d00',
)


class TestKey(unittest.TestCase): class TestKey(unittest.TestCase):


def test_pad(self): def test_pad(self):
Expand Down

0 comments on commit 6bcca8e

Please sign in to comment.