Merge lp:~thisfred/u1db/add-basic-auth into lp:u1db
- add-basic-auth
- Merge into trunk
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 |
Related bugs: |
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
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 |
good