Merge lp:~bac/launchpad/bug-750984 into lp:launchpad

Proposed by Brad Crittenden
Status: Merged
Approved by: Brad Crittenden
Approved revision: no longer in the source branch.
Merged at revision: 13127
Proposed branch: lp:~bac/launchpad/bug-750984
Merge into: lp:launchpad
Diff against target: 418 lines (+92/-55)
3 files modified
lib/canonical/launchpad/doc/webapp-publication.txt (+50/-33)
lib/canonical/launchpad/interfaces/oauth.py (+22/-6)
lib/canonical/launchpad/webapp/servers.py (+20/-16)
To merge this branch: bzr merge lp:~bac/launchpad/bug-750984
Reviewer Review Type Date Requested Status
j.c.sackett (community) Approve
Review via email: mp+62543@code.launchpad.net

Commit message

[r=jcsackett][bug=750984] Don't OOPS on bad API token/nonce/timestamp.

Description of the change

= Summary =

Previously problems with API token, nonce, or timestamp resulted in an
Unauthorized exception being raised which caused an OOPS to be
generated. Since the likely cause of those problems is bad client data
(though server side processing could be at fault) it is inappropriate to
generated an OOPS.

== Proposed fix ==

Rather than returning Unauthorized return custom exceptions that have
been marked with webservice_error as 401. The client still sees an HTTP
401 but no OOPS is reported.

== Pre-implementation notes ==

Talks with Gary and lots of conversation on the bug between Robert and
Martin.

== Tests ==

bin/test -vvt webapp-publication.txt

== Demo and Q/A ==

It should be possible to force bad client data but the details are not
obvious at the moment.

= Launchpad lint =

The lint issues are not fixable.

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/canonical/launchpad/interfaces/oauth.py
  lib/canonical/launchpad/doc/webapp-publication.txt
  lib/canonical/launchpad/webapp/servers.py

./lib/canonical/launchpad/doc/webapp-publication.txt
     195: want exceeds 78 characters.
     203: want exceeds 78 characters.
     325: want exceeds 78 characters.

To post a comment you must log in.
Revision history for this message
j.c.sackett (jcsackett) wrote :

