summaryrefslogtreecommitdiff
path: root/server/src/leap/soledad
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/leap/soledad')
-rw-r--r--server/src/leap/soledad/server/__init__.py8
-rw-r--r--server/src/leap/soledad/server/_blobs.py44
-rw-r--r--server/src/leap/soledad/server/_config.py (renamed from server/src/leap/soledad/server/config.py)33
-rw-r--r--server/src/leap/soledad/server/_resource.py87
-rw-r--r--server/src/leap/soledad/server/_server_info.py44
-rw-r--r--server/src/leap/soledad/server/_wsgi.py (renamed from server/src/leap/soledad/server/application.py)30
-rw-r--r--server/src/leap/soledad/server/auth.py529
-rw-r--r--server/src/leap/soledad/server/entrypoint.py50
-rw-r--r--server/src/leap/soledad/server/resource.py53
-rw-r--r--server/src/leap/soledad/server/session.py107
-rw-r--r--server/src/leap/soledad/server/url_mapper.py77
11 files changed, 600 insertions, 462 deletions
diff --git a/server/src/leap/soledad/server/__init__.py b/server/src/leap/soledad/server/__init__.py
index 039bef75..5bed22c9 100644
--- a/server/src/leap/soledad/server/__init__.py
+++ b/server/src/leap/soledad/server/__init__.py
@@ -88,15 +88,17 @@ import sys
from leap.soledad.common.l2db.remote import http_app, utils
from leap.soledad.common import SHARED_DB_NAME
-from leap.soledad.server.sync import SyncResource
-from leap.soledad.server.sync import MAX_REQUEST_SIZE
-from leap.soledad.server.sync import MAX_ENTRY_SIZE
+from .sync import SyncResource
+from .sync import MAX_REQUEST_SIZE
+from .sync import MAX_ENTRY_SIZE
from ._version import get_versions
+from ._config import get_config
__all__ = [
'SoledadApp',
+ 'get_config',
'__version__',
]
diff --git a/server/src/leap/soledad/server/_blobs.py b/server/src/leap/soledad/server/_blobs.py
new file mode 100644
index 00000000..cacabbdf
--- /dev/null
+++ b/server/src/leap/soledad/server/_blobs.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# _blobs.py
+# Copyright (C) 2017 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Blobs Server implementation.
+"""
+from twisted.web import resource
+
+from ._config import get_config
+
+
+__all__ = ['BlobsResource', 'blobs_resource']
+
+
+class BlobsResource(resource.Resource):
+
+ isLeaf = True
+
+ def __init__(self, blobs_path):
+ resource.Resource.__init__(self)
+ self._blobs_path = blobs_path
+
+ def render_GET(self, request):
+ return 'blobs is not implemented yet!'
+
+
+# provide a configured instance of the resource
+_config = get_config()
+_path = _config['blobs_path']
+
+blobs_resource = BlobsResource(_path)
diff --git a/server/src/leap/soledad/server/config.py b/server/src/leap/soledad/server/_config.py
index 3c17ec19..e89e70d6 100644
--- a/server/src/leap/soledad/server/config.py
+++ b/server/src/leap/soledad/server/_config.py
@@ -19,12 +19,17 @@
import configparser
+__all__ = ['get_config']
+
+
CONFIG_DEFAULTS = {
'soledad-server': {
'couch_url': 'http://localhost:5984',
'create_cmd': None,
'admin_netrc': '/etc/couchdb/couchdb-admin.netrc',
- 'batching': True
+ 'batching': True,
+ 'blobs': False,
+ 'blobs_path': '/srv/leap/soledad/blobs',
},
'database-security': {
'members': ['soledad'],
@@ -35,7 +40,17 @@ CONFIG_DEFAULTS = {
}
-def load_configuration(file_path):
+_config = None
+
+
+def get_config(section='soledad-server'):
+ global _config
+ if not _config:
+ _config = _load_config('/etc/soledad/soledad-server.conf')
+ return _config[section]
+
+
+def _load_config(file_path):
"""
Load server configuration from file.
@@ -45,23 +60,23 @@ def load_configuration(file_path):
@return: A dictionary with the configuration.
@rtype: dict
"""
- defaults = dict(CONFIG_DEFAULTS)
+ conf = dict(CONFIG_DEFAULTS)
config = configparser.SafeConfigParser()
config.read(file_path)
- for section in defaults:
+ for section in conf:
if not config.has_section(section):
continue
- for key, value in defaults[section].items():
+ for key, value in conf[section].items():
if not config.has_option(section, key):
continue
elif type(value) == bool:
- defaults[section][key] = config.getboolean(section, key)
+ conf[section][key] = config.getboolean(section, key)
elif type(value) == list:
values = config.get(section, key).split(',')
values = [v.strip() for v in values]
- defaults[section][key] = values
+ conf[section][key] = values
else:
- defaults[section][key] = config.get(section, key)
+ conf[section][key] = config.get(section, key)
# TODO: implement basic parsing/sanitization of options comming from
# config file.
- return defaults
+ return conf
diff --git a/server/src/leap/soledad/server/_resource.py b/server/src/leap/soledad/server/_resource.py
new file mode 100644
index 00000000..7a00ad9a
--- /dev/null
+++ b/server/src/leap/soledad/server/_resource.py
@@ -0,0 +1,87 @@
+# -*- coding: utf-8 -*-
+# resource.py
+# Copyright (C) 2016 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+A twisted resource that serves the Soledad Server.
+"""
+from twisted.web.resource import Resource
+
+from ._blobs import blobs_resource
+from ._server_info import ServerInfo
+from ._wsgi import get_sync_resource
+
+
+__all__ = ['SoledadResource', 'SoledadAnonResource']
+
+
+class _Robots(Resource):
+ def render_GET(self, request):
+ return (
+ 'User-agent: *\n'
+ 'Disallow: /\n'
+ '# you are not a robot, are you???')
+
+
+class SoledadAnonResource(Resource):
+
+ """
+ The parts of Soledad Server that unauthenticated users can see.
+ This is nice because this means that a non-authenticated user will get 404
+ for anything that is not in this minimal resource tree.
+ """
+
+ def __init__(self, enable_blobs=False):
+ Resource.__init__(self)
+ server_info = ServerInfo(enable_blobs)
+ self.putChild('', server_info)
+ self.putChild('robots.txt', _Robots())
+
+
+class SoledadResource(Resource):
+ """
+ This is a dummy twisted resource, used only to allow different entry points
+ for the Soledad Server.
+ """
+
+ def __init__(self, enable_blobs=False, sync_pool=None):
+ """
+ Initialize the Soledad resource.
+
+ :param enable_blobs: Whether the blobs feature should be enabled.
+ :type enable_blobs: bool
+
+ :param sync_pool: A pool to pass to the WSGI sync resource.
+ :type sync_pool: twisted.python.threadpool.ThreadPool
+ """
+ Resource.__init__(self)
+
+ # requests to / return server information
+ server_info = ServerInfo(enable_blobs)
+ self.putChild('', server_info)
+
+ # requests to /blobs will serve blobs if enabled
+ if enable_blobs:
+ self.putChild('blobs', blobs_resource)
+
+ # other requests are routed to legacy sync resource
+ self._sync_resource = get_sync_resource(sync_pool)
+
+ def getChild(self, path, request):
+ """
+ Route requests to legacy WSGI sync resource dynamically.
+ """
+ request.postpath.insert(0, request.prepath.pop())
+ return self._sync_resource
diff --git a/server/src/leap/soledad/server/_server_info.py b/server/src/leap/soledad/server/_server_info.py
new file mode 100644
index 00000000..50659338
--- /dev/null
+++ b/server/src/leap/soledad/server/_server_info.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+# _server_info.py
+# Copyright (C) 2017 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Resource that announces information about the server.
+"""
+import json
+
+from twisted.web.resource import Resource
+
+from leap.soledad.server import __version__
+
+
+__all__ = ['ServerInfo']
+
+
+class ServerInfo(Resource):
+ """
+ Return information about the server.
+ """
+
+ isLeaf = True
+
+ def __init__(self, blobs_enabled):
+ self._info = {
+ "blobs": blobs_enabled,
+ "version": __version__
+ }
+
+ def render_GET(self, request):
+ return json.dumps(self._info)
diff --git a/server/src/leap/soledad/server/application.py b/server/src/leap/soledad/server/_wsgi.py
index 17296425..f6ff6b26 100644
--- a/server/src/leap/soledad/server/application.py
+++ b/server/src/leap/soledad/server/_wsgi.py
@@ -15,40 +15,31 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
-A WSGI application to serve as the root resource of the webserver.
-
-Use it like this:
-
- twistd web --wsgi=leap.soledad.server.application.wsgi_application
+A WSGI application that serves Soledad synchronization.
"""
from twisted.internet import reactor
+from twisted.web.wsgi import WSGIResource
from leap.soledad.server import SoledadApp
-from leap.soledad.server.auth import SoledadTokenAuthMiddleware
from leap.soledad.server.gzip_middleware import GzipMiddleware
-from leap.soledad.server.config import load_configuration
from leap.soledad.common.backend import SoledadBackend
from leap.soledad.common.couch.state import CouchServerState
from leap.soledad.common.log import getLogger
+from twisted.logger import Logger
+log = Logger()
-__all__ = ['wsgi_application']
-
-
-def _load_config():
- conf = load_configuration('/etc/soledad/soledad-server.conf')
- return conf['soledad-server']
+__all__ = ['init_couch_state', 'get_sync_resource']
-def _get_couch_state():
- conf = _load_config()
+def _get_couch_state(conf):
state = CouchServerState(conf['couch_url'], create_cmd=conf['create_cmd'],
check_schema_versions=True)
SoledadBackend.BATCH_SUPPORT = conf.get('batching', False)
return state
-_app = SoledadTokenAuthMiddleware(SoledadApp(None)) # delay state init
+_app = SoledadApp(None) # delay state init
wsgi_application = GzipMiddleware(_app)
@@ -61,13 +52,14 @@ wsgi_application = GzipMiddleware(_app)
# work. Because of that, we delay couch state initialization until the reactor
# is running.
-def _init_couch_state(_app):
+def init_couch_state(conf):
try:
- _app.state = _get_couch_state()
+ _app.state = _get_couch_state(conf)
except Exception as e:
logger = getLogger()
logger.error(str(e))
reactor.stop()
-reactor.callWhenRunning(_init_couch_state, _app)
+def get_sync_resource(pool):
+ return WSGIResource(reactor, pool, wsgi_application)
diff --git a/server/src/leap/soledad/server/auth.py b/server/src/leap/soledad/server/auth.py
index b0764569..b5744fe9 100644
--- a/server/src/leap/soledad/server/auth.py
+++ b/server/src/leap/soledad/server/auth.py
@@ -15,383 +15,156 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
-Authentication facilities for Soledad Server.
+Twisted http token auth.
"""
-import httplib
-import json
-
-from abc import ABCMeta, abstractmethod
-from routes.mapper import Mapper
-
-from leap.soledad.common.log import getLogger
-from leap.soledad.common.l2db import DBNAME_CONSTRAINTS, errors as u1db_errors
-from leap.soledad.common import SHARED_DB_NAME
-from leap.soledad.common import USER_DB_PREFIX
-
-
-logger = getLogger(__name__)
-
-
-class URLToAuthorization(object):
- """
- Verify if actions can be performed by a user.
- """
-
- HTTP_METHOD_GET = 'GET'
- HTTP_METHOD_PUT = 'PUT'
- HTTP_METHOD_DELETE = 'DELETE'
- HTTP_METHOD_POST = 'POST'
-
- def __init__(self, uuid):
- """
- Initialize the mapper.
-
- The C{uuid} is used to create the rules that will either allow or
- disallow the user to perform specific actions.
-
- @param uuid: The user uuid.
- @type uuid: str
- @param user_db_prefix: The string prefix of users' databases.
- @type user_db_prefix: str
- """
- self._map = Mapper(controller_scan=None)
- self._user_db_name = "%s%s" % (USER_DB_PREFIX, uuid)
- self._uuid = uuid
- self._register_auth_info()
-
- def is_authorized(self, environ):
- """
- Return whether an HTTP request that produced the CGI C{environ}
- corresponds to an authorized action.
-
- @param environ: Dictionary containing CGI variables.
- @type environ: dict
-
- @return: Whether the action is authorized or not.
- @rtype: bool
- """
- return self._map.match(environ=environ) is not None
-
- def _register(self, pattern, http_methods):
- """
- Register a C{pattern} in the mapper as valid for C{http_methods}.
-
- @param pattern: The URL pattern that corresponds to the user action.
- @type pattern: str
- @param http_methods: A list of authorized HTTP methods.
- @type http_methods: list of str
- """
- self._map.connect(
- None, pattern, http_methods=http_methods,
- conditions=dict(method=http_methods),
- requirements={'dbname': DBNAME_CONSTRAINTS})
-
- def _register_auth_info(self):
- """
- Register the authorization info in the mapper using C{SHARED_DB_NAME}
- as the user's database name.
-
- This method sets up the following authorization rules:
-
- URL path | Authorized actions
- --------------------------------------------------
- / | GET
- /shared-db | GET
- /shared-db/docs | -
- /shared-db/doc/{any_id} | GET, PUT, DELETE
- /shared-db/sync-from/{source} | -
- /user-db | GET, PUT, DELETE
- /user-db/docs | -
- /user-db/doc/{id} | -
- /user-db/sync-from/{source} | GET, PUT, POST
- """
- # auth info for global resource
- self._register('/', [self.HTTP_METHOD_GET])
- # auth info for shared-db database resource
- self._register(
- '/%s' % SHARED_DB_NAME,
- [self.HTTP_METHOD_GET])
- # auth info for shared-db doc resource
- self._register(
- '/%s/doc/{id:.*}' % SHARED_DB_NAME,
- [self.HTTP_METHOD_GET, self.HTTP_METHOD_PUT,
- self.HTTP_METHOD_DELETE])
- # auth info for user-db database resource
- self._register(
- '/%s' % self._user_db_name,
- [self.HTTP_METHOD_GET, self.HTTP_METHOD_PUT,
- self.HTTP_METHOD_DELETE])
- # auth info for user-db sync resource
- self._register(
- '/%s/sync-from/{source_replica_uid}' % self._user_db_name,
- [self.HTTP_METHOD_GET, self.HTTP_METHOD_PUT,
- self.HTTP_METHOD_POST])
- # generate the regular expressions
- self._map.create_regs()
-
-
-class SoledadAuthMiddleware(object):
- """
- Soledad Authentication WSGI middleware.
-
- This class must be extended to implement specific authentication methods
- (see SoledadTokenAuthMiddleware below).
-
- It expects an HTTP_AUTHORIZATION header containing the concatenation of
- the following strings:
-
- 1. The authentication scheme. It will be verified by the
- _verify_authentication_scheme() method.
-
- 2. A space character.
-
- 3. The base64 encoded string of the concatenation of the user uuid with
- the authentication data, separated by a collon, like this:
-
- base64("<uuid>:<auth_data>")
-
- After authentication check, the class performs an authorization check to
- verify whether the user is authorized to perform the requested action.
-
- On client-side, 2 methods must be implemented so the soledad client knows
- how to send authentication headers to server:
-
- * set_<method>_credentials: store authentication credentials in the
- class.
-
- * _sign_request: format and include custom authentication data in
- the HTTP_AUTHORIZATION header.
-
- See leap.soledad.auth and u1db.remote.http_client.HTTPClient to understand
- how to do it.
- """
-
- __metaclass__ = ABCMeta
-
- HTTP_AUTH_KEY = "HTTP_AUTHORIZATION"
- PATH_INFO_KEY = "PATH_INFO"
-
- CONTENT_TYPE_JSON = ('content-type', 'application/json')
-
- def __init__(self, app):
- """
- Initialize the Soledad Authentication Middleware.
-
- @param app: The application to run on successfull authentication.
- @type app: u1db.remote.http_app.HTTPApp
- @param prefix: Auth app path prefix.
- @type prefix: str
- """
- self._app = app
-
- def _error(self, start_response, status, description, message=None):
- """
- Send a JSON serialized error to WSGI client.
-
- @param start_response: Callable of the form start_response(status,
- response_headers, exc_info=None).
- @type start_response: callable
- @param status: Status string of the form "999 Message here"
- @type status: str
- @param response_headers: A list of (header_name, header_value) tuples
- describing the HTTP response header.
- @type response_headers: list
- @param description: The error description.
- @type description: str
- @param message: The error message.
- @type message: str
-
- @return: List with JSON serialized error message.
- @rtype list
- """
- start_response("%d %s" % (status, httplib.responses[status]),
- [self.CONTENT_TYPE_JSON])
- err = {"error": description}
- if message:
- err['message'] = message
- return [json.dumps(err)]
-
- def _unauthorized_error(self, start_response, message):
- """
- Send a unauth error.
-
- @param message: The error message.
- @type message: str
- @param start_response: Callable of the form start_response(status,
- response_headers, exc_info=None).
- @type start_response: callable
-
- @return: List with JSON serialized error message.
- @rtype list
- """
- return self._error(
- start_response,
- 401,
- "unauthorized",
- message)
-
- def __call__(self, environ, start_response):
- """
- Handle a WSGI call to the authentication application.
-
- @param environ: Dictionary containing CGI variables.
- @type environ: dict
- @param start_response: Callable of the form start_response(status,
- response_headers, exc_info=None).
- @type start_response: callable
-
- @return: Target application results if authentication succeeds, an
- error message otherwise.
- @rtype: list
- """
- # check for authentication header
- auth = environ.get(self.HTTP_AUTH_KEY)
- if not auth:
- return self._unauthorized_error(
- start_response, "Missing authentication header.")
-
- # get authentication data
- scheme, encoded = auth.split(None, 1)
- uuid, auth_data = encoded.decode('base64').split(':', 1)
- if not self._verify_authentication_scheme(scheme):
- return self._unauthorized_error(
- start_response, "Wrong authentication scheme")
-
- # verify if user is athenticated
- try:
- if not self._verify_authentication_data(uuid, auth_data):
- return self._unauthorized_error(
- start_response,
- self._get_auth_error_string())
- except u1db_errors.Unauthorized as e:
- return self._error(
- start_response,
- 401,
- e.wire_description)
-
- # verify if user is authorized to perform action
- if not self._verify_authorization(environ, uuid):
- return self._unauthorized_error(
- start_response,
- "Unauthorized action.")
-
- # move on to the real Soledad app
- del environ[self.HTTP_AUTH_KEY]
- return self._app(environ, start_response)
-
- @abstractmethod
- def _verify_authentication_scheme(self, scheme):
- """
- Verify if authentication scheme is valid.
-
- @param scheme: Auth scheme extracted from the HTTP_AUTHORIZATION
- header.
- @type scheme: str
-
- @return: Whether the authentitcation scheme is valid.
- """
- return None
-
- @abstractmethod
- def _verify_authentication_data(self, uuid, auth_data):
- """
- Verify valid authenticatiion for this request.
-
- @param uuid: The user's uuid.
- @type uuid: str
- @param auth_data: Authentication data.
- @type auth_data: str
-
- @return: Whether the token is valid for authenticating the request.
- @rtype: bool
-
- @raise Unauthorized: Raised when C{auth_data} is not enough to
- authenticate C{uuid}.
- """
- return None
-
- def _verify_authorization(self, environ, uuid):
- """
- Verify if the user is authorized to perform the requested action over
- the requested database.
-
- @param environ: Dictionary containing CGI variables.
- @type environ: dict
- @param uuid: The user's uuid.
- @type uuid: str
-
- @return: Whether the user is authorize to perform the requested action
- over the requested db.
- @rtype: bool
- """
- return URLToAuthorization(uuid).is_authorized(environ)
-
- @abstractmethod
- def _get_auth_error_string(self):
- """
- Return an error string specific for each kind of authentication method.
-
- @return: The error string.
- """
- return None
-
-
-class SoledadTokenAuthMiddleware(SoledadAuthMiddleware):
- """
- Token based authentication.
- """
-
- TOKEN_AUTH_ERROR_STRING = "Incorrect address or token."
-
- def _get_state(self):
- return self._app.state
-
- def _set_state(self, state):
- self._app.state = state
-
- state = property(_get_state, _set_state)
-
- def _verify_authentication_scheme(self, scheme):
- """
- Verify if authentication scheme is valid.
-
- @param scheme: Auth scheme extracted from the HTTP_AUTHORIZATION
- header.
- @type scheme: str
-
- @return: Whether the authentitcation scheme is valid.
- """
- if scheme.lower() != 'token':
- return False
- return True
-
- def _verify_authentication_data(self, uuid, auth_data):
- """
- Extract token from C{auth_data} and proceed with verification of
- C{uuid} authentication.
-
- @param uuid: The user UID.
- @type uuid: str
- @param auth_data: Authentication data (i.e. the token).
- @type auth_data: str
-
- @return: Whether the token is valid for authenticating the request.
- @rtype: bool
-
- @raise Unauthorized: Raised when C{auth_data} is not enough to
- authenticate C{uuid}.
- """
- token = auth_data # we expect a cleartext token at this point
+import binascii
+import time
+
+from hashlib import sha512
+from zope.interface import implementer
+
+from twisted.cred import error
+from twisted.cred.checkers import ICredentialsChecker
+from twisted.cred.credentials import IUsernamePassword
+from twisted.cred.credentials import IAnonymous
+from twisted.cred.credentials import Anonymous
+from twisted.cred.credentials import UsernamePassword
+from twisted.cred.portal import IRealm
+from twisted.cred.portal import Portal
+from twisted.internet import defer
+from twisted.logger import Logger
+from twisted.web.iweb import ICredentialFactory
+from twisted.web.resource import IResource
+
+from leap.soledad.common.couch import couch_server
+
+from ._resource import SoledadResource, SoledadAnonResource
+from ._config import get_config
+
+
+log = Logger()
+
+
+@implementer(IRealm)
+class SoledadRealm(object):
+
+ def __init__(self, sync_pool, conf=None):
+ assert sync_pool is not None
+ if conf is None:
+ conf = get_config()
+ blobs = conf['blobs']
+ self.anon_resource = SoledadAnonResource(
+ enable_blobs=blobs)
+ self.auth_resource = SoledadResource(
+ enable_blobs=blobs,
+ sync_pool=sync_pool)
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+
+ # Anonymous access
+ if IAnonymous.providedBy(avatarId):
+ return (IResource, self.anon_resource,
+ lambda: None)
+
+ # Authenticated access
+ else:
+ if IResource in interfaces:
+ return (IResource, self.auth_resource,
+ lambda: None)
+ raise NotImplementedError()
+
+
+@implementer(ICredentialsChecker)
+class TokenChecker(object):
+
+ credentialInterfaces = [IUsernamePassword, IAnonymous]
+
+ TOKENS_DB_PREFIX = "tokens_"
+ TOKENS_DB_EXPIRE = 30 * 24 * 3600 # 30 days in seconds
+ TOKENS_TYPE_KEY = "type"
+ TOKENS_TYPE_DEF = "Token"
+ TOKENS_USER_ID_KEY = "user_id"
+
+ def __init__(self):
+ self._couch_url = get_config().get('couch_url')
+
+ def _get_server(self):
+ return couch_server(self._couch_url)
+
+ def _tokens_dbname(self):
+ # the tokens db rotates every 30 days, and the current db name is
+ # "tokens_NNN", where NNN is the number of seconds since epoch
+ # divide dby the rotate period in seconds. When rotating, old and
+ # new tokens db coexist during a certain window of time and valid
+ # tokens are replicated from the old db to the new one. See:
+ # https://leap.se/code/issues/6785
+ dbname = self.TOKENS_DB_PREFIX + \
+ str(int(time.time() / self.TOKENS_DB_EXPIRE))
+ return dbname
+
+ def _tokens_db(self):
+ dbname = self._tokens_dbname()
+
+ # TODO -- leaking abstraction here: this module shouldn't need
+ # to known anything about the context manager. hide that in the couch
+ # module
+ with self._get_server() as server:
+ db = server[dbname]
+ return db
+
+ def requestAvatarId(self, credentials):
+ if IAnonymous.providedBy(credentials):
+ return defer.succeed(Anonymous())
+
+ uuid = credentials.username
+ token = credentials.password
+
+ # lookup key is a hash of the token to prevent timing attacks.
+ # TODO cache the tokens already!
+
+ db = self._tokens_db()
+ token = db.get(sha512(token).hexdigest())
+ if token is None:
+ return defer.fail(error.UnauthorizedLogin())
+
+ # TODO -- use cryptography constant time builtin comparison.
+ # we compare uuid hashes to avoid possible timing attacks that
+ # might exploit python's builtin comparison operator behaviour,
+ # which fails immediatelly when non-matching bytes are found.
+ couch_uuid_hash = sha512(token[self.TOKENS_USER_ID_KEY]).digest()
+ req_uuid_hash = sha512(uuid).digest()
+ if token[self.TOKENS_TYPE_KEY] != self.TOKENS_TYPE_DEF \
+ or couch_uuid_hash != req_uuid_hash:
+ return defer.fail(error.UnauthorizedLogin())
+
+ return defer.succeed(uuid)
+
+
+@implementer(ICredentialFactory)
+class TokenCredentialFactory(object):
+
+ scheme = 'token'
+
+ def getChallenge(self, request):
+ return {}
+
+ def decode(self, response, request):
try:
- return self.state.verify_token(uuid, token)
- except Exception as e:
- logger.error(e)
- return False
-
- def _get_auth_error_string(self):
- """
- Get the error string for token auth.
-
- @return: The error string.
- """
- return self.TOKEN_AUTH_ERROR_STRING
+ creds = binascii.a2b_base64(response + b'===')
+ except binascii.Error:
+ raise error.LoginFailed('Invalid credentials')
+
+ creds = creds.split(b':', 1)
+ if len(creds) == 2:
+ return UsernamePassword(*creds)
+ else:
+ raise error.LoginFailed('Invalid credentials')
+
+
+def portalFactory(sync_pool):
+ realm = SoledadRealm(sync_pool=sync_pool)
+ checker = TokenChecker()
+ return Portal(realm, [checker])
+
+
+credentialFactory = TokenCredentialFactory()
diff --git a/server/src/leap/soledad/server/entrypoint.py b/server/src/leap/soledad/server/entrypoint.py
new file mode 100644
index 00000000..c06b740e
--- /dev/null
+++ b/server/src/leap/soledad/server/entrypoint.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# entrypoint.py
+# Copyright (C) 2016 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+The entrypoint for Soledad server.
+
+This is the entrypoint for the application that is loaded from the initscript
+or the systemd script.
+"""
+
+from twisted.internet import reactor
+from twisted.python import threadpool
+
+from .auth import portalFactory
+from .session import SoledadSession
+from ._config import get_config
+from ._wsgi import init_couch_state
+
+
+# load configuration from file
+conf = get_config()
+
+
+class SoledadEntrypoint(SoledadSession):
+
+ def __init__(self):
+ pool = threadpool.ThreadPool(name='wsgi')
+ reactor.callWhenRunning(pool.start)
+ reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
+ portal = portalFactory(pool)
+ SoledadSession.__init__(self, portal)
+
+
+# see the comments in application.py recarding why couch state has to be
+# initialized when the reactor is running
+
+reactor.callWhenRunning(init_couch_state, conf)
diff --git a/server/src/leap/soledad/server/resource.py b/server/src/leap/soledad/server/resource.py
deleted file mode 100644
index dbb91b0a..00000000
--- a/server/src/leap/soledad/server/resource.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# -*- coding: utf-8 -*-
-# resource.py
-# Copyright (C) 2016 LEAP
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see <http://www.gnu.org/licenses/>.
-"""
-A twisted resource that serves the Soledad Server.
-"""
-
-from twisted.web.resource import Resource
-from twisted.web.wsgi import WSGIResource
-from twisted.internet import reactor
-from twisted.python import threadpool
-
-from leap.soledad.server.application import wsgi_application
-
-
-__all__ = ['SoledadResource']
-
-
-# setup a wsgi resource with its own threadpool
-pool = threadpool.ThreadPool()
-reactor.callWhenRunning(pool.start)
-reactor.addSystemEventTrigger('after', 'shutdown', pool.stop)
-wsgi_resource = WSGIResource(reactor, pool, wsgi_application)
-
-
-class SoledadResource(Resource):
- """
- This is a dummy twisted resource, used only to allow different entry points
- for the Soledad Server.
- """
-
- def __init__(self):
- self.children = {'': wsgi_resource}
-
- def getChild(self, path, request):
- # for now, just "rewind" the path and serve the wsgi resource for all
- # requests. In the future, we might look into the request path to
- # decide which child resources should serve each request.
- request.postpath.insert(0, request.prepath.pop())
- return self.children['']
diff --git a/server/src/leap/soledad/server/session.py b/server/src/leap/soledad/server/session.py
new file mode 100644
index 00000000..1c1b5345
--- /dev/null
+++ b/server/src/leap/soledad/server/session.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# session.py
+# Copyright (C) 2017 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+Twisted resource containing an authenticated Soledad session.
+"""
+from zope.interface import implementer
+
+from twisted.cred.credentials import Anonymous
+from twisted.cred import error
+from twisted.python import log
+from twisted.web import util
+from twisted.web._auth import wrapper
+from twisted.web.guard import HTTPAuthSessionWrapper
+from twisted.web.resource import ErrorPage
+from twisted.web.resource import IResource
+
+from leap.soledad.server.auth import credentialFactory
+from leap.soledad.server.url_mapper import URLMapper
+
+
+@implementer(IResource)
+class UnauthorizedResource(wrapper.UnauthorizedResource):
+ isLeaf = True
+
+ def __init__(self):
+ pass
+
+ def render(self, request):
+ request.setResponseCode(401)
+ if request.method == b'HEAD':
+ return b''
+ return b'Unauthorized'
+
+ def getChildWithDefault(self, path, request):
+ return self
+
+
+@implementer(IResource)
+class SoledadSession(HTTPAuthSessionWrapper):
+
+ def __init__(self, portal):
+ self._mapper = URLMapper()
+ self._portal = portal
+ self._credentialFactory = credentialFactory
+ # expected by the contract of the parent class
+ self._credentialFactories = [credentialFactory]
+
+ def _matchPath(self, request):
+ match = self._mapper.match(request.path, request.method)
+ return match
+
+ def _parseHeader(self, header):
+ elements = header.split(b' ')
+ scheme = elements[0].lower()
+ if scheme == self._credentialFactory.scheme:
+ return (b' '.join(elements[1:]))
+ return None
+
+ def _authorizedResource(self, request):
+ # check whether the path of the request exists in the app
+ match = self._matchPath(request)
+ if not match:
+ return UnauthorizedResource()
+
+ # get authorization header or fail
+ header = request.getHeader(b'authorization')
+ if not header:
+ return util.DeferredResource(self._login(Anonymous()))
+
+ # parse the authorization header
+ auth_data = self._parseHeader(header)
+ if not auth_data:
+ return UnauthorizedResource()
+
+ # decode the credentials from the parsed header
+ try:
+ credentials = self._credentialFactory.decode(auth_data, request)
+ except error.LoginFailed:
+ return UnauthorizedResource()
+ except:
+ # If you port this to the newer log facility, be aware that
+ # the tests rely on the error to be logged.
+ log.err(None, "Unexpected failure from credentials factory")
+ return ErrorPage(500, None, None)
+
+ # make sure the uuid given in path corresponds to the one given in
+ # the credentials
+ request_uuid = match.get('uuid')
+ if request_uuid and request_uuid != credentials.username:
+ return ErrorPage(500, None, None)
+
+ # if all checks pass, try to login with credentials
+ return util.DeferredResource(self._login(credentials))
diff --git a/server/src/leap/soledad/server/url_mapper.py b/server/src/leap/soledad/server/url_mapper.py
new file mode 100644
index 00000000..a0edeaca
--- /dev/null
+++ b/server/src/leap/soledad/server/url_mapper.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# url_mapper.py
+# Copyright (C) 2013 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""
+An URL mapper that represents authorized paths.
+"""
+from routes.mapper import Mapper
+
+from leap.soledad.common import SHARED_DB_NAME
+from leap.soledad.common.l2db import DBNAME_CONSTRAINTS
+
+
+class URLMapper(object):
+ """
+ Maps the URLs users can access.
+ """
+
+ def __init__(self):
+ self._map = Mapper(controller_scan=None)
+ self._connect_urls()
+ self._map.create_regs()
+
+ def match(self, path, method):
+ environ = {'PATH_INFO': path, 'REQUEST_METHOD': method}
+ return self._map.match(environ=environ)
+
+ def _connect(self, pattern, http_methods):
+ self._map.connect(
+ None, pattern, http_methods=http_methods,
+ conditions=dict(method=http_methods),
+ requirements={'dbname': DBNAME_CONSTRAINTS})
+
+ def _connect_urls(self):
+ """
+ Register the authorization info in the mapper using C{SHARED_DB_NAME}
+ as the user's database name.
+
+ This method sets up the following authorization rules:
+
+ URL path | Authorized actions
+ --------------------------------------------------
+ / | GET
+ /robots.txt | GET
+ /shared-db | GET
+ /shared-db/docs | -
+ /shared-db/doc/{any_id} | GET, PUT, DELETE
+ /shared-db/sync-from/{source} | -
+ /user-db | -
+ /user-db/docs | -
+ /user-db/doc/{id} | -
+ /user-db/sync-from/{source} | GET, PUT, POST
+ """
+ # auth info for global resource
+ self._connect('/', ['GET'])
+ # robots
+ self._connect('/robots.txt', ['GET'])
+ # auth info for shared-db database resource
+ self._connect('/%s' % SHARED_DB_NAME, ['GET'])
+ # auth info for shared-db doc resource
+ self._connect('/%s/doc/{id:.*}' % SHARED_DB_NAME,
+ ['GET', 'PUT', 'DELETE'])
+ # auth info for user-db sync resource
+ self._connect('/user-{uuid}/sync-from/{source_replica_uid}',
+ ['GET', 'PUT', 'POST'])