Merge lp:~thisfred/u1db/add-basic-auth into lp:u1db

Proposed by Eric Casteleijn
Status: Merged
Approved by: Samuele Pedroni
Approved revision: 398
Merged at revision: 395
Proposed branch: lp:~thisfred/u1db/add-basic-auth
Merge into: lp:u1db
Diff against target: 395 lines (+231/-27)
5 files modified
u1db/remote/basic_auth_middleware.py (+63/-0)
u1db/remote/oauth_middleware.py (+3/-2)
u1db/tests/test_auth_middleware.py (+157/-15)
u1db/tests/test_https.py (+7/-9)
u1db/tests/test_remote_sync_target.py (+1/-1)
To merge this branch: bzr merge lp:~thisfred/u1db/add-basic-auth
Reviewer Review Type Date Requested Status
Samuele Pedroni Approve
Review via email: mp+122323@code.launchpad.net

Commit message

Added basic authentication middleware.

Description of the change

Added basic authentication middleware.

To post a comment you must log in.
lp:~thisfred/u1db/add-basic-auth updated
396. By Eric Casteleijn

prefix configurable

397. By Eric Casteleijn

improved testing, removed copy paste detritus

398. By Eric Casteleijn

more prefix tests

Revision history for this message
Samuele Pedroni (pedronis) wrote :

