Merge lp:~jimmy-sigint/mailman/restapi_auth into lp:mailman

Proposed by Jimmy Bergman
Status: Merged
Approved by: Barry Warsaw
Approved revision: 6956
Merged at revision: 6957
Proposed branch: lp:~jimmy-sigint/mailman/restapi_auth
Merge into: lp:mailman
Diff against target: 178 lines (+52/-10)
5 files modified
src/mailman/config/schema.cfg (+5/-0)
src/mailman/rest/docs/basic.txt (+22/-3)
src/mailman/rest/root.py (+14/-2)
src/mailman/testing/layers.py (+6/-2)
src/mailman/tests/test_documentation.py (+5/-3)
To merge this branch: bzr merge lp:~jimmy-sigint/mailman/restapi_auth
Reviewer Review Type Date Requested Status
Barry Warsaw Approve
Review via email: mp+36833@code.launchpad.net

Description of the change

In my opinion the REST API needs to be authenticated for the following reasons:

1. Even though it is by default exposed only on localhost, this means that all local users can administer mailing-lists instead of only some specific user like root.

2. It makes sense to use the REST API for integrating with external systems. These external systems will often be on other servers, causing the need for exposing the REST API on different interfaces than the loopback interface. For this authentication is a requirement.

The change in my branch solves this by adding a single shared username/password in the webservice section of the config using the parameters admin_user and admin_pass. The API is then changed to require HTTP basic auth using these credentials.

To post a comment you must log in.
Revision history for this message
Barry Warsaw (barry) wrote :

Thanks for the branch Jimmy. I think you make some good points about enabling some form of authentication in the REST server. I've looked at:

http://ish.io/embedded/restish/guard.html

which provides examples of hooking up repoze.who. Did you think about that and if so, why did you choose not to use it (adding a dependency and the extra code complexity is a valid answer :).

Have you thought about using something like OAuth? Are you concerned at all about cleartext passwords?

Finally, while your patch looks basically decent (I'd quibble with some whitespace, but that's unimportant), please consider adding a test and/or some documentation (the latter perhaps as a doctest). And are you willing and able to assign your copyright to the FSF?

Revision history for this message
Jimmy Bergman (jimmy-sigint) wrote :

Hi

Well, adding a dependency and the extra code complexity was mainly the reason - yes.

I think the use case for repoze.who is more "I need good way to use an existing authentication store in an WSGI application".

In the mailman REST server case I didn't think that there existed an existing user store to plug into (at least with the same meaning that I was aiming for, i.e. global access to the installation like in the current REST server).

So while I could of course use repoze.who, I don't think it has that much merit for this simple case (one credential for the complete app).

OAuth for this feels overkill as well to me.

If the goal was more granular auth in the REST server (perhaps ability to use authenticate as list owner/subscriber/site admin and become authorized only for the related things), then I would have gone for repoze.who and perhaps OAuth.

But I figured that your choice to not have any authentication at all reflected a wish to KISS, and that wish is something which I very much agree with; hence the single user/pass shared secret.

Regarding clear text passwords, sure - but I think that a simple solution for this is to use SSL for the transport - which I think is supported with the use_https property.

If you're ok with the code (and by all means you can suggest indentation-changes, I like real tabs myself - but tried to use the project style, perhaps unsuccessfully) then I'll go ahead and commit test/doc to the branch.

Regarding the copyright, sure - this is no problem, just point me in the right direction.

Revision history for this message
Barry Warsaw (barry) wrote :

I agree with your rationale, and I do think that basic auth is better than nothing. ;) I haven't tried it, but your code looks essentially correct. Please do extend your branch with tests and docs. Contact me at barry at list dot org and I'll get the FSF assignment process started.

Thanks for you contribution to Mailman!

6956. By root <root@s97>

Add doctest for connecting using invalid credentials and some documentation regarding the basic auth for the REST server.

Revision history for this message
Barry Warsaw (barry) wrote :

Thanks for the updates Jimmy, this looks pretty good and I will merge it into trunk, with some modifications. Mostly, there are a few style issues, but nothing major. Things to take note:

