Skip to content

Commit

Permalink
Merge pull request #799 from meejah/newapi-improvements.6-authentication
Browse files Browse the repository at this point in the history
experiment: authentication API
  • Loading branch information
oberstet committed Mar 31, 2017
2 parents f620b27 + b40990e commit 5ba5e77
Show file tree
Hide file tree
Showing 9 changed files with 397 additions and 9 deletions.
184 changes: 181 additions & 3 deletions autobahn/twisted/wamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import six
import inspect
import binascii

import txaio
txaio.use_twisted() # noqa
Expand All @@ -45,9 +46,11 @@
from autobahn.websocket.compress import PerMessageDeflateOffer, \
PerMessageDeflateResponse, PerMessageDeflateResponseAccept

from autobahn.wamp import protocol
from autobahn.wamp import protocol, auth
from autobahn.wamp.interfaces import IAuthenticator
from autobahn.wamp.types import ComponentConfig


__all__ = [
'ApplicationSession',
'ApplicationSessionFactory',
Expand Down Expand Up @@ -733,18 +736,118 @@ class Session(ApplicationSession):
# ApplicationSession a subclass of Session -- and it can then be
# separate deprecated and removed, ultimately, if desired.

#: name -> IAuthenticator
_authenticators = None

def onJoin(self, details):
return self.on_join(details)

# XXX def onChallenge(self, )
# XXX def onOpen ??
def onConnect(self):
if self._authenticators:
# authid, authrole *must* match across all authenticators
# (checked in add_authenticator) so these lists are either
# [None] or [None, 'some_authid']
authid = [x._args.get('authid', None) for x in self._authenticators.values()][-1]
authrole = [x._args.get('authrole', None) for x in self._authenticators.values()][-1]
authextra = self._merged_authextra()
self.join(
self.config.realm,
authmethods=self._authenticators.keys(),
authid=authid or u'public',
authrole=authrole or u'default',
authextra=authextra,
)
else:
super(Session, self).onConnect()

def onChallenge(self, challenge):
try:
authenticator = self._authenticators[challenge.method]
except KeyError:
raise RuntimeError(
"Received challenge for unknown authmethod '{}'".format(
challenge.method
)
)
return authenticator.on_challenge(self, challenge)

def onLeave(self, details):
return self.on_leave(details)

def onDisconnect(self):
return self.on_disconnect()

# experimental authentication API

def add_authenticator(self, name, **kw):
if self._authenticators is None:
self._authenticators = {}
try:
auth = {
'cryptosign': AuthCryptoSign,
'wampcra': AuthWampCra,
}[name](**kw)
except KeyError:
raise RuntimeError(
"Unknown authenticator '{}'".format(name)
)

# all authids must match
unique_authids = set([
a['authid']
for a in self._authenticators.values()
if 'authid' in a
])
if len(unique_authids) > 1:
raise ValueError(
"Inconsistent authids: {}".format(
' '.join(unique_authids),
)
)

# all authroles must match
unique_authroles = set([
a['authrole']
for a in self._authenticators.values()
if 'authrole' in a
])
if len(unique_authroles) > 1:
raise ValueError(
"Inconsistent authroles: '{}' vs '{}'".format(
' '.join(unique_authroles),
)
)

# can we do anything else other than merge all authextra keys?
# here we check that any duplicate keys have the same values
authextra = kw.get('authextra', {})
merged = self._merged_authextra()
for k, v in merged:
if k in authextra and authextra[k] != v:
raise ValueError(
"Inconsistent authextra values for '{}': '{}' vs '{}'".format(
k, v, authextra[k],
)
)

# validation complete, add it
self._authenticators[name] = auth

def _merged_authextra(self):
authextras = [a._args.get('authextra', {}) for a in self._authenticators.values()]
# for all existing _authenticators, we've already checked that
# if they contain a key it has the same value as all others.
return {
k: v
for k, v in zip(
reduce(lambda x, y: x | set(y.keys()), authextras, set()),
reduce(lambda x, y: x | set(y.values()), authextras, set())
)
}

# these are the actual "new API" methods (i.e. snake_case)
#

def on_join(self, details):
pass

Expand All @@ -753,3 +856,78 @@ def on_leave(self, details):

def on_disconnect(self):
pass


# experimental authentication API
class AuthCryptoSign(object):

def __init__(self, **kw):
# should put in checkconfig or similar
for key in kw.keys():
if key not in [u'authextra', u'authid', u'authrole', u'privkey']:
raise ValueError(
"Unexpected key '{}' for {}".format(key, self.__class__.__name__)
)
for key in [u'privkey', u'authid']:
if key not in kw:
raise ValueError(
"Must provide '{}' for cryptosign".format(key)
)
for key in kw.get('authextra', dict()):
if key not in [u'pubkey']:
raise ValueError(
"Unexpected key '{}' in 'authextra'".format(key)
)

from autobahn.wamp.cryptosign import SigningKey
self._privkey = SigningKey.from_key_bytes(
binascii.a2b_hex(kw[u'privkey'])
)

if u'pubkey' in kw.get(u'authextra', dict()):
pubkey = kw[u'authextra'][u'pubkey']
if pubkey != self._privkey.public_key():
raise ValueError(
"Public key doesn't correspond to private key"
)
else:
kw[u'authextra'] = kw.get(u'authextra', dict())
kw[u'authextra'][u'pubkey'] = self._privkey.public_key()
self._args = kw

def on_challenge(self, session, challenge):
return self._privkey.sign_challenge(session, challenge)


IAuthenticator.register(AuthCryptoSign)


class AuthWampCra(object):

def __init__(self, **kw):
# should put in checkconfig or similar
for key in kw.keys():
if key not in [u'authextra', u'authid', u'authrole', u'secret']:
raise ValueError(
"Unexpected key '{}' for {}".format(key, self.__class__.__name__)
)
for key in [u'secret', u'authid']:
if key not in kw:
raise ValueError(
"Must provide '{}' for wampcra".format(key)
)

self._args = kw
self._secret = kw.pop(u'secret')
if not isinstance(self._secret, six.text_type):
self._secret = self._secret.decode('utf8')

def on_challenge(self, session, challenge):
signature = auth.compute_wcs(
self._secret.encode('utf8'),
challenge.extra['challenge'].encode('utf8')
)
return signature.decode('ascii')


IAuthenticator.register(AuthWampCra)
11 changes: 10 additions & 1 deletion autobahn/wamp/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ def do_registration(session, details):
self.on('join', do_registration)
return decorator

def __init__(self, main=None, transports=None, config=None, realm=u'default', extra=None):
def __init__(self, main=None, transports=None, config=None, realm=u'default', extra=None, authentication=None):
"""
:param main: After a transport has been connected and a session
has been established and joined to a realm, this (async)
Expand Down Expand Up @@ -380,6 +380,9 @@ def __init__(self, main=None, transports=None, config=None, realm=u'default', ex
:param realm: the realm to join
:type realm: unicode
:param authentication: configuration of authenticators
:type authentication: dict mapping auth_type to dict
"""
self.set_valid_events(
[
Expand Down Expand Up @@ -426,6 +429,9 @@ def __init__(self, main=None, transports=None, config=None, realm=u'default', ex
_create_transport(idx, transport, self._check_native_endpoint)
)

# XXX should have some checkconfig support
self._authentication = authentication or {}

self._realm = realm
self._extra = extra

Expand Down Expand Up @@ -458,6 +464,9 @@ def create_session():
cfg = ComponentConfig(self._realm, self._extra)
try:
session = self.session_factory(cfg)
for auth_name, auth_config in self._authentication.items():
session.add_authenticator(auth_name, **auth_config)

except Exception as e:
# couldn't instantiate session calls, which is fatal.
# let the reconnection logic deal with that
Expand Down
17 changes: 12 additions & 5 deletions autobahn/wamp/cryptosign.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,17 @@ def process(signature_raw):
return d2

@util.public
@classmethod
def from_key_bytes(cls, keydata, comment=None):
if not (comment is None or type(comment) == six.text_type):
raise ValueError("invalid type {} for comment".format(type(comment)))

if len(keydata) != 32:
raise ValueError("invalid key length {}".format(len(keydata)))

key = signing.SigningKey(keydata)
return cls(key, comment)

@classmethod
def from_raw_key(cls, filename, comment=None):
"""
Expand All @@ -522,11 +533,7 @@ def from_raw_key(cls, filename, comment=None):
with open(filename, 'rb') as f:
keydata = f.read()

if len(keydata) != 32:
raise Exception("invalid key length {}".format(len(keydata)))

key = signing.SigningKey(keydata)
return cls(key, comment)
return cls.from_key_bytes(keydata, comment=comment)

@util.public
@classmethod
Expand Down
10 changes: 10 additions & 0 deletions autobahn/wamp/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,13 @@ def subscribe(handler, topic=None, options=None):
:returns: A single Deferred/Future or a list of such objects
:rtype: instance(s) of :tx:`twisted.internet.defer.Deferred` / :py:class:`asyncio.Future`
"""


# experimental authentication API
@six.add_metaclass(abc.ABCMeta)
class IAuthenticator(object):

@abc.abstractmethod
def on_challenge(session, challenge):
"""
"""
12 changes: 12 additions & 0 deletions examples/router/.crossbar/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@
"anonymous": {
"type": "static",
"role": "anonymous"
},
"cryptosign": {
"type": "static",
"principals": {
"alice": {
"realm": "crossbardemo",
"role": "authenticated",
"authorized_keys": [
"020b13239ca0f10a1c65feaf26e8dfca6e84c81d2509a2b7b75a7e5ee5ce4b66"
]
}
}
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions examples/twisted/wamp/auth/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
###############################################################################
#
# The MIT License (MIT)
#
# Copyright (c) Crossbar.io Technologies GmbH
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
###############################################################################

from os import environ
from twisted.internet import reactor
from twisted.internet.defer import inlineCallbacks

from autobahn.twisted.wamp import Session, ApplicationRunner


class Component(Session):
"""
An application component calling the different backend procedures.
"""

def onJoin(self, details):
print("session attached {}".format(details))
return self.leave()


if __name__ == '__main__':
runner = ApplicationRunner(
environ.get("AUTOBAHN_DEMO_ROUTER", u"ws://127.0.0.1:8080/auth_ws"),
u"crossbardemo",
)

def make(config):
session = Component(config)
session.add_authenticator(
u"wampcra", authid=u'username', secret=u'p4ssw0rd'
)
return session
runner.run(make)

0 comments on commit 5ba5e77

Please sign in to comment.