good

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'u1db/remote/basic_auth_middleware.py'
2--- u1db/remote/basic_auth_middleware.py 1970-01-01 00:00:00 +0000
3+++ u1db/remote/basic_auth_middleware.py 2012-09-05 16:09:18 +0000
4@@ -0,0 +1,63 @@
5+# Copyright 2012 Canonical Ltd.
6+#
7+# This file is part of u1db.
8+#
9+# u1db is free software: you can redistribute it and/or modify
10+# it under the terms of the GNU Lesser General Public License version 3
11+# as published by the Free Software Foundation.
12+#
13+# u1db is distributed in the hope that it will be useful,
14+# but WITHOUT ANY WARRANTY; without even the implied warranty of
15+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+# GNU Lesser General Public License for more details.
17+#
18+# You should have received a copy of the GNU Lesser General Public License
19+# along with u1db. If not, see <http://www.gnu.org/licenses/>.
20+"""U1DB Basic Auth authorisation WSGI middleware."""
21+import httplib
22+try:
23+ import simplejson as json
24+except ImportError:
25+ import json # noqa
26+from wsgiref.util import shift_path_info
27+
28+
29+class BasicAuthMiddleware(object):
30+ """U1DB Basic Auth Authorisation WSGI middleware."""
31+
32+ def __init__(self, app, prefix):
33+ self.app = app
34+ self.prefix = prefix
35+
36+ def _error(self, start_response, status, description, message=None):
37+ start_response("%d %s" % (status, httplib.responses[status]),
38+ [('content-type', 'application/json')])
39+ err = {"error": description}
40+ if message:
41+ err['message'] = message
42+ return [json.dumps(err)]
43+
44+ def __call__(self, environ, start_response):
45+ if self.prefix and not environ['PATH_INFO'].startswith(self.prefix):
46+ return self._error(start_response, 400, "bad request")
47+ auth = environ.get('HTTP_AUTHORIZATION')
48+ if not auth:
49+ return self._error(start_response, 401, "unauthorized",
50+ "Missing Basic Authentication.")
51+ scheme, encoded = auth.split(None, 1)
52+ if scheme.lower() != 'basic':
53+ return self._error(
54+ start_response, 401, "unauthorized",
55+ "Missing Basic Authentication")
56+ user, password = encoded.decode('base64').split(':', 1)
57+ if not self.verify_user(user, password):
58+ return self._error(
59+ start_response, 401, "unauthorized",
60+ "Incorrect password or login.")
61+ del environ['HTTP_AUTHORIZATION']
62+ environ['user_id'] = user
63+ shift_path_info(environ)
64+ return self.app(environ, start_response)
65+
66+ def verify_user(self, username, password):
67+ raise NotImplementedError(self.verify_user)
68
69=== modified file 'u1db/remote/oauth_middleware.py'
70--- u1db/remote/oauth_middleware.py 2012-08-14 14:31:45 +0000
71+++ u1db/remote/oauth_middleware.py 2012-09-05 16:09:18 +0000
72@@ -35,9 +35,10 @@
73 # from arrival time
74 timestamp_threshold = 300
75
76- def __init__(self, app, base_url):
77+ def __init__(self, app, base_url, prefix='/~/'):
78 self.app = app
79 self.base_url = base_url
80+ self.prefix = prefix
81
82 def get_oauth_data_store(self):
83 """Provide a oauth.OAuthDataStore."""
84@@ -52,7 +53,7 @@
85 return [json.dumps(err)]
86
87 def __call__(self, environ, start_response):
88- if not environ['PATH_INFO'].startswith('/~/'):
89+ if self.prefix and not environ['PATH_INFO'].startswith(self.prefix):
90 return self._error(start_response, 400, "bad request")
91 headers = {}
92 if 'HTTP_AUTHORIZATION' in environ:
93
94=== renamed file 'u1db/tests/test_oauth_middleware.py' => 'u1db/tests/test_auth_middleware.py'
95--- u1db/tests/test_oauth_middleware.py 2012-08-14 14:31:45 +0000
96+++ u1db/tests/test_auth_middleware.py 2012-09-05 16:09:18 +0000
97@@ -26,15 +26,89 @@
98 from u1db import tests
99
100 from u1db.remote.oauth_middleware import OAuthMiddleware
101-
102-
103-BASE_URL = 'https://u1db.net'
104-
105-
106-class TestAuthMiddleware(tests.TestCase):
107-
108- def setUp(self):
109- super(TestAuthMiddleware, self).setUp()
110+from u1db.remote.basic_auth_middleware import BasicAuthMiddleware
111+
112+
113+BASE_URL = 'https://example.net'
114+
115+
116+class TestBasicAuthMiddleware(tests.TestCase):
117+
118+ def setUp(self):
119+ super(TestBasicAuthMiddleware, self).setUp()
120+ self.got = []
121+
122+ def witness_app(environ, start_response):
123+ start_response("200 OK", [("content-type", "text/plain")])
124+ self.got.append((environ['user_id'], environ['PATH_INFO'],
125+ environ['QUERY_STRING']))
126+ return ["ok"]
127+
128+ class MyAuthMiddleware(BasicAuthMiddleware):
129+
130+ def verify_user(self, user, password):
131+ if user != "correct_user":
132+ return False
133+ if password != "correct_password":
134+ return False
135+ return True
136+
137+ self.auth_midw = MyAuthMiddleware(witness_app, prefix="/pfx/")
138+ self.app = paste.fixture.TestApp(self.auth_midw)
139+
140+ def test_expect_prefix(self):
141+ url = BASE_URL + '/foo/doc/doc-id'
142+ resp = self.app.delete(url, expect_errors=True)
143+ self.assertEqual(400, resp.status)
144+ self.assertEqual('application/json', resp.header('content-type'))
145+ self.assertEqual('{"error": "bad request"}', resp.body)
146+
147+ def test_missing_auth(self):
148+ url = BASE_URL + '/pfx/foo/doc/doc-id'
149+ resp = self.app.delete(url, expect_errors=True)
150+ self.assertEqual(401, resp.status)
151+ self.assertEqual('application/json', resp.header('content-type'))
152+ self.assertEqual(
153+ {"error": "unauthorized",
154+ "message": "Missing Basic Authentication."},
155+ json.loads(resp.body))
156+
157+ def test_correct_auth(self):
158+ user = "correct_user"
159+ password = "correct_password"
160+ params = {'old_rev': 'old-rev'}
161+ url = BASE_URL + '/pfx/foo/doc/doc-id?%s' % (
162+ '&'.join("%s=%s" % (k, v) for k, v in params.items()))
163+ auth = '%s:%s' % (user, password)
164+ headers = {
165+ 'Authorization': 'Basic %s' % (auth.encode('base64'),)}
166+ resp = self.app.delete(url, headers=headers)
167+ self.assertEqual(200, resp.status)
168+ self.assertEqual(
169+ [('correct_user', '/foo/doc/doc-id', 'old_rev=old-rev')], self.got)
170+
171+ def test_incorrect_auth(self):
172+ user = "correct_user"
173+ password = "incorrect_password"
174+ params = {'old_rev': 'old-rev'}
175+ url = BASE_URL + '/pfx/foo/doc/doc-id?%s' % (
176+ '&'.join("%s=%s" % (k, v) for k, v in params.items()))
177+ auth = '%s:%s' % (user, password)
178+ headers = {
179+ 'Authorization': 'Basic %s' % (auth.encode('base64'),)}
180+ resp = self.app.delete(url, headers=headers, expect_errors=True)
181+ self.assertEqual(401, resp.status)
182+ self.assertEqual('application/json', resp.header('content-type'))
183+ self.assertEqual(
184+ {"error": "unauthorized",
185+ "message": "Incorrect password or login."},
186+ json.loads(resp.body))
187+
188+
189+class TestOAuthMiddlewareDefaultPrefix(tests.TestCase):
190+ def setUp(self):
191+
192+ super(TestOAuthMiddlewareDefaultPrefix, self).setUp()
193 self.got = []
194
195 def witness_app(environ, start_response):
196@@ -61,8 +135,76 @@
197 self.assertEqual('application/json', resp.header('content-type'))
198 self.assertEqual('{"error": "bad request"}', resp.body)
199
200+ def test_oauth_in_header(self):
201+ url = BASE_URL + '/~/foo/doc/doc-id'
202+ params = {'old_rev': 'old-rev'}
203+ oauth_req = oauth.OAuthRequest.from_consumer_and_token(
204+ tests.consumer2,
205+ tests.token2,
206+ parameters=params,
207+ http_url=url,
208+ http_method='DELETE'
209+ )
210+ url = oauth_req.get_normalized_http_url() + '?' + (
211+ '&'.join("%s=%s" % (k, v) for k, v in params.items()))
212+ oauth_req.sign_request(tests.sign_meth_HMAC_SHA1,
213+ tests.consumer2, tests.token2)
214+ resp = self.app.delete(url, headers=oauth_req.to_header())
215+ self.assertEqual(200, resp.status)
216+ self.assertEqual([(tests.token2.key,
217+ '/foo/doc/doc-id', 'old_rev=old-rev')], self.got)
218+
219+ def test_oauth_in_query_string(self):
220+ url = BASE_URL + '/~/foo/doc/doc-id'
221+ params = {'old_rev': 'old-rev'}
222+ oauth_req = oauth.OAuthRequest.from_consumer_and_token(
223+ tests.consumer1,
224+ tests.token1,
225+ parameters=params,
226+ http_url=url,
227+ http_method='DELETE'
228+ )
229+ oauth_req.sign_request(tests.sign_meth_HMAC_SHA1,
230+ tests.consumer1, tests.token1)
231+ resp = self.app.delete(oauth_req.to_url())
232+ self.assertEqual(200, resp.status)
233+ self.assertEqual([(tests.token1.key,
234+ '/foo/doc/doc-id', 'old_rev=old-rev')], self.got)
235+
236+
237+class TestOAuthMiddleware(tests.TestCase):
238+
239+ def setUp(self):
240+ super(TestOAuthMiddleware, self).setUp()
241+ self.got = []
242+
243+ def witness_app(environ, start_response):
244+ start_response("200 OK", [("content-type", "text/plain")])
245+ self.got.append((environ['token_key'], environ['PATH_INFO'],
246+ environ['QUERY_STRING']))
247+ return ["ok"]
248+
249+ class MyOAuthMiddleware(OAuthMiddleware):
250+ get_oauth_data_store = lambda self: tests.testingOAuthStore
251+
252+ def verify(self, environ, oauth_req):
253+ consumer, token = super(MyOAuthMiddleware, self).verify(
254+ environ, oauth_req)
255+ environ['token_key'] = token.key
256+
257+ self.oauth_midw = MyOAuthMiddleware(
258+ witness_app, BASE_URL, prefix='/pfx/')
259+ self.app = paste.fixture.TestApp(self.oauth_midw)
260+
261+ def test_expect_prefix(self):
262+ url = BASE_URL + '/foo/doc/doc-id'
263+ resp = self.app.delete(url, expect_errors=True)
264+ self.assertEqual(400, resp.status)
265+ self.assertEqual('application/json', resp.header('content-type'))
266+ self.assertEqual('{"error": "bad request"}', resp.body)
267+
268 def test_missing_oauth(self):
269- url = BASE_URL + '/~/foo/doc/doc-id'
270+ url = BASE_URL + '/pfx/foo/doc/doc-id'
271 resp = self.app.delete(url, expect_errors=True)
272 self.assertEqual(401, resp.status)
273 self.assertEqual('application/json', resp.header('content-type'))
274@@ -71,7 +213,7 @@
275 json.loads(resp.body))
276
277 def test_oauth_in_query_string(self):
278- url = BASE_URL + '/~/foo/doc/doc-id'
279+ url = BASE_URL + '/pfx/foo/doc/doc-id'
280 params = {'old_rev': 'old-rev'}
281 oauth_req = oauth.OAuthRequest.from_consumer_and_token(
282 tests.consumer1,
283@@ -88,7 +230,7 @@
284 '/foo/doc/doc-id', 'old_rev=old-rev')], self.got)
285
286 def test_oauth_invalid(self):
287- url = BASE_URL + '/~/foo/doc/doc-id'
288+ url = BASE_URL + '/pfx/foo/doc/doc-id'
289 params = {'old_rev': 'old-rev'}
290 oauth_req = oauth.OAuthRequest.from_consumer_and_token(
291 tests.consumer1,
292@@ -109,7 +251,7 @@
293 err)
294
295 def test_oauth_in_header(self):
296- url = BASE_URL + '/~/foo/doc/doc-id'
297+ url = BASE_URL + '/pfx/foo/doc/doc-id'
298 params = {'old_rev': 'old-rev'}
299 oauth_req = oauth.OAuthRequest.from_consumer_and_token(
300 tests.consumer2,
301@@ -128,7 +270,7 @@
302 '/foo/doc/doc-id', 'old_rev=old-rev')], self.got)
303
304 def test_oauth_plain_text(self):
305- url = BASE_URL + '/~/foo/doc/doc-id'
306+ url = BASE_URL + '/pfx/foo/doc/doc-id'
307 params = {'old_rev': 'old-rev'}
308 oauth_req = oauth.OAuthRequest.from_consumer_and_token(
309 tests.consumer1,
310@@ -145,7 +287,7 @@
311 '/foo/doc/doc-id', 'old_rev=old-rev')], self.got)
312
313 def test_oauth_timestamp_threshold(self):
314- url = BASE_URL + '/~/foo/doc/doc-id'
315+ url = BASE_URL + '/pfx/foo/doc/doc-id'
316 params = {'old_rev': 'old-rev'}
317 oauth_req = oauth.OAuthRequest.from_consumer_and_token(
318 tests.consumer1,
319
320=== modified file 'u1db/tests/test_https.py'
321--- u1db/tests/test_https.py 2012-07-05 15:26:52 +0000
322+++ u1db/tests/test_https.py 2012-09-05 16:09:18 +0000
323@@ -7,7 +7,6 @@
324 from paste import httpserver
325
326 from u1db import (
327- errors,
328 tests,
329 )
330 from u1db.remote import (
331@@ -21,7 +20,7 @@
332 def oauth_https_server_def():
333 def make_server(host_port, handler, state):
334 app = http_app.HTTPApp(state)
335- application = oauth_middleware.OAuthMiddleware(app, None)
336+ application = oauth_middleware.OAuthMiddleware(app, None, prefix='/~/')
337 application.get_oauth_data_store = lambda: tests.testingOAuthStore
338 from OpenSSL import SSL
339 cert_file = os.path.join(os.path.dirname(__file__), 'testing-certs',
340@@ -36,8 +35,6 @@
341 handler,
342 ssl_context=ssl_context
343 )
344- # workaround apparent interface mismatch
345- orig_shutdown_request = srv.shutdown_request
346
347 def shutdown_request(req):
348 req.shutdown()
349@@ -68,7 +65,7 @@
350
351 def setUp(self):
352 try:
353- import OpenSSL
354+ import OpenSSL # noqa
355 except ImportError:
356 self.skipTest("Requires pyOpenSSL")
357 self.cacert_pem = os.path.join(os.path.dirname(__file__),
358@@ -96,7 +93,7 @@
359 self.startServer()
360 # don't print expected traceback server-side
361 self.server.handle_error = lambda req, cli_addr: None
362- db = self.request_state._create_database('test')
363+ self.request_state._create_database('test')
364 remote_target = self.getSyncTarget('localhost', 'test')
365 try:
366 remote_target.record_sync_info('other-id', 2, 'T-id')
367@@ -110,11 +107,12 @@
368 self.skipTest(
369 "XXX certificate verification happens on linux only for now")
370 self.startServer()
371- db = self.request_state._create_database('test')
372+ self.request_state._create_database('test')
373 self.patch(http_client, 'CA_CERTS', self.cacert_pem)
374 remote_target = self.getSyncTarget('127.0.0.1', 'test')
375- self.assertRaises(http_client.CertificateError,
376- remote_target.record_sync_info, 'other-id', 2, 'T-id')
377+ self.assertRaises(
378+ http_client.CertificateError, remote_target.record_sync_info,
379+ 'other-id', 2, 'T-id')
380
381
382 load_tests = tests.load_with_scenarios
383
384=== modified file 'u1db/tests/test_remote_sync_target.py'
385--- u1db/tests/test_remote_sync_target.py 2012-07-19 19:50:58 +0000
386+++ u1db/tests/test_remote_sync_target.py 2012-09-05 16:09:18 +0000
387@@ -130,7 +130,7 @@
388
389 def make_server(host_port, handler, state):
390 app = http_app.HTTPApp(state)
391- application = oauth_middleware.OAuthMiddleware(app, None)
392+ application = oauth_middleware.OAuthMiddleware(app, None, prefix='/~/')
393 application.get_oauth_data_store = lambda: tests.testingOAuthStore
394 srv = simple_server.WSGIServer(host_port, handler)
395 # patch the value in

Subscribers

People subscribed via source and target branches