diff options
author | drebs <drebs@leap.se> | 2017-03-11 14:38:51 +0100 |
---|---|---|
committer | drebs <drebs@leap.se> | 2017-03-11 14:38:51 +0100 |
commit | 3ec21a7a6b39b4fe8885f3050ab75402e6812a1f (patch) | |
tree | 300a2d8da67bbc7c6484f9ee136a4a18c41c48c6 /server | |
parent | c379a58d84fbf061b8d046057e45089f0e3c65f6 (diff) | |
parent | 3eefcb7d138ef41932a748ae729bfa0b629758d2 (diff) |
Merge tag '0.9.3' into debian/platform-0.9
0.9.3
Conflicts:
server/pkg/soledad-server
server/pkg/soledad-server.service
Diffstat (limited to 'server')
-rwxr-xr-x | server/pkg/create-user-db | 9 | ||||
-rw-r--r-- | server/pkg/soledad-server.service | 2 | ||||
-rw-r--r-- | server/pkg/soledad-sudoers | 2 | ||||
-rw-r--r-- | server/src/leap/soledad/server/__init__.py | 8 | ||||
-rw-r--r-- | server/src/leap/soledad/server/_blobs.py | 44 | ||||
-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.py | 87 | ||||
-rw-r--r-- | server/src/leap/soledad/server/_server_info.py | 44 | ||||
-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.py | 529 | ||||
-rw-r--r-- | server/src/leap/soledad/server/entrypoint.py | 50 | ||||
-rw-r--r-- | server/src/leap/soledad/server/resource.py | 53 | ||||
-rw-r--r-- | server/src/leap/soledad/server/session.py | 107 | ||||
-rw-r--r-- | server/src/leap/soledad/server/url_mapper.py | 77 |
14 files changed, 608 insertions, 467 deletions
diff --git a/server/pkg/create-user-db b/server/pkg/create-user-db index 9e2b6b50..5e0ef5e2 100755 --- a/server/pkg/create-user-db +++ b/server/pkg/create-user-db @@ -22,7 +22,7 @@ import argparse from leap.soledad.common.couch import CouchDatabase from leap.soledad.common.couch.state import is_db_name_valid from leap.soledad.common.couch import list_users_dbs -from leap.soledad.server.config import load_configuration +from leap.soledad.server._config import get_config BYPASS_AUTH = os.environ.get('SOLEDAD_BYPASS_AUTH', False) @@ -38,8 +38,9 @@ parser.add_argument('dbname', metavar='user-d34db33f', type=str, help='database name on the format user-{uuid4}') parser.add_argument('--migrate-all', action='store_true', help="recreate all design docs for all existing account") -CONF = load_configuration('/etc/soledad/soledad-server.conf') -NETRC_PATH = CONF['soledad-server']['admin_netrc'] +CONF = get_config() +DBCONF = get_config(section='database-security') +NETRC_PATH = CONF['admin_netrc'] def url_for_db(dbname): @@ -78,7 +79,7 @@ def ensure_database(dbname): print ("Invalid name! %s" % dbname) sys.exit(1) url = url_for_db(dbname) - db_security = CONF['database-security'] + db_security = DBCONF db = CouchDatabase.open_database(url=url, create=True, replica_uid=None, database_security=db_security) diff --git a/server/pkg/soledad-server.service b/server/pkg/soledad-server.service index ccd03b97..30c4bf88 100644 --- a/server/pkg/soledad-server.service +++ b/server/pkg/soledad-server.service @@ -3,7 +3,7 @@ Description=Soledad Server [Service] Environment=PATH=/sbin:/bin:/usr/sbin:/usr/bin -Environment=CLASS=leap.soledad.server.resource.SoledadResource +Environment=CLASS=leap.soledad.server.entrypoint.SoledadEntrypoint Environment=HTTPS_PORT=2424 Environment=CERT_PATH=/etc/soledad/soledad-server.pem Environment=PRIVKEY_PATH=/etc/soledad/soledad-server.key diff --git a/server/pkg/soledad-sudoers b/server/pkg/soledad-sudoers new file mode 100644 index 00000000..642497f8 --- /dev/null +++ b/server/pkg/soledad-sudoers @@ -0,0 +1,2 @@ +Cmnd_Alias SOLEDAD_CREATE_DB = /usr/bin/create-user-db +soledad ALL=(soledad-admin) NOPASSWD: SOLEDAD_CREATE_DB 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']) |