From 86976a27e7aa9222afc0695c240b0ea7cc8e362b Mon Sep 17 00:00:00 2001 From: "Kali Kaneko (leap communications)" Date: Wed, 9 Nov 2016 20:34:51 +0100 Subject: [feature] authentication classes and tests --- src/leap/bitmask/core/_web.py | 121 +++++++++++++++++++++++++++++++++--- src/leap/bitmask/core/dispatcher.py | 3 +- src/leap/bitmask/core/service.py | 8 ++- tests/unit/core/test_web_api.py | 105 +++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 tests/unit/core/test_web_api.py diff --git a/src/leap/bitmask/core/_web.py b/src/leap/bitmask/core/_web.py index 1cbba19..c0c041e 100644 --- a/src/leap/bitmask/core/_web.py +++ b/src/leap/bitmask/core/_web.py @@ -23,14 +23,18 @@ import json import os import pkg_resources -from twisted.internet import reactor from twisted.application import service from twisted.internet import endpoints -from twisted.web.resource import Resource +from twisted.cred import portal, checkers, credentials, error as credError +from twisted.internet import reactor, defer +from twisted.logger import Logger +from twisted.web.guard import HTTPAuthSessionWrapper, BasicCredentialFactory +from twisted.web.resource import IResource, Resource from twisted.web.server import Site, NOT_DONE_YET from twisted.web.static import File -from twisted.logger import Logger + +from zope.interface import implementer from leap.bitmask.util import here from leap.bitmask.core.dispatcher import CommandDispatcher @@ -49,6 +53,90 @@ except Exception: log = Logger() +class TokenCredentialFactory(BasicCredentialFactory): + scheme = 'token' + + +@implementer(checkers.ICredentialsChecker) +class TokenDictChecker: + + credentialInterfaces = (credentials.IUsernamePassword, + credentials.IUsernameHashedPassword) + + def __init__(self, tokens): + "tokens: a dict-like object mapping usernames to session-tokens" + self.tokens = tokens + + def requestAvatarId(self, credentials): + username = credentials.username + if username in self.tokens: + if credentials.checkPassword(self.tokens[username]): + return defer.succeed(username) + else: + return defer.fail( + credError.UnauthorizedLogin("Bad session token")) + else: + return defer.fail( + credError.UnauthorizedLogin("No such user")) + + +@implementer(portal.IRealm) +class HttpPasswordRealm(object): + + def __init__(self, resource): + self.resource = resource + + def requestAvatar(self, user, mind, *interfaces): + if IResource in interfaces: + # the resource is passed on regardless of user + return (IResource, self.resource, lambda: None) + raise NotImplementedError() + + +@implementer(IResource) +class WhitelistHTTPAuthSessionWrapper(HTTPAuthSessionWrapper): + + """ + Wrap a portal, enforcing supported header-based authentication schemes. + It doesn't apply the enforcement to routes included in a whitelist. + """ + + # TODO extend this to inspect the data -- so that we pass a tuple + # with the action + + whitelist = (None,) + + def __init__(self, *args, **kw): + self.whitelist = kw.pop('whitelist', tuple()) + super(WhitelistHTTPAuthSessionWrapper, self).__init__( + *args, **kw) + + def getChildWithDefault(self, path, request): + if request.path in self.whitelist: + return self + return HTTPAuthSessionWrapper.getChildWithDefault(self, path, request) + + def render(self, request): + if request.path in self.whitelist: + _res = self._portal.realm.resource + return _res.render(request) + return HTTPAuthSessionWrapper.render(self, request) + + + +def protectedResourceFactory(resource, passwords, whitelist): + realm = HttpPasswordRealm(resource) + # TODO this should have the per-site tokens. + # can put it inside the API Resource object. + checker = PasswordDictChecker(passwords) + resource_portal = portal.Portal(realm, [checker]) + credentialFactory = TokenCredentialFactory('localhost') + protected_resource = WhitelistHTTPAuthSessionWrapper( + resource_portal, [credentialFactory], + whitelist=whitelist) + return protected_resource + + class HTTPDispatcherService(service.Service): """ @@ -58,11 +146,16 @@ class HTTPDispatcherService(service.Service): If the package ``leap.bitmask_js`` is found in the import path, we'll serve the whole JS UI in the root resource too (under the ``public`` path). - + If that package cannot be found, we'll serve just the javascript wrapper around the REST API. """ + API_WHITELIST = ( + '/API/bonafide/user', + ) + + def __init__(self, core, port=7070, debug=False, onion=False): self._core = core self.port = port @@ -71,6 +164,7 @@ class HTTPDispatcherService(service.Service): self.uri = '' def startService(self): + # TODO refactor this, too long---------------------------------------- if HAS_WEB_UI: webdir = os.path.abspath( pkg_resources.resource_filename('leap.bitmask_js', 'public')) @@ -83,18 +177,27 @@ class HTTPDispatcherService(service.Service): here(), '..', '..', '..', 'ui', 'app', 'lib', 'bitmask.js') jsapi = File(os.path.abspath(jspath)) + root = File(webdir) + # TODO move this to the tests... + DUMMY_PASS = {'user1': 'pass'} + api = Api(CommandDispatcher(self._core)) - root.putChild(u'API', api) + protected_api = protectedResourceFactory( + api, DUMMY_PASS, self.API_WHITELIST) + root.putChild(u'API', protected_api) + + if not HAS_WEB_UI: + root.putChild('bitmask.js', jsapi) # TODO --- pass requestFactory for header authentication + # so that we remove the setting of the cookie. + + # http://www.tsheffler.com/blog/2011/09/22/twisted-learning-about-cred-and-basicdigest-authentication/#Digest_Authentication factory = Site(root) self.site = factory - if not HAS_WEB_UI: - root.putChild('bitmask.js', jsapi) - if self.onion: try: import txtorcon @@ -102,13 +205,13 @@ class HTTPDispatcherService(service.Service): log.error('onion is enabled, but could not find txtorcon') return self._start_onion_service(factory) - else: interface = '127.0.0.1' endpoint = endpoints.TCP4ServerEndpoint( reactor, self.port, interface=interface) self.uri = 'https://%s:%s' % (interface, self.port) endpoint.listen(factory) + # TODO this should be set in a callback to the listen call self.running = True diff --git a/src/leap/bitmask/core/dispatcher.py b/src/leap/bitmask/core/dispatcher.py index 11a319f..5900390 100644 --- a/src/leap/bitmask/core/dispatcher.py +++ b/src/leap/bitmask/core/dispatcher.py @@ -43,6 +43,7 @@ class SubCommand(object): __metaclass__ = APICommand def dispatch(self, service, *parts, **kw): + subcmd = '' try: subcmd = parts[1] _method = getattr(self, 'do_' + subcmd.upper(), None) @@ -402,7 +403,7 @@ class CommandDispatcher(object): return d def do_WEBUI(self, *parts): - subcmd = parts[1] + subcmd = parts[1] if parts and len(parts) > 1 else None dispatch = self.subcommand_webui.dispatch if subcmd == 'enable': diff --git a/src/leap/bitmask/core/service.py b/src/leap/bitmask/core/service.py index 9fde788..cba6f8d 100644 --- a/src/leap/bitmask/core/service.py +++ b/src/leap/bitmask/core/service.py @@ -51,6 +51,12 @@ else: class BitmaskBackend(configurable.ConfigurableService): + """ + The Bitmask Core Backend Service. + Here is where the multiple service tree gets composed. + This is passed to the command dispatcher. + """ + def __init__(self, basedir=configurable.DEFAULT_BASEDIR): configurable.ConfigurableService.__init__(self, basedir) @@ -217,7 +223,7 @@ class BitmaskBackend(configurable.ConfigurableService): class BackendCommands(object): """ - General commands for the BitmaskBackend Core Service. + General Commands for the BitmaskBackend Core Service. """ def __init__(self, core): diff --git a/tests/unit/core/test_web_api.py b/tests/unit/core/test_web_api.py new file mode 100644 index 0000000..f440417 --- /dev/null +++ b/tests/unit/core/test_web_api.py @@ -0,0 +1,105 @@ +import base64 + +from twisted.cred import portal +from twisted.trial import unittest +from twisted.web.test.test_web import DummyRequest +from twisted.web import resource + +from leap.bitmask.core import _web + + +def b64encode(s): + return base64.b64encode(s).strip() + + +class APIMixin: + """ + L{TestCase} mixin class which defines a number of tests for + L{basic.BasicCredentialFactory}. Because this mixin defines C{setUp}, it + must be inherited before L{TestCase}. + """ + def setUp(self): + self.request = self.makeRequest() + + api = AuthTestResource() + self.realm = _web.HttpPasswordRealm(api) + tokens = {'testuser': 'token'} + checker = _web.TokenDictChecker(tokens) + self.portal = portal.Portal(self.realm, [checker]) + + def makeRequest(self, method=b'GET', clientAddress=None): + """ + Create a request object to be passed to + TokenCredentialFactory.decode along with a response value. + Override this in a subclass. + """ + raise NotImplementedError("%r did not implement makeRequest" % ( + self.__class__,)) + + +class WhitelistedResourceTests(APIMixin, unittest.TestCase): + + def makeRequest(self, method=b'GET', clientAddress=None, path='/'): + """ + Create a L{DummyRequest} (change me to create a + L{twisted.web.http.Request} instead). + """ + request = DummyRequest(b'/') + request.method = method + request.client = clientAddress + request.path = path + return request + + def test_render_returns_unauthorized_by_default(self): + """ + By default, a Whitelisted resource renders with a 401 response code and + a I{WWW-Authenticate} header and puts a simple unauthorized message + into the response body. + """ + protected = _web.WhitelistHTTPAuthSessionWrapper( + self.portal, + [_web.TokenCredentialFactory('localhost')]) + request = self.makeRequest(method='POST', path='/') + request.render(protected) + assert request.responseCode == 401 + + auth_header = request.responseHeaders.getRawHeaders( + b'www-authenticate') + assert auth_header == [b'token realm="localhost"'] + assert b'Unauthorized' == b''.join(request.written) + + def test_whitelisted_resource_does_render(self): + protected = _web.WhitelistHTTPAuthSessionWrapper( + self.portal, + [_web.TokenCredentialFactory('localhost')], + whitelist=['/whitelisted']) + request = self.makeRequest(method='GET', path='/whitelisted') + request.render(protected) + assert b'dummyGET' == b''.join(request.written) + + def test_good_token_authenticates(self): + protected = _web.WhitelistHTTPAuthSessionWrapper( + self.portal, + [_web.TokenCredentialFactory('localhost')], + whitelist=[]) + request = self.makeRequest(method='GET', path='/') + authorization = b64encode(b'testuser:token') + request.requestHeaders.addRawHeader(b'authorization', + b'Token ' + authorization) + request.render(protected) + assert b'dummyGET' == b''.join(request.written) + + def test_session_does_not_use_cookies(self): + # TODO + pass + + +class AuthTestResource(resource.Resource): + + isLeaf = True + + def render_GET(self, request): + return "dummyGET" + + def render_POST(self, request): + return "dummyPOST" -- cgit v1.2.3