This looks good to land.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/doc/webapp-publication.txt'
2--- lib/canonical/launchpad/doc/webapp-publication.txt 2011-04-21 01:30:30 +0000
3+++ lib/canonical/launchpad/doc/webapp-publication.txt 2011-05-26 19:11:35 +0000
4@@ -1,4 +1,5 @@
5-= Launchpad Publication =
6+Launchpad Publication
7+=====================
8
9 Launchpad uses the generic Zope3 publisher. It registers several
10 factories that are responsible for instantiating the appropriate
11@@ -6,7 +7,8 @@
12 zope.publisher.IPublication for the request.
13
14
15-== Virtual host configurations ==
16+Virtual host configurations
17+---------------------------
18
19 The configuration defines a number of domains, one for the main
20 Launchpad site and one for the sites of the various applications.
21@@ -118,7 +120,8 @@
22 xmlrpc.launchpad.dev
23
24
25-== VirtualHostRequestPublicationFactory ==
26+VirtualHostRequestPublicationFactory
27+------------------------------------
28
29 A number of VirtualHostRequestPublicationFactories are registered with
30 Zope to handle requests for a particular vhost, port, and set of HTTP
31@@ -322,7 +325,8 @@
32 <class 'canonical.launchpad.webapp.publication.LaunchpadBrowserPublication'>
33
34
35-== Zope Publisher integration ==
36+Zope Publisher integration
37+--------------------------
38
39 A factory is registered for each of our available virtual host. This
40 is done by the register_launchpad_request_publication_factories
41@@ -417,7 +421,8 @@
42 Allow: POST
43
44 >>> print_request_and_publication(
45- ... 'xmlrpc.launchpad.dev', method='POST', mime_type='application/xml')
46+ ... 'xmlrpc.launchpad.dev', method='POST',
47+ ... mime_type='application/xml')
48 ProtocolErrorRequest
49 ProtocolErrorPublication: status=415
50
51@@ -483,7 +488,8 @@
52 >>> login(ANONYMOUS)
53
54
55-== ILaunchpadBrowserApplicationRequest ==
56+ILaunchpadBrowserApplicationRequest
57+-----------------------------------
58
59 All Launchpad requests provides the ILaunchpadBrowserApplicationRequest
60 interface. That interface is an extension of the zope standard
61@@ -497,7 +503,8 @@
62 True
63
64
65-== Handling form data using IBrowserFormNG ==
66+Handling form data using IBrowserFormNG
67+---------------------------------------
68
69 Submitted form data is available in the form_ng request attribute. This
70 is an object providing the IBrowserFormNG interface which offers two
71@@ -582,7 +589,8 @@
72 items_field
73
74
75-== Page ID ==
76+Page ID
77+-------
78
79 Our publication implementation sets a WSGI variable 'launchpad.pageid'.
80 This is an identifier of the form ContextName:ViewName.
81@@ -654,7 +662,8 @@
82 ''
83
84
85-== Tick counts ==
86+Tick counts
87+-----------
88
89 Similarly to our page IDs, our publication implementation will store the
90 tick counts for the traversal and object publication processes in WSGI
91@@ -832,7 +841,8 @@
92 >>> login(ANONYMOUS)
93
94
95-== Transaction Logging ==
96+Transaction Logging
97+-------------------
98
99 The publication implementation is responsible for putting the name
100 of the logged in user in the transaction. (The afterCall() hook is
101@@ -872,7 +882,8 @@
102 / 16
103
104
105-== Read-Only Requests ==
106+Read-Only Requests
107+------------------
108
109 Our publication implementation make sure that requests supposed to be
110 read-only (GET and HEAD) don't change anything in the database.
111@@ -943,7 +954,8 @@
112 Darwin
113
114
115-=== GET requests on api.launchpad.net ===
116+GET requests on api.launchpad.net
117+.................................
118
119 In the case of WebServicePublication, though, we have to commit the
120 transaction after GET requests in order to persist new entries added to
121@@ -992,13 +1004,14 @@
122 ... 'api.launchpad.dev', 'GET',
123 ... extra_environment=dict(QUERY_STRING=urlencode(form)))
124 >>> test_request.processInputs()
125- >>> print publication.getPrincipal(test_request).title
126+ >>> publication.getPrincipal(test_request)
127 Traceback (most recent call last):
128 ...
129- Unauthorized: Invalid nonce/timestamp: This nonce has been used already.
130-
131-
132-== Doomed transactions are aborted ==
133+ NonceAlreadyUsed: This nonce has been used already.
134+
135+
136+Doomed transactions are aborted
137+-------------------------------
138
139 Doomed transactions are aborted.
140
141@@ -1032,7 +1045,8 @@
142 >>> del txn.abort # Clean up test fixture.
143
144
145-== Requests on Python C Methods succeed ==
146+Requests on Python C Methods succeed
147+------------------------------------
148
149 Rarely but occasionally, it is possible to traverse to a Python C method.
150 For instance, an XMLRPC proxy might allow a traversal to __repr__.
151@@ -1048,7 +1062,8 @@
152 '{}'
153
154
155-== HEAD requests have empty body ==
156+HEAD requests have empty body
157+-----------------------------
158
159 The publication implementation also makes sure that no body is
160 returned as part of HEAD requests. (Again this is handled by the
161@@ -1085,7 +1100,8 @@
162 Some boring content.
163
164
165-== Authentication of requests ==
166+Authentication of requests
167+--------------------------
168
169 In LaunchpadBrowserPublication, authentication happens in the
170 beforeTraversal() hook. Our publication will set the principal to
171@@ -1183,10 +1199,11 @@
172 >>> form2 = form.copy()
173 >>> form2['oauth_nonce'] = '1764572616e48616d6d65724c61686'
174 >>> test_request = LaunchpadTestRequest(form=form2)
175- >>> print publication.getPrincipal(test_request).title
176+ >>> publication.getPrincipal(test_request)
177 Traceback (most recent call last):
178 ...
179- Unauthorized: Expired token...
180+ TokenException: Expired token...
181+
182 >>> access_token.date_expires = now + timedelta(days=1)
183 >>> syncUpdate(access_token)
184
185@@ -1194,10 +1211,10 @@
186 >>> form2['oauth_token'] += 'z'
187 >>> form2['oauth_nonce'] = '4572616e48616d6d65724c61686176'
188 >>> test_request = LaunchpadTestRequest(form=form2)
189- >>> print publication.getPrincipal(test_request).title
190+ >>> publication.getPrincipal(test_request)
191 Traceback (most recent call last):
192 ...
193- Unauthorized: Unknown access token...
194+ TokenException: Unknown access token...
195
196 The consumer must be registered as well, and the signature must be
197 correct.
198@@ -1205,7 +1222,7 @@
199 >>> form2 = form.copy()
200 >>> form2['oauth_consumer_key'] += 'z'
201 >>> test_request = LaunchpadTestRequest(form=form2)
202- >>> print publication.getPrincipal(test_request).title
203+ >>> publication.getPrincipal(test_request)
204 Traceback (most recent call last):
205 ...
206 Unauthorized: Unknown consumer (foobar123451432z).
207@@ -1214,10 +1231,10 @@
208 >>> form2['oauth_signature'] += 'z'
209 >>> form2['oauth_nonce'] = '2616e48616d6d65724c61686176457'
210 >>> test_request = LaunchpadTestRequest(form=form2)
211- >>> print publication.getPrincipal(test_request).title
212+ >>> publication.getPrincipal(test_request)
213 Traceback (most recent call last):
214 ...
215- Unauthorized: Invalid signature.
216+ TokenException: Invalid signature.
217
218 The nonce specified in the request must not have been used before in a request
219 with this same token and timestamp.
220@@ -1229,10 +1246,10 @@
221 Guilherme Salgado
222
223 >>> test_request = LaunchpadTestRequest(form=form2)
224- >>> print publication.getPrincipal(test_request).title
225+ >>> publication.getPrincipal(test_request)
226 Traceback (most recent call last):
227 ...
228- Unauthorized: Invalid nonce/timestamp: This nonce has been used already.
229+ NonceAlreadyUsed: This nonce has been used already.
230
231 The timestamp must not be older than TIMESTAMP_ACCEPTANCE_WINDOW from the most
232 recent request for this token.
233@@ -1247,10 +1264,10 @@
234
235 >>> form2['oauth_timestamp'] -= 10
236 >>> test_request = LaunchpadTestRequest(form=form2)
237- >>> print publication.getPrincipal(test_request).title
238+ >>> publication.getPrincipal(test_request)
239 Traceback (most recent call last):
240 ...
241- Unauthorized: Invalid nonce/timestamp: Timestamp too old compared ...
242+ TimestampOrderingError: Timestamp too old compared ...
243
244 Last but not least, the timestamp must not be too far in the future, as
245 defined by TIMESTAMP_SKEW_WINDOW.
246@@ -1260,10 +1277,10 @@
247 >>> form2 = form.copy()
248 >>> form2['oauth_timestamp'] += (TIMESTAMP_SKEW_WINDOW+10)
249 >>> test_request = LaunchpadTestRequest(form=form2)
250- >>> print publication.getPrincipal(test_request).title
251+ >>> publication.getPrincipal(test_request)
252 Traceback (most recent call last):
253 ...
254- Unauthorized: Invalid nonce/timestamp: Timestamp ... from bad system clock
255+ ClockSkew: Timestamp ... from bad system clock
256
257 Close the bogus request that was started by the call to
258 beforeTraversal, in order to ensure we leave our state sane.
259
260=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
261--- lib/canonical/launchpad/interfaces/oauth.py 2010-10-18 11:34:31 +0000
262+++ lib/canonical/launchpad/interfaces/oauth.py 2011-05-26 19:11:35 +0000
263@@ -20,8 +20,10 @@
264 'NonceAlreadyUsed',
265 'TimestampOrderingError',
266 'ClockSkew',
267+ 'TokenException',
268 ]
269
270+import httplib
271 from zope.interface import (
272 Attribute,
273 Interface,
274@@ -34,6 +36,8 @@
275 TextLine,
276 )
277
278+from lazr.restful.declarations import webservice_error
279+
280 from canonical.launchpad import _
281 from canonical.launchpad.webapp.interfaces import (
282 AccessLevel,
283@@ -300,14 +304,26 @@
284 """Marker interface for a request signed with OAuth credentials."""
285
286
287-# Note that these three exceptions are converted to Unauthorized (equating to
288-# 401 status) in webapp/servers.py, WebServicePublication.getPrincipal.
289-
290-class NonceAlreadyUsed(Exception):
291+# Note that these exceptions are marked as UNAUTHORIZED (401 status)
292+# so they may be raised but will not cause an OOPS to be generated. The
293+# client will see them as an UNAUTHORIZED error.
294+
295+class _TokenException(Exception):
296+ """Base class for token, nonce, and timestamp exceptions."""
297+ webservice_error(httplib.UNAUTHORIZED)
298+
299+
300+class NonceAlreadyUsed(_TokenException):
301 """Nonce has been used together with same token but another timestamp."""
302
303-class TimestampOrderingError(Exception):
304+
305+class TimestampOrderingError(_TokenException):
306 """Timestamp is too old, compared to the last request."""
307
308-class ClockSkew(Exception):
309+
310+class ClockSkew(_TokenException):
311 """Timestamp is too far off from server's clock."""
312+
313+
314+class TokenException(_TokenException):
315+ """Token has expired."""
316
317=== modified file 'lib/canonical/launchpad/webapp/servers.py'
318--- lib/canonical/launchpad/webapp/servers.py 2011-05-20 19:35:04 +0000
319+++ lib/canonical/launchpad/webapp/servers.py 2011-05-26 19:11:35 +0000
320@@ -67,11 +67,9 @@
321 IWebServiceApplication,
322 )
323 from canonical.launchpad.interfaces.oauth import (
324- ClockSkew,
325 IOAuthConsumerSet,
326 IOAuthSignedRequest,
327- NonceAlreadyUsed,
328- TimestampOrderingError,
329+ TokenException,
330 )
331 import canonical.launchpad.layers
332 from canonical.launchpad.webapp.authentication import (
333@@ -1025,7 +1023,7 @@
334
335
336 http = wsgi.ServerType(
337- ZServerTracelogServer, # subclass of WSGIHTTPServer
338+ ZServerTracelogServer, # subclass of WSGIHTTPServer
339 WSGIPublisherApplication,
340 LaunchpadAccessLogger,
341 8080,
342@@ -1039,7 +1037,7 @@
343 True)
344
345 debughttp = wsgi.ServerType(
346- ZServerTracelogServer, # subclass of WSGIHTTPServer
347+ ZServerTracelogServer, # subclass of WSGIHTTPServer
348 WSGIPublisherApplication,
349 LaunchpadAccessLogger,
350 8082,
351@@ -1047,7 +1045,7 @@
352 requestFactory=DebugLayerRequestFactory)
353
354 privatexmlrpc = wsgi.ServerType(
355- ZServerTracelogServer, # subclass of WSGIHTTPServer
356+ ZServerTracelogServer, # subclass of WSGIHTTPServer
357 WSGIPublisherApplication,
358 LaunchpadAccessLogger,
359 8080,
360@@ -1208,6 +1206,14 @@
361
362 Web service requests are authenticated using OAuth, except for the
363 one made using (presumably) JavaScript on the /api override path.
364+
365+ Raises a variety of token errors (ClockSkew, NonceAlreadyUsed,
366+ TimestampOrderingError, TokenException) which have a webservice error
367+ status of Unauthorized - 401. All of these exceptions represent
368+ errors on the part of the client.
369+
370+ Raises Unauthorized directly in the case where the consumer is None
371+ for a non-anonymous request as it may represent a server error.
372 """
373 # Use the regular HTTP authentication, when the request is not
374 # on the API virtual host but comes through the path_override on
375@@ -1250,7 +1256,7 @@
376 # transactions committed so that we can keep track of
377 # the OAuth nonces and prevent replay attacks.
378 if consumer_key == '' or consumer_key is None:
379- raise Unauthorized("No consumer key specified.")
380+ raise TokenException("No consumer key specified.")
381 consumer = consumers.new(consumer_key, '')
382 else:
383 # An unknown consumer can never make a non-anonymous
384@@ -1270,19 +1276,16 @@
385 return auth_utility.unauthenticatedPrincipal()
386 token = consumer.getAccessToken(token_key)
387 if token is None:
388- raise Unauthorized('Unknown access token (%s).' % token_key)
389+ raise TokenException('Unknown access token (%s).' % token_key)
390 nonce = form.get('oauth_nonce')
391 timestamp = form.get('oauth_timestamp')
392- try:
393- token.checkNonceAndTimestamp(nonce, timestamp)
394- except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
395- raise Unauthorized('Invalid nonce/timestamp: %s' % e)
396+ token.checkNonceAndTimestamp(nonce, timestamp)
397 if token.permission == OAuthPermission.UNAUTHORIZED:
398- raise Unauthorized('Unauthorized token (%s).' % token.key)
399+ raise TokenException('Unauthorized token (%s).' % token.key)
400 elif token.is_expired:
401- raise Unauthorized('Expired token (%s).' % token.key)
402+ raise TokenException('Expired token (%s).' % token.key)
403 elif not check_oauth_signature(request, consumer, token):
404- raise Unauthorized('Invalid signature.')
405+ raise TokenException('Invalid signature.')
406 else:
407 # Everything is fine, let's return the principal.
408 pass
409@@ -1529,7 +1532,8 @@
410 # len(factories)+1.
411 for priority, factory in enumerate(factories):
412 publisher_factory_registry.register(
413- "*", "*", factory.vhost_name, len(factories)-priority+1, factory)
414+ "*", "*", factory.vhost_name, len(factories) - priority + 1,
415+ factory)
416
417 # Register a catch-all "not found" handler at the lowest priority.
418 publisher_factory_registry.register(