* import sorting order as per STYLEGUIDE.txt
* preferred use of single quotes rather than double quotes
* vertical whitespace as per STYLEGUIDE.txt
* lines too long
* in restish errors, I prefer to use b'foo' instead of str('foo') to indicate error messages
* i changed some of the logic in root.py so the key access from request.environ only happens once
* you don't need to use ...None, None... in the test for bad auth; just use keyword arguments
* in dump_json(), instead of defaulting the keywords to strings, use None and grab the defaults from the config
* new style string interpolation instead of %s's
* add new parameters to docstring for call_http() and dump_json()
* b64encode() instead of encodestring()

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/mailman/config/schema.cfg'
--- src/mailman/config/schema.cfg 2010-03-28 22:20:10 +0000
+++ src/mailman/config/schema.cfg 2010-09-29 08:41:51 +0000
@@ -298,6 +298,11 @@
298# The API version number for the current API.298# The API version number for the current API.
299api_version: 3.0299api_version: 3.0
300300
301# The administrative username.
302admin_user: restadmin
303
304# The administrative password.
305admin_pass: restpass
301306
302[language.master]307[language.master]
303# Template for language definitions. The section name must be [language.xx]308# Template for language definitions. The section name must be [language.xx]
304309
=== modified file 'src/mailman/rest/docs/basic.txt'
--- src/mailman/rest/docs/basic.txt 2010-09-15 15:37:54 +0000
+++ src/mailman/rest/docs/basic.txt 2010-09-29 08:41:51 +0000
@@ -2,12 +2,20 @@
2REST server2REST server
3===========3===========
44
5Mailman exposes a REST_ HTTP server for administrative control.5Mailman exposes a REST HTTP server for administrative control.
66
7The server listens for connections on a configurable host name and port.7The server listens for connections on a configurable host name and port.
8
9It is always protected by HTTP basic authentication using a single global
10username and password. The credentials are set in the webservice section
11of the config using the admin_user and admin_pass properties.
12
8Because the REST server has full administrative access, it should always be13Because the REST server has full administrative access, it should always be
9run only on localhost, unless you really know what you're doing. The Mailman14run only on localhost, unless you really know what you're doing. In addition
10major and minor version numbers are in the URL.15you should set the username and password to secure values and distribute
16them to any REST clients with reasonable precautions.
17
18The Mailman major and minor version numbers are in the URL.
1119
12System information can be retrieved from the server. By default JSON is20System information can be retrieved from the server. By default JSON is
13returned.21returned.
@@ -30,5 +38,16 @@
30 ...38 ...
31 HTTPError: HTTP Error 404: 404 Not Found39 HTTPError: HTTP Error 404: 404 Not Found
3240
41Invalid credentials
42===================
43
44When you try to access the REST server using invalid credentials you will get
45an appropriate HTTP 401 Unauthorized error.
46
47 >>> dump_json('http://localhost:8001/3.0/system', None, None, 'baduser', 'badpass')
48 Traceback (most recent call last):
49 ...
50 HTTPError: HTTP Error 401: 401 Unauthorized
51 ...
3352
34.. _REST: http://en.wikipedia.org/wiki/REST53.. _REST: http://en.wikipedia.org/wiki/REST
3554
=== modified file 'src/mailman/rest/root.py'
--- src/mailman/rest/root.py 2010-02-26 01:38:25 +0000
+++ src/mailman/rest/root.py 2010-09-29 08:41:51 +0000
@@ -25,7 +25,8 @@
25 ]25 ]
2626
2727
28from restish import http, resource28from restish import http, resource, guard
29from base64 import b64decode
2930
30from mailman.config import config31from mailman.config import config
31from mailman.core.system import system32from mailman.core.system import system
@@ -36,6 +37,16 @@
3637
3738
3839
3940
41def webservice_auth_checker(request, obj):
42 if "HTTP_AUTHORIZATION" in request.environ and request.environ["HTTP_AUTHORIZATION"].startswith("Basic "):
43 credentials = b64decode(request.environ["HTTP_AUTHORIZATION"][6:])
44 username, password = credentials.split(":", 1)
45
46 if username != config.webservice.admin_user or password != config.webservice.admin_pass:
47 raise guard.GuardError(str("User is not authorized for the REST api."))
48 else:
49 raise guard.GuardError(str("The REST api requires authentication."))
50
40class Root(resource.Resource):51class Root(resource.Resource):
41 """The RESTful root resource.52 """The RESTful root resource.
4253
@@ -44,11 +55,12 @@
44 and we start at 3.0 to match the Mailman version number. That may not55 and we start at 3.0 to match the Mailman version number. That may not
45 always be the case though.56 always be the case though.
46 """57 """
58
47 @resource.child(config.webservice.api_version)59 @resource.child(config.webservice.api_version)
60 @guard.guard(webservice_auth_checker)
48 def api_version(self, request, segments):61 def api_version(self, request, segments):
49 return TopLevel()62 return TopLevel()
5063
51
52class TopLevel(resource.Resource):64class TopLevel(resource.Resource):
53 """Top level collections and entries."""65 """Top level collections and entries."""
5466
5567
=== modified file 'src/mailman/testing/layers.py'
--- src/mailman/testing/layers.py 2010-08-30 15:03:00 +0000
+++ src/mailman/testing/layers.py 2010-09-29 08:41:51 +0000
@@ -37,7 +37,8 @@
3737
38from pkg_resources import resource_string38from pkg_resources import resource_string
39from textwrap import dedent39from textwrap import dedent
40from urllib2 import urlopen, URLError40from urllib2 import urlopen, URLError, Request
41from base64 import encodestring
41from zope.component import getUtility42from zope.component import getUtility
4243
43from mailman.config import config44from mailman.config import config
@@ -273,7 +274,10 @@
273 until = datetime.datetime.now() + TEST_TIMEOUT274 until = datetime.datetime.now() + TEST_TIMEOUT
274 while datetime.datetime.now() < until:275 while datetime.datetime.now() < until:
275 try:276 try:
276 fp = urlopen('http://localhost:8001/3.0/system')277 request = Request('http://localhost:8001/3.0/system')
278 base64string = encodestring('%s:%s' % ("restadmin", "restpass")).replace('\n', '')
279 request.add_header("Authorization", "Basic %s" % base64string)
280 fp = urlopen(request)
277 except URLError:281 except URLError:
278 pass282 pass
279 else:283 else:
280284
=== modified file 'src/mailman/tests/test_documentation.py'
--- src/mailman/tests/test_documentation.py 2010-08-24 01:37:20 +0000
+++ src/mailman/tests/test_documentation.py 2010-09-29 08:41:51 +0000
@@ -39,6 +39,7 @@
39from httplib2 import Http39from httplib2 import Http
40from urllib import urlencode40from urllib import urlencode
41from urllib2 import HTTPError41from urllib2 import HTTPError
42from base64 import encodestring
4243
43import mailman44import mailman
4445
@@ -109,7 +110,7 @@
109 print '{0:{2}}: {1}'.format(key, msgdata[key], longest)110 print '{0:{2}}: {1}'.format(key, msgdata[key], longest)
110111
111112
112def call_http(url, data=None, method=None):113def call_http(url, data=None, method=None, username="restadmin", password="restpass"):
113 """'Call' a URL with a given HTTP method and return the resulting object.114 """'Call' a URL with a given HTTP method and return the resulting object.
114115
115 The object will have been JSON decoded.116 The object will have been JSON decoded.
@@ -131,6 +132,7 @@
131 else:132 else:
132 method = 'POST'133 method = 'POST'
133 method = method.upper()134 method = method.upper()
135 headers['Authorization'] = 'Basic %s' % encodestring('%s:%s' % (username, password)).replace('\n', '')
134 response, content = Http().request(url, method, data, headers)136 response, content = Http().request(url, method, data, headers)
135 # If we did not get a 2xx status code, make this look like a urllib2137 # If we did not get a 2xx status code, make this look like a urllib2
136 # exception, for backward compatibility with existing doctests.138 # exception, for backward compatibility with existing doctests.
@@ -143,7 +145,7 @@
143 return json.loads(content)145 return json.loads(content)
144146
145147
146def dump_json(url, data=None, method=None):148def dump_json(url, data=None, method=None, username="restadmin", password="restpass"):
147 """Print the JSON dictionary read from a URL.149 """Print the JSON dictionary read from a URL.
148150
149 :param url: The url to open, read, and print.151 :param url: The url to open, read, and print.
@@ -153,7 +155,7 @@
153 :param method: Alternative HTTP method to use.155 :param method: Alternative HTTP method to use.
154 :type method: str156 :type method: str
155 """157 """
156 data = call_http(url, data, method)158 data = call_http(url, data, method, username, password)
157 if data is None:159 if data is None:
158 return160 return
159 for key in sorted(data):161 for key in sorted(data):