summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKali Kaneko (leap communications) <kali@leap.se>2016-11-09 20:34:51 +0100
committerKali Kaneko (leap communications) <kali@leap.se>2016-12-29 03:09:50 +0100
commit86976a27e7aa9222afc0695c240b0ea7cc8e362b (patch)
tree8f19391d07b0f2e67b94f3f1e9bc168bd3e4cea8
parentc3acb3ca45480d3a4d72731ca68b69bec6db4e2c (diff)
[feature] authentication classes and tests
-rw-r--r--src/leap/bitmask/core/_web.py121
-rw-r--r--src/leap/bitmask/core/dispatcher.py3
-rw-r--r--src/leap/bitmask/core/service.py8
-rw-r--r--tests/unit/core/test_web_api.py105
4 files changed, 226 insertions, 11 deletions
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"