Merge lp:~leonardr/lazr.restful/forward-port into lp:lazr.restful

Proposed by Leonard Richardson
Status: Merged
Merged at revision: not available
Proposed branch: lp:~leonardr/lazr.restful/forward-port
Merge into: lp:lazr.restful
Diff against target: 527 lines
8 files modified
setup.py (+0/-1)
src/lazr/restful/NEWS.txt (+9/-0)
src/lazr/restful/_resource.py (+1/-1)
src/lazr/restful/docs/webservice.txt (+5/-3)
src/lazr/restful/docs/wsgi.txt (+0/-225)
src/lazr/restful/testing/webservice.py (+0/-33)
src/lazr/restful/version.txt (+1/-1)
src/lazr/restful/wsgi.py (+0/-144)
To merge this branch: bzr merge lp:~leonardr/lazr.restful/forward-port
Reviewer Review Type Date Requested Status
Deryck Hodge (community) code Approve
Review via email: mp+13009@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Leonard Richardson (leonardr) wrote :

This branch forward-ports a branch that intellectronica accidentally landed on the now-defunct PQM-managed lazr.restful branch.

Revision history for this message
Deryck Hodge (deryck) wrote :

Looks good to me. Thanks for bringing this change forward! Those being plagued by this error when doing non-ascii in bug descriptions will clap and cheer. :)

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'setup.py'
--- setup.py 2009-10-01 15:24:08 +0000
+++ setup.py 2009-10-07 17:20:23 +0000
@@ -63,7 +63,6 @@
63 'lazr.lifecycle',63 'lazr.lifecycle',
64 'lazr.uri',64 'lazr.uri',
65 'martian==0.11',65 'martian==0.11',
66 'oauth',
67 'setuptools',66 'setuptools',
68 'simplejson',67 'simplejson',
69 'van.testing',68 'van.testing',
7069
=== modified file 'src/lazr/restful/NEWS.txt'
--- src/lazr/restful/NEWS.txt 2009-10-06 16:09:51 +0000
+++ src/lazr/restful/NEWS.txt 2009-10-07 17:20:23 +0000
@@ -2,6 +2,15 @@
2NEWS for lazr.restful2NEWS for lazr.restful
3=====================3=====================
44
50.9.9 (2009-10-07)
6==================
7
8The authentication-related WSGI middleware classes have been split
9into a separate project, lazr.authentication.
10
11Fixed a bug that prevented some incoming strings from being loaded by
12simplejson.
13
50.9.8 (2009-10-06)140.9.8 (2009-10-06)
6==================15==================
716
817
=== modified file 'src/lazr/restful/_resource.py'
--- src/lazr/restful/_resource.py 2009-09-24 15:23:48 +0000
+++ src/lazr/restful/_resource.py 2009-10-07 17:20:23 +0000
@@ -882,7 +882,7 @@
882 self.request.response.setStatus(415)882 self.request.response.setStatus(415)
883 return None, 'Expected a media type of %s.' % self.JSON_TYPE883 return None, 'Expected a media type of %s.' % self.JSON_TYPE
884 try:884 try:
885 h = simplejson.loads(unicode(representation))885 h = simplejson.loads(representation.decode('utf-8'))
886 except ValueError:886 except ValueError:
887 self.request.response.setStatus(400)887 self.request.response.setStatus(400)
888 return None, "Entity-body was not a well-formed JSON document."888 return None, "Entity-body was not a well-formed JSON document."
889889
=== modified file 'src/lazr/restful/docs/webservice.txt'
--- src/lazr/restful/docs/webservice.txt 2009-09-02 18:54:22 +0000
+++ src/lazr/restful/docs/webservice.txt 2009-10-07 17:20:23 +0000
@@ -1815,11 +1815,13 @@
18151815
1816A PATCH request will automatically result in a modification event1816A PATCH request will automatically result in a modification event
1817being sent out about the modified object, which means that1817being sent out about the modified object, which means that
1818modify_cookbook() will be run. Here, we modify the name of one of the1818modify_cookbook() will be run. Here, we modify the name and the
1819cookbooks.1819cuisine of one of the cookbooks. Note that the cuisine contains
1820non-ASCII characters.
18201821
1821 >>> headers = {'CONTENT_TYPE' : 'application/json'}1822 >>> headers = {'CONTENT_TYPE' : 'application/json'}
1822 >>> body = '{"name" : "The Joy of Cooking (revised)"}'1823 >>> body = '''{"name" : "The Joy of Cooking (revised)",
1824 ... "cuisine" : "\xd7\x97\xd7\x95\xd7\x9e\xd7\x95\xd7\xa1"}'''
18231825
1824 >>> patch_request = create_web_service_request(1826 >>> patch_request = create_web_service_request(
1825 ... '/beta/cookbooks/The%20Joy%20of%20Cooking', body=body,1827 ... '/beta/cookbooks/The%20Joy%20of%20Cooking', body=body,
18261828
=== removed file 'src/lazr/restful/docs/wsgi.txt'
--- src/lazr/restful/docs/wsgi.txt 2009-10-06 15:59:10 +0000
+++ src/lazr/restful/docs/wsgi.txt 1970-01-01 00:00:00 +0000
@@ -1,225 +0,0 @@
1WSGI Middleware
2===============
3
4lazr.restful defines some simple WSGI middleware for doing
5authentication. This is mainly for setting up testing scenarios for
6lazr.restfulclient, but you can use the middleware in real
7applications.
8
9BasicAuthMiddleware
10-------------------
11
12The BasicAuthMiddleware implements HTTP Basic Auth. Its constructor
13takes a number of arguments, including a callback function that
14performs the actual authentication. This function returns an object
15identifying the user who's trying to authenticate. If the
16authentication credentials are invalid, it's supposed to return None.
17
18First, let's create a really simple WSGI application that responds to
19any request with a 200 response code.
20
21 >>> from lazr.restful.testing.webservice import WebServiceApplication
22 >>> from lazr.restful.example.base.tests.test_integration import (
23 ... CookbookWebServiceTestPublication)
24
25 >>> def dummy_application(environ, start_response):
26 ... start_response('200', [('Content-type','text/plain')])
27 ... return ['Success']
28
29Now let's protect that application. Here's an authentication callback
30function.
31
32 >>> def authenticate(username, password):
33 ... """Accepts "user/password", rejects everything else.
34 ...
35 ... :return: The username, if the credentials are valid.
36 ... None, otherwise.
37 ... """
38 ... if username == "user" and password == "password":
39 ... return username
40 ... return None
41
42 >>> print authenticate("user", "password")
43 user
44
45 >>> print authenticate("notuser", "password")
46 None
47
48Here's a WSGI application that protects the application using
49BasicAuthMiddleware.
50
51 >>> from lazr.restful.wsgi import BasicAuthMiddleware
52 >>> def protected_application():
53 ... return BasicAuthMiddleware(
54 ... dummy_application, realm="WSGI middleware test",
55 ... protect_path_pattern=".*protected.*",
56 ... authenticate_with=authenticate)
57
58 >>> import wsgi_intercept
59 >>> from wsgi_intercept.httplib2_intercept import install
60 >>> install()
61 >>> wsgi_intercept.add_wsgi_intercept(
62 ... 'basictest', 80, protected_application)
63
64Most of the application's URLs are not protected by the
65middleware. You can access them without providing credentials.
66
67 >>> import httplib2
68 >>> client = httplib2.Http()
69 >>> response, body = client.request('http://basictest/')
70 >>> print response['status']
71 200
72 >>> print body
73 Success
74
75Any URL that includes the string "protected" is protected by the
76middleware, and cannot be accessed without credentials.
77
78 >>> response, body = client.request('http://basictest/protected/')
79 >>> print response['status']
80 401
81 >>> print response['www-authenticate']
82 Basic realm="WSGI middleware test"
83
84 >>> response, body = client.request(
85 ... 'http://basictest/this-is-protected-as-well/')
86 >>> print response['status']
87 401
88
89The check_credentials() implementation given at the beginning of the
90test will only accept the user/password combination "user"/"password".
91Provide a bad username or password and you'll get a 401.
92
93 >>> client.add_credentials("baduser", "baspassword")
94 >>> response, body = client.request('http://basictest/protected/')
95 >>> print response['status']
96 401
97
98Provide the correct credentials and you'll get a 200, even for the
99protected URIs.
100
101 >>> client.add_credentials("user", "password")
102 >>> response, body = client.request('http://basictest/protected/')
103 >>> print response['status']
104 200
105
106OAuthMiddleware
107---------------
108
109The OAuthMiddleware implements section 7 ("Accessing Protected
110Resources") of the OAuth specification. That is, it makes sure that
111incoming consumer keys and access tokens pass some application-defined
112test. It does not help you serve request tokens or exchange a request
113token for an access token.
114
115We'll use OAuthMiddleware to protect the same simple application we
116protected earlier with BasicAuthMiddleware. But since we're using
117OAuth, we'll be checking a consumer key and access token, instead of a
118username and password.
119
120 >>> from oauth.oauth import OAuthConsumer, OAuthToken
121
122 >>> valid_consumer = OAuthConsumer("consumer", '')
123 >>> valid_token = OAuthToken("token", "secret")
124
125 >>> def authenticate(consumer, token, parameters):
126 ... """Accepts the valid consumer and token, rejects everything else.
127 ...
128 ... :return: The consumer, if the credentials are valid.
129 ... None, otherwise.
130 ... """
131 ... if consumer == valid_consumer and token == valid_token:
132 ... return consumer
133 ... return None
134
135 >>> print authenticate(valid_consumer, valid_token, None).key
136 consumer
137
138 >>> invalid_consumer = OAuthConsumer("other consumer", '')
139 >>> print authenticate(invalid_consumer, valid_token, None)
140 None
141
142To test the OAuthMiddleware's security features, we'll also need to
143create a data store. In a real application the data store would
144probably be a database containing the registered consumer keys and
145tokens. We're using a simple data store designed for testing, and
146telling it about the one valid consumer and token.
147
148 >>> from lazr.restful.testing.webservice import SimpleOAuthDataStore
149 >>> data_store = SimpleOAuthDataStore(
150 ... {valid_consumer.key : valid_consumer},
151 ... {valid_token.key : valid_token})
152
153 >>> print data_store.lookup_consumer("consumer").key
154 consumer
155 >>> print data_store.lookup_consumer("badconsumer")
156 None
157
158The data store tracks the use of OAuth nonces. If you call the data
159store's lookup_nonce() twice with the same values, the first call will
160return False and the second call will return True.
161
162 >>> print data_store.lookup_nonce("consumer", "token", "nonce")
163 False
164 >>> print data_store.lookup_nonce("consumer", "token", "nonce")
165 True
166
167 >>> print data_store.lookup_nonce("newconsumer", "token", "nonce")
168 False
169
170Now let's protect an application with lazr.restful's OAuthMiddleware,
171using our authentication technique and our simple data store.
172
173 >>> from lazr.restful.wsgi import OAuthMiddleware
174 >>> def protected_application():
175 ... return OAuthMiddleware(
176 ... dummy_application, realm="OAuth test",
177 ... authenticate_with=authenticate, data_store=data_store)
178
179 >>> wsgi_intercept.add_wsgi_intercept(
180 ... 'oauthtest', 80, protected_application)
181 >>> client = httplib2.Http()
182
183A properly signed request will go through to the underlying WSGI
184application.
185
186 >>> from oauth.oauth import (
187 ... OAuthRequest, OAuthSignatureMethod_PLAINTEXT)
188 >>> def sign_request(url, consumer=valid_consumer, token=valid_token):
189 ... request = OAuthRequest().from_consumer_and_token(
190 ... consumer, token, http_url=url)
191 ... request.sign_request(
192 ... OAuthSignatureMethod_PLAINTEXT(), consumer, token)
193 ... headers = request.to_header('OAuth test')
194 ... return headers
195
196 >>> url = 'http://oauthtest/'
197 >>> headers = sign_request(url)
198 >>> response, body = client.request(url, headers=headers)
199 >>> print response['status']
200 200
201 >>> print body
202 Success
203
204If you replay a signed HTTP request that worked the first time, it
205will fail the second time, because you'll be sending a nonce that was
206already used.
207
208 >>> response, body = client.request(url, headers=headers)
209 >>> print response['status']
210 401
211
212An unsigned request will fail.
213
214 >>> response, body = client.request('http://oauthtest/')
215 >>> print response['status']
216 401
217
218A request signed with invalid credentials will fail.
219
220 >>> bad_token = OAuthToken("token", "badsecret")
221 >>> headers = sign_request(url, token=bad_token)
222 >>> response, body = client.request(url, headers=headers)
223 >>> print response['status']
224 401
225
2260
=== modified file 'src/lazr/restful/testing/webservice.py'
--- src/lazr/restful/testing/webservice.py 2009-10-06 15:59:10 +0000
+++ src/lazr/restful/testing/webservice.py 2009-10-07 17:20:23 +0000
@@ -11,7 +11,6 @@
11 'pprint_entry',11 'pprint_entry',
12 'WebServiceTestPublication',12 'WebServiceTestPublication',
13 'WebServiceTestRequest',13 'WebServiceTestRequest',
14 'SimpleOAuthDataStore',
15 'TestPublication',14 'TestPublication',
16 ]15 ]
1716
@@ -22,8 +21,6 @@
22from urlparse import urljoin21from urlparse import urljoin
23import wsgi_intercept22import wsgi_intercept
2423
25from oauth.oauth import OAuthDataStore
26
27from zope.component import adapts, getUtility, queryMultiAdapter24from zope.component import adapts, getUtility, queryMultiAdapter
28from zope.interface import implements25from zope.interface import implements
29from zope.publisher.browser import BrowserRequest26from zope.publisher.browser import BrowserRequest
@@ -363,33 +360,3 @@
363 def __str__(self):360 def __str__(self):
364 return "http://dummy"361 return "http://dummy"
365 __call__ = __str__362 __call__ = __str__
366
367
368class SimpleOAuthDataStore(OAuthDataStore):
369 """A very simple implementation of the oauth library's OAuthDataStore."""
370
371 def __init__(self, consumers={}, tokens={}):
372 """Initialize with no nonces."""
373 self.consumers = consumers
374 self.tokens = tokens
375 self.nonces = set()
376
377 def lookup_token(self, token_type, token_field):
378 """Turn a token key into an OAuthToken object."""
379 return self.tokens.get(token_field)
380
381 def lookup_consumer(self, consumer):
382 """Turn a consumer key into an OAuthConsumer object."""
383 return self.consumers.get(consumer)
384
385 def lookup_nonce(self, consumer, token, nonce):
386 """Make sure a nonce has not already been used.
387
388 If the nonce has not been used, add it to the set
389 so that a future call to this method will return False.
390 """
391 key = (consumer, token, nonce)
392 if key in self.nonces:
393 return True
394 self.nonces.add(key)
395 return False
396363
=== modified file 'src/lazr/restful/version.txt'
--- src/lazr/restful/version.txt 2009-10-06 16:09:51 +0000
+++ src/lazr/restful/version.txt 2009-10-07 17:20:23 +0000
@@ -1,1 +1,1 @@
10.9.810.9.9
22
=== modified file 'src/lazr/restful/wsgi.py'
--- src/lazr/restful/wsgi.py 2009-10-06 15:59:10 +0000
+++ src/lazr/restful/wsgi.py 2009-10-07 17:20:23 +0000
@@ -2,13 +2,10 @@
22
3__metaclass__ = type3__metaclass__ = type
4__all__ = [4__all__ = [
5 'BasicAuthMiddleware',
6 'WSGIApplication',5 'WSGIApplication',
7 ]6 ]
87
9from pkg_resources import resource_string8from pkg_resources import resource_string
10import re
11import urlparse
12from wsgiref.simple_server import make_server as wsgi_make_server9from wsgiref.simple_server import make_server as wsgi_make_server
1310
14from zope.component import getUtility11from zope.component import getUtility
@@ -16,9 +13,6 @@
16from zope.interface import implements13from zope.interface import implements
17from zope.publisher.publish import publish14from zope.publisher.publish import publish
1815
19from oauth.oauth import (
20 OAuthError, OAuthRequest, OAuthServer, OAuthSignatureMethod_PLAINTEXT)
21
22from lazr.restful.interfaces import (16from lazr.restful.interfaces import (
23 IWebServiceConfiguration, IServiceRootResource)17 IWebServiceConfiguration, IServiceRootResource)
24from lazr.restful.simple import Publication, Request18from lazr.restful.simple import Publication, Request
@@ -66,141 +60,3 @@
66 """Create a WSGI server object for a particular web service."""60 """Create a WSGI server object for a particular web service."""
67 cls.configure_server(host, port, config_package, config_file)61 cls.configure_server(host, port, config_package, config_file)
68 return wsgi_make_server(host, int(port), cls)62 return wsgi_make_server(host, int(port), cls)
69
70
71class AuthenticationMiddleware(object):
72 """A base class for middleware that authenticates HTTP requests.
73
74 This class implements a generic HTTP authentication workflow:
75 check whether the requested resource is protected, get credentials
76 from the WSGI environment, validate them (using a callback
77 function) and either allow or deny acces.
78 """
79
80 def __init__(self, application, authenticate_with,
81 realm="Restricted area", protect_path_pattern='.*'):
82 """Constructor.
83
84 :param application: A WSGI application.
85
86 :param authenticate_with: A callback function that takes some
87 number of credential arguemnts (the number and type
88 depends on the implementation of
89 getCredentialsFromEnvironment()) and returns an object
90 representing the authenticated user. If the credentials
91 are invalid or don't identify any existing user, the
92 function should return None.
93
94 :param realm: The string to give out as the authentication realm.
95 :param protect_path_pattern: A regular expression string. URL
96 paths matching this string will be protected with the
97 authentication method. URL paths not matching this string
98 can be accessed without authenticating.
99 """
100 self.application = application
101 self.authenticate_with = authenticate_with
102 self.realm = realm
103 self.protect_path_pattern = re.compile(protect_path_pattern)
104
105 def _unauthorized(self, start_response):
106 """Short-circuit the request with a 401 error code."""
107 start_response("401 Unauthorized",
108 [('WWW-Authenticate',
109 'Basic realm="%s"' % self.realm)])
110 return ['401 Unauthorized']
111
112 def __call__(self, environ, start_response):
113 """Protect certain resources by checking auth credentials."""
114 path_info = environ.get('PATH_INFO', '/')
115 if not self.protect_path_pattern.match(path_info):
116 environ['authenticated_user'] = None
117 return self.application(environ, start_response)
118
119 try:
120 credentials = self.getCredentialsFromEnvironment(environ)
121 except ValueError:
122 credentials = None
123 if credentials is None:
124 return self._unauthorized(start_response)
125
126 authenticated_user = self.authenticate_with(*credentials)
127 if authenticated_user is None:
128 return self._unauthorized(start_response)
129
130 environ['authenticated_user'] = authenticated_user
131
132 return self.application(environ, start_response)
133
134 def getCredentialsFromEnvironment(self, environment):
135 """Retrieve a set of credentials from the environment.
136
137 This superclass implementation ignores the environment
138 entirely, and so never authenticates anybody.
139
140 :param environment: The WSGI environment.
141 :return: A list of objects to be passed into the authenticate_with
142 callback function, or None if the credentials could not
143 be determined.
144 """
145 return None
146
147
148class BasicAuthMiddleware(AuthenticationMiddleware):
149 """WSGI middleware that implements HTTP Basic Auth."""
150
151 def getCredentialsFromEnvironment(self, environ):
152 authorization = environ.get('HTTP_AUTHORIZATION')
153 if authorization is None:
154 return None
155
156 method, auth = authorization.split(' ', 1)
157 if method.lower() != 'basic':
158 return None
159
160 auth = auth.strip().decode('base64')
161 username, password = auth.split(':', 1)
162 return username, password
163
164
165class OAuthMiddleware(AuthenticationMiddleware):
166 """WSGI middleware that implements (part of) OAuth.
167
168 This middleware only protects resources by making sure requests
169 are signed with a valid consumer and access token. It does not
170 help clients get request tokens or exchange request tokens for
171 access tokens.
172 """
173
174 def __init__(self, application, authenticate_with, data_store=None,
175 realm="Restricted area", protect_path_pattern='.*'):
176 """See `AuthenticationMiddleware.`
177
178 :param data_store: An OAuthDataStore.
179 """
180 super(OAuthMiddleware, self).__init__(
181 application, authenticate_with, realm, protect_path_pattern)
182 self.data_store = data_store
183
184 def getCredentialsFromEnvironment(self, environ):
185 http_method = environ['REQUEST_METHOD']
186
187 # Recreate the URL.
188 url_scheme = environ['wsgi.url_scheme']
189 hostname = environ['HTTP_HOST']
190 path = environ['PATH_INFO']
191 query_string = environ['QUERY_STRING']
192 original_url = urlparse.urlunparse(
193 (url_scheme, hostname, path, '', query_string, ''))
194 headers = {'Authorization' : environ.get('HTTP_AUTHORIZATION', '')}
195 request = OAuthRequest().from_request(
196 http_method, original_url, headers=headers,
197 query_string=query_string)
198 if request is None:
199 return None
200 server = OAuthServer(self.data_store)
201 server.add_signature_method(OAuthSignatureMethod_PLAINTEXT())
202 try:
203 consumer, token, parameters = server.verify_request(request)
204 except OAuthError, e:
205 return None
206 return consumer, token, parameters

Subscribers

People subscribed via source and target branches