From 826261effa9d74b8ecdf1247e9ebba75fa3b2baa Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Tue, 12 Aug 2014 16:42:12 -0400 Subject: First pass at converting to falcon for the REST API layer. Currently, only //system and its subpaths work, but basic auth does work too. Requires a refactoring modification to falcon. --- src/mailman/rest/docs/basic.rst | 70 +++++++++---------------- src/mailman/rest/preferences.py | 10 ++-- src/mailman/rest/root.py | 102 +++++++++++++++++++++++------------- src/mailman/rest/tests/test_root.py | 55 +++++++++++++++++-- src/mailman/rest/wsgiapp.py | 66 +++++++++++++++++++++-- src/mailman/testing/helpers.py | 5 +- 6 files changed, 211 insertions(+), 97 deletions(-) (limited to 'src') diff --git a/src/mailman/rest/docs/basic.rst b/src/mailman/rest/docs/basic.rst index 51b287c90..42e5379ad 100644 --- a/src/mailman/rest/docs/basic.rst +++ b/src/mailman/rest/docs/basic.rst @@ -7,70 +7,48 @@ Mailman exposes a REST HTTP server for administrative control. The server listens for connections on a configurable host name and port. It is always protected by HTTP basic authentication using a single global -username and password. The credentials are set in the webservice section -of the config using the admin_user and admin_pass properties. +user name and password. The credentials are set in the `[webservice]` section +of the configuration using the `admin_user` and `admin_pass` properties. Because the REST server has full administrative access, it should always be run only on localhost, unless you really know what you're doing. In addition -you should set the username and password to secure values and distribute them +you should set the user name and password to secure values and distribute them to any REST clients with reasonable precautions. The Mailman major and minor version numbers are in the URL. -System information can be retrieved from the server. By default JSON is -returned. - >>> dump_json('http://localhost:9001/3.0/system') - http_etag: "..." - mailman_version: GNU Mailman 3.0... (...) - python_version: ... - self_link: http://localhost:9001/3.0/system - - -Non-existent links -================== - -When you try to access a link that doesn't exist, you get the appropriate HTTP -404 Not Found error. - - >>> dump_json('http://localhost:9001/3.0/does-not-exist') - Traceback (most recent call last): - ... - HTTPError: HTTP Error 404: 404 Not Found - - -Invalid credentials -=================== +Credentials +=========== -When you try to access the REST server using invalid credentials you will get -an appropriate HTTP 401 Unauthorized error. -:: +When the `Authorization` header contains the proper creditials, the request +succeeds. >>> from base64 import b64encode - >>> auth = b64encode('baduser:badpass') - - >>> url = 'http://localhost:9001/3.0/system' + >>> from httplib2 import Http + >>> auth = b64encode('{0}:{1}'.format(config.webservice.admin_user, + ... config.webservice.admin_pass)) >>> headers = { ... 'Content-Type': 'application/x-www-form-urlencode', ... 'Authorization': 'Basic ' + auth, ... } - - >>> from httplib2 import Http - >>> response, content = Http().request(url, 'GET', None, headers) - >>> print(content) - 401 Unauthorized - - User is not authorized for the REST API - - -But with the right headers, the request succeeds. - - >>> auth = b64encode('{0}:{1}'.format(config.webservice.admin_user, - ... config.webservice.admin_pass)) - >>> headers['Authorization'] = 'Basic ' + auth + >>> url = 'http://localhost:9001/3.0/system' >>> response, content = Http().request(url, 'GET', None, headers) >>> print(response.status) 200 +Basic information +================= + +System information can be retrieved from the server, in the form of a JSON +encoded response. + + >>> dump_json('http://localhost:9001/3.0/system') + http_etag: "..." + mailman_version: GNU Mailman 3.0... (...) + python_version: ... + self_link: http://localhost:9001/3.0/system + + .. _REST: http://en.wikipedia.org/wiki/REST diff --git a/src/mailman/rest/preferences.py b/src/mailman/rest/preferences.py index bbc6b1769..d501ef865 100644 --- a/src/mailman/rest/preferences.py +++ b/src/mailman/rest/preferences.py @@ -26,6 +26,8 @@ __all__ = [ ] +import falcon + from lazr.config import as_boolean from restish import http, resource @@ -48,15 +50,14 @@ PREFERENCES = ( -class ReadOnlyPreferences(resource.Resource): +class ReadOnlyPreferences: """...//preferences""" def __init__(self, parent, base_url): self._parent = parent self._base_url = base_url - @resource.GET() - def preferences(self, segments): + def on_get(self, request, response): resource = dict() for attr in PREFERENCES: # Handle this one specially. @@ -72,7 +73,8 @@ class ReadOnlyPreferences(resource.Resource): # Add the self link. resource['self_link'] = path_to( '{0}/preferences'.format(self._base_url)) - return http.ok([], etag(resource)) + response.status = falcon.HTTP_200 + response.body = etag(resource) diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index ec0c9c93b..6b13d8246 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -25,8 +25,10 @@ __all__ = [ ] +import falcon + from base64 import b64decode -from restish import guard, http, resource +from restish import http from zope.component import getUtility from mailman.config import config @@ -44,21 +46,25 @@ from mailman.rest.templates import TemplateFinder from mailman.rest.users import AUser, AllUsers +def child(matcher=None): + def decorator(func): + if matcher is None: + func.__matcher__ = func.__name__ + else: + func.__matcher__ = matcher + return func + return decorator + + +class BadRequest: + def on_get(self, request, response): + raise falcon.HTTPError(falcon.HTTP_400, None) + +STOP = [] + + -def webservice_auth_checker(request, obj): - auth = request.environ.get('HTTP_AUTHORIZATION', '') - if auth.startswith('Basic '): - credentials = b64decode(auth[6:]) - username, password = credentials.split(':', 1) - if (username != config.webservice.admin_user or - password != config.webservice.admin_pass): - # Not authorized. - raise guard.GuardError(b'User is not authorized for the REST API') - else: - raise guard.GuardError(b'The REST API requires authentication') - - -class Root(resource.Resource): +class Root: """The RESTful root resource. At the root of the tree are the API version numbers. Everything else @@ -67,33 +73,59 @@ class Root(resource.Resource): always be the case though. """ - @resource.child(config.webservice.api_version) - @guard.guard(webservice_auth_checker) + @child(config.webservice.api_version) def api_version(self, request, segments): - return TopLevel() + # We have to do this here instead of in a @falcon.before() handler + # because those handlers are not compatible with our custom traversal + # logic. Specifically, falcon's before/after handlers will call the + # responder, but the method we're wrapping isn't a responder, it's a + # child traversal method. There's no way to cause the thing that + # calls the before hook to follow through with the child traversal in + # the case where no error is raised. + if request.auth is None: + raise falcon.HTTPUnauthorized( + b'401 Unauthorized', + b'The REST API requires authentication') + if request.auth.startswith('Basic '): + credentials = b64decode(request.auth[6:]) + username, password = credentials.split(':', 1) + if (username != config.webservice.admin_user or + password != config.webservice.admin_pass): + # Not authorized. + raise falcon.HTTPUnauthorized( + b'401 Unauthorized', + b'User is not authorized for the REST API') + return TopLevel(), segments + + +class System: + def on_get(self, request, response): + """//system""" + resource = dict( + mailman_version=system.mailman_version, + python_version=system.python_version, + self_link=path_to('system'), + ) + response.status = falcon.HTTP_200 + response.body = etag(resource) -class TopLevel(resource.Resource): +class TopLevel: """Top level collections and entries.""" - @resource.child() + @child() def system(self, request, segments): """//system""" if len(segments) == 0: - resource = dict( - mailman_version=system.mailman_version, - python_version=system.python_version, - self_link=path_to('system'), - ) + return System(), STOP elif len(segments) > 1: - return http.bad_request() + return BadRequest(), STOP elif segments[0] == 'preferences': - return ReadOnlyPreferences(system_preferences, 'system'), [] + return ReadOnlyPreferences(system_preferences, 'system'), STOP else: - return http.bad_request() - return http.ok([], etag(resource)) + return BadRequest(), STOP - @resource.child() + @child() def addresses(self, request, segments): """//addresses //addresses/ @@ -104,7 +136,7 @@ class TopLevel(resource.Resource): email = segments.pop(0) return AnAddress(email), segments - @resource.child() + @child() def domains(self, request, segments): """//domains //domains/ @@ -115,7 +147,7 @@ class TopLevel(resource.Resource): domain = segments.pop(0) return ADomain(domain), segments - @resource.child() + @child() def lists(self, request, segments): """//lists //lists/ @@ -135,7 +167,7 @@ class TopLevel(resource.Resource): list_identifier = segments.pop(0) return AList(list_identifier), segments - @resource.child() + @child() def members(self, request, segments): """//members""" if len(segments) == 0: @@ -148,7 +180,7 @@ class TopLevel(resource.Resource): else: return AMember(segment), segments - @resource.child() + @child() def users(self, request, segments): """//users""" if len(segments) == 0: @@ -157,7 +189,7 @@ class TopLevel(resource.Resource): user_id = segments.pop(0) return AUser(user_id), segments - @resource.child() + @child() def templates(self, request, segments): """//templates//