Merge ~cjwatson/launchpad:oauthlib into launchpad:master
- Git
- lp:~cjwatson/launchpad
- oauthlib
- Merge into master
Proposed by
Colin Watson
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Colin Watson | ||||
Approved revision: | bc855e779a25db270488e87c029533772e2d95ed | ||||
Merge reported by: | Otto Co-Pilot | ||||
Merged at revision: | not available | ||||
Proposed branch: | ~cjwatson/launchpad:oauthlib | ||||
Merge into: | launchpad:master | ||||
Diff against target: |
750 lines (+44/-587) 6 files modified
dev/null (+0/-536) lib/lp/services/webapp/authentication.py (+12/-5) lib/lp/services/webapp/tests/test_authentication.py (+10/-8) lib/lp/services/webapp/tests/test_publication.py (+11/-16) lib/lp/testing/pages.py (+10/-22) setup.py (+1/-0) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Tom Wardill (community) | Approve | ||
Review via email: mp+393828@code.launchpad.net |
Commit message
Port from contrib.oauth to oauthlib
Description of the change
Let's prefer code that somebody else is maintaining.
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/contrib/oauth.py b/lib/contrib/oauth.py | |||
2 | 0 | deleted file mode 100644 | 0 | deleted file mode 100644 |
3 | index 2d11e6c..0000000 | |||
4 | --- a/lib/contrib/oauth.py | |||
5 | +++ /dev/null | |||
6 | @@ -1,536 +0,0 @@ | |||
7 | 1 | import base64 | ||
8 | 2 | import hmac | ||
9 | 3 | import random | ||
10 | 4 | import time | ||
11 | 5 | |||
12 | 6 | import six | ||
13 | 7 | from six.moves.urllib.parse import ( | ||
14 | 8 | parse_qs, | ||
15 | 9 | quote, | ||
16 | 10 | unquote, | ||
17 | 11 | urlencode, | ||
18 | 12 | urlparse, | ||
19 | 13 | ) | ||
20 | 14 | |||
21 | 15 | |||
22 | 16 | VERSION = '1.0' # Hi Blaine! | ||
23 | 17 | HTTP_METHOD = 'GET' | ||
24 | 18 | SIGNATURE_METHOD = 'PLAINTEXT' | ||
25 | 19 | |||
26 | 20 | # Generic exception class | ||
27 | 21 | class OAuthError(RuntimeError): | ||
28 | 22 | def __init__(self, message='OAuth error occured'): | ||
29 | 23 | self.message = message | ||
30 | 24 | |||
31 | 25 | # optional WWW-Authenticate header (401 error) | ||
32 | 26 | def build_authenticate_header(realm=''): | ||
33 | 27 | return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} | ||
34 | 28 | |||
35 | 29 | # url escape | ||
36 | 30 | def escape(s): | ||
37 | 31 | # escape '/' too | ||
38 | 32 | return quote(s, safe='~') | ||
39 | 33 | |||
40 | 34 | # util function: current timestamp | ||
41 | 35 | # seconds since epoch (UTC) | ||
42 | 36 | def generate_timestamp(): | ||
43 | 37 | return int(time.time()) | ||
44 | 38 | |||
45 | 39 | # util function: nonce | ||
46 | 40 | # pseudorandom number | ||
47 | 41 | def generate_nonce(length=8): | ||
48 | 42 | return ''.join(str(random.randint(0, 9)) for i in range(length)) | ||
49 | 43 | |||
50 | 44 | # OAuthConsumer is a data type that represents the identity of the Consumer | ||
51 | 45 | # via its shared secret with the Service Provider. | ||
52 | 46 | class OAuthConsumer(object): | ||
53 | 47 | key = None | ||
54 | 48 | secret = None | ||
55 | 49 | |||
56 | 50 | def __init__(self, key, secret): | ||
57 | 51 | self.key = key | ||
58 | 52 | self.secret = secret | ||
59 | 53 | |||
60 | 54 | # OAuthToken is a data type that represents an End User via either an access | ||
61 | 55 | # or request token. | ||
62 | 56 | class OAuthToken(object): | ||
63 | 57 | # access tokens and request tokens | ||
64 | 58 | key = None | ||
65 | 59 | secret = None | ||
66 | 60 | |||
67 | 61 | ''' | ||
68 | 62 | key = the token | ||
69 | 63 | secret = the token secret | ||
70 | 64 | ''' | ||
71 | 65 | def __init__(self, key, secret): | ||
72 | 66 | self.key = key | ||
73 | 67 | self.secret = secret | ||
74 | 68 | |||
75 | 69 | def to_string(self): | ||
76 | 70 | return urlencode({'oauth_token': self.key, 'oauth_token_secret': self.secret}) | ||
77 | 71 | |||
78 | 72 | # return a token from something like: | ||
79 | 73 | # oauth_token_secret=digg&oauth_token=digg | ||
80 | 74 | @staticmethod | ||
81 | 75 | def from_string(s): | ||
82 | 76 | params = parse_qs(s, keep_blank_values=False) | ||
83 | 77 | key = params['oauth_token'][0] | ||
84 | 78 | secret = params['oauth_token_secret'][0] | ||
85 | 79 | return OAuthToken(key, secret) | ||
86 | 80 | |||
87 | 81 | def __str__(self): | ||
88 | 82 | return self.to_string() | ||
89 | 83 | |||
90 | 84 | # OAuthRequest represents the request and can be serialized | ||
91 | 85 | class OAuthRequest(object): | ||
92 | 86 | ''' | ||
93 | 87 | OAuth parameters: | ||
94 | 88 | - oauth_consumer_key | ||
95 | 89 | - oauth_token | ||
96 | 90 | - oauth_signature_method | ||
97 | 91 | - oauth_signature | ||
98 | 92 | - oauth_timestamp | ||
99 | 93 | - oauth_nonce | ||
100 | 94 | - oauth_version | ||
101 | 95 | ... any additional parameters, as defined by the Service Provider. | ||
102 | 96 | ''' | ||
103 | 97 | parameters = None # oauth parameters | ||
104 | 98 | http_method = HTTP_METHOD | ||
105 | 99 | http_url = None | ||
106 | 100 | version = VERSION | ||
107 | 101 | |||
108 | 102 | def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None): | ||
109 | 103 | self.http_method = http_method | ||
110 | 104 | self.http_url = http_url | ||
111 | 105 | self.parameters = parameters or {} | ||
112 | 106 | |||
113 | 107 | def set_parameter(self, parameter, value): | ||
114 | 108 | self.parameters[parameter] = value | ||
115 | 109 | |||
116 | 110 | def get_parameter(self, parameter): | ||
117 | 111 | try: | ||
118 | 112 | return self.parameters[parameter] | ||
119 | 113 | except: | ||
120 | 114 | raise OAuthError('Parameter not found: %s' % parameter) | ||
121 | 115 | |||
122 | 116 | def _get_timestamp_nonce(self): | ||
123 | 117 | return self.get_parameter('oauth_timestamp'), self.get_parameter('oauth_nonce') | ||
124 | 118 | |||
125 | 119 | # get any non-oauth parameters | ||
126 | 120 | def get_nonoauth_parameters(self): | ||
127 | 121 | parameters = {} | ||
128 | 122 | for k, v in six.iteritems(self.parameters): | ||
129 | 123 | # ignore oauth parameters | ||
130 | 124 | if k.find('oauth_') < 0: | ||
131 | 125 | parameters[k] = v | ||
132 | 126 | return parameters | ||
133 | 127 | |||
134 | 128 | # serialize as a header for an HTTPAuth request | ||
135 | 129 | def to_header(self, realm=''): | ||
136 | 130 | auth_header = 'OAuth realm="%s"' % realm | ||
137 | 131 | # add the oauth parameters | ||
138 | 132 | if self.parameters: | ||
139 | 133 | for k, v in six.iteritems(self.parameters): | ||
140 | 134 | auth_header += ', %s="%s"' % (k, v) | ||
141 | 135 | return {'Authorization': auth_header} | ||
142 | 136 | |||
143 | 137 | # serialize as post data for a POST request | ||
144 | 138 | def to_postdata(self): | ||
145 | 139 | return '&'.join( | ||
146 | 140 | '%s=%s' % (escape(str(k)), escape(str(v))) | ||
147 | 141 | for k, v in six.iteritems(self.parameters)) | ||
148 | 142 | |||
149 | 143 | # serialize as a url for a GET request | ||
150 | 144 | def to_url(self): | ||
151 | 145 | return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata()) | ||
152 | 146 | |||
153 | 147 | # return a string that consists of all the parameters that need to be signed | ||
154 | 148 | def get_normalized_parameters(self): | ||
155 | 149 | params = self.parameters | ||
156 | 150 | try: | ||
157 | 151 | # exclude the signature if it exists | ||
158 | 152 | del params['oauth_signature'] | ||
159 | 153 | except: | ||
160 | 154 | pass | ||
161 | 155 | key_values = params.items() | ||
162 | 156 | # sort lexicographically, first after key, then after value | ||
163 | 157 | key_values.sort() | ||
164 | 158 | # combine key value pairs in string and escape | ||
165 | 159 | return '&'.join('%s=%s' % (escape(str(k)), escape(str(v))) for k, v in key_values) | ||
166 | 160 | |||
167 | 161 | # just uppercases the http method | ||
168 | 162 | def get_normalized_http_method(self): | ||
169 | 163 | return self.http_method.upper() | ||
170 | 164 | |||
171 | 165 | # parses the url and rebuilds it to be scheme://host/path | ||
172 | 166 | def get_normalized_http_url(self): | ||
173 | 167 | parts = urlparse(self.http_url) | ||
174 | 168 | url_string = '%s://%s%s' % (parts[0], parts[1], parts[2]) # scheme, netloc, path | ||
175 | 169 | return url_string | ||
176 | 170 | |||
177 | 171 | # set the signature parameter to the result of build_signature | ||
178 | 172 | def sign_request(self, signature_method, consumer, token): | ||
179 | 173 | # set the signature method | ||
180 | 174 | self.set_parameter('oauth_signature_method', signature_method.get_name()) | ||
181 | 175 | # set the signature | ||
182 | 176 | self.set_parameter('oauth_signature', self.build_signature(signature_method, consumer, token)) | ||
183 | 177 | |||
184 | 178 | def build_signature(self, signature_method, consumer, token): | ||
185 | 179 | # call the build signature method within the signature method | ||
186 | 180 | return signature_method.build_signature(self, consumer, token) | ||
187 | 181 | |||
188 | 182 | @staticmethod | ||
189 | 183 | def from_request(http_method, http_url, headers=None, postdata=None, parameters=None): | ||
190 | 184 | |||
191 | 185 | # let the library user override things however they'd like, if they know | ||
192 | 186 | # which parameters to use then go for it, for example XMLRPC might want to | ||
193 | 187 | # do this | ||
194 | 188 | if parameters is not None: | ||
195 | 189 | return OAuthRequest(http_method, http_url, parameters) | ||
196 | 190 | |||
197 | 191 | # from the headers | ||
198 | 192 | if headers is not None: | ||
199 | 193 | try: | ||
200 | 194 | auth_header = headers['Authorization'] | ||
201 | 195 | # check that the authorization header is OAuth | ||
202 | 196 | auth_header.index('OAuth') | ||
203 | 197 | # get the parameters from the header | ||
204 | 198 | parameters = OAuthRequest._split_header(auth_header) | ||
205 | 199 | return OAuthRequest(http_method, http_url, parameters) | ||
206 | 200 | except: | ||
207 | 201 | raise OAuthError('Unable to parse OAuth parameters from Authorization header.') | ||
208 | 202 | |||
209 | 203 | # from the parameter string (post body) | ||
210 | 204 | if http_method == 'POST' and postdata is not None: | ||
211 | 205 | parameters = OAuthRequest._split_url_string(postdata) | ||
212 | 206 | |||
213 | 207 | # from the url string | ||
214 | 208 | elif http_method == 'GET': | ||
215 | 209 | param_str = urlparse(http_url).query | ||
216 | 210 | parameters = OAuthRequest._split_url_string(param_str) | ||
217 | 211 | |||
218 | 212 | if parameters: | ||
219 | 213 | return OAuthRequest(http_method, http_url, parameters) | ||
220 | 214 | |||
221 | 215 | raise OAuthError('Missing all OAuth parameters. OAuth parameters must be in the headers, post body, or url.') | ||
222 | 216 | |||
223 | 217 | @staticmethod | ||
224 | 218 | def from_consumer_and_token(oauth_consumer, token=None, http_method=HTTP_METHOD, http_url=None, parameters=None): | ||
225 | 219 | if not parameters: | ||
226 | 220 | parameters = {} | ||
227 | 221 | |||
228 | 222 | defaults = { | ||
229 | 223 | 'oauth_consumer_key': oauth_consumer.key, | ||
230 | 224 | 'oauth_timestamp': generate_timestamp(), | ||
231 | 225 | 'oauth_nonce': generate_nonce(), | ||
232 | 226 | 'oauth_version': OAuthRequest.version, | ||
233 | 227 | } | ||
234 | 228 | |||
235 | 229 | defaults.update(parameters) | ||
236 | 230 | parameters = defaults | ||
237 | 231 | |||
238 | 232 | if token: | ||
239 | 233 | parameters['oauth_token'] = token.key | ||
240 | 234 | |||
241 | 235 | return OAuthRequest(http_method, http_url, parameters) | ||
242 | 236 | |||
243 | 237 | @staticmethod | ||
244 | 238 | def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, http_url=None, parameters=None): | ||
245 | 239 | if not parameters: | ||
246 | 240 | parameters = {} | ||
247 | 241 | |||
248 | 242 | parameters['oauth_token'] = token.key | ||
249 | 243 | |||
250 | 244 | if callback: | ||
251 | 245 | parameters['oauth_callback'] = escape(callback) | ||
252 | 246 | |||
253 | 247 | return OAuthRequest(http_method, http_url, parameters) | ||
254 | 248 | |||
255 | 249 | # util function: turn Authorization: header into parameters, has to do some unescaping | ||
256 | 250 | @staticmethod | ||
257 | 251 | def _split_header(header): | ||
258 | 252 | params = {} | ||
259 | 253 | header = header.lstrip() | ||
260 | 254 | if not header.startswith('OAuth '): | ||
261 | 255 | raise ValueError("not an OAuth header: %r" % header) | ||
262 | 256 | header = header[6:] | ||
263 | 257 | parts = header.split(',') | ||
264 | 258 | for param in parts: | ||
265 | 259 | # remove whitespace | ||
266 | 260 | param = param.strip() | ||
267 | 261 | # split key-value | ||
268 | 262 | param_parts = param.split('=', 1) | ||
269 | 263 | if param_parts[0] == 'realm': | ||
270 | 264 | # Realm header is not an OAuth parameter according to rfc5849 | ||
271 | 265 | # section 3.4.1.3.1. | ||
272 | 266 | continue | ||
273 | 267 | # remove quotes and unescape the value | ||
274 | 268 | params[param_parts[0]] = unquote(param_parts[1].strip('\"')) | ||
275 | 269 | return params | ||
276 | 270 | |||
277 | 271 | # util function: turn url string into parameters, has to do some unescaping | ||
278 | 272 | @staticmethod | ||
279 | 273 | def _split_url_string(param_str): | ||
280 | 274 | parameters = parse_qs(param_str, keep_blank_values=False) | ||
281 | 275 | for k, v in six.iteritems(parameters): | ||
282 | 276 | parameters[k] = unquote(v[0]) | ||
283 | 277 | return parameters | ||
284 | 278 | |||
285 | 279 | # OAuthServer is a worker to check a requests validity against a data store | ||
286 | 280 | class OAuthServer(object): | ||
287 | 281 | timestamp_threshold = 300 # in seconds, five minutes | ||
288 | 282 | version = VERSION | ||
289 | 283 | signature_methods = None | ||
290 | 284 | data_store = None | ||
291 | 285 | |||
292 | 286 | def __init__(self, data_store=None, signature_methods=None): | ||
293 | 287 | self.data_store = data_store | ||
294 | 288 | self.signature_methods = signature_methods or {} | ||
295 | 289 | |||
296 | 290 | def set_data_store(self, oauth_data_store): | ||
297 | 291 | self.data_store = oauth_data_store | ||
298 | 292 | |||
299 | 293 | def get_data_store(self): | ||
300 | 294 | return self.data_store | ||
301 | 295 | |||
302 | 296 | def add_signature_method(self, signature_method): | ||
303 | 297 | self.signature_methods[signature_method.get_name()] = signature_method | ||
304 | 298 | return self.signature_methods | ||
305 | 299 | |||
306 | 300 | # process a request_token request | ||
307 | 301 | # returns the request token on success | ||
308 | 302 | def fetch_request_token(self, oauth_request): | ||
309 | 303 | try: | ||
310 | 304 | # get the request token for authorization | ||
311 | 305 | token = self._get_token(oauth_request, 'request') | ||
312 | 306 | except: | ||
313 | 307 | # no token required for the initial token request | ||
314 | 308 | version = self._get_version(oauth_request) | ||
315 | 309 | consumer = self._get_consumer(oauth_request) | ||
316 | 310 | self._check_signature(oauth_request, consumer, None) | ||
317 | 311 | # fetch a new token | ||
318 | 312 | token = self.data_store.fetch_request_token(consumer) | ||
319 | 313 | return token | ||
320 | 314 | |||
321 | 315 | # process an access_token request | ||
322 | 316 | # returns the access token on success | ||
323 | 317 | def fetch_access_token(self, oauth_request): | ||
324 | 318 | version = self._get_version(oauth_request) | ||
325 | 319 | consumer = self._get_consumer(oauth_request) | ||
326 | 320 | # get the request token | ||
327 | 321 | token = self._get_token(oauth_request, 'request') | ||
328 | 322 | self._check_signature(oauth_request, consumer, token) | ||
329 | 323 | new_token = self.data_store.fetch_access_token(consumer, token) | ||
330 | 324 | return new_token | ||
331 | 325 | |||
332 | 326 | # verify an api call, checks all the parameters | ||
333 | 327 | def verify_request(self, oauth_request): | ||
334 | 328 | # -> consumer and token | ||
335 | 329 | version = self._get_version(oauth_request) | ||
336 | 330 | consumer = self._get_consumer(oauth_request) | ||
337 | 331 | # get the access token | ||
338 | 332 | token = self._get_token(oauth_request, 'access') | ||
339 | 333 | self._check_signature(oauth_request, consumer, token) | ||
340 | 334 | parameters = oauth_request.get_nonoauth_parameters() | ||
341 | 335 | return consumer, token, parameters | ||
342 | 336 | |||
343 | 337 | # authorize a request token | ||
344 | 338 | def authorize_token(self, token, user): | ||
345 | 339 | return self.data_store.authorize_request_token(token, user) | ||
346 | 340 | |||
347 | 341 | # get the callback url | ||
348 | 342 | def get_callback(self, oauth_request): | ||
349 | 343 | return oauth_request.get_parameter('oauth_callback') | ||
350 | 344 | |||
351 | 345 | # optional support for the authenticate header | ||
352 | 346 | def build_authenticate_header(self, realm=''): | ||
353 | 347 | return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} | ||
354 | 348 | |||
355 | 349 | # verify the correct version request for this server | ||
356 | 350 | def _get_version(self, oauth_request): | ||
357 | 351 | try: | ||
358 | 352 | version = oauth_request.get_parameter('oauth_version') | ||
359 | 353 | except: | ||
360 | 354 | version = VERSION | ||
361 | 355 | if version and version != self.version: | ||
362 | 356 | raise OAuthError('OAuth version %s not supported' % str(version)) | ||
363 | 357 | return version | ||
364 | 358 | |||
365 | 359 | # figure out the signature with some defaults | ||
366 | 360 | def _get_signature_method(self, oauth_request): | ||
367 | 361 | try: | ||
368 | 362 | signature_method = oauth_request.get_parameter('oauth_signature_method') | ||
369 | 363 | except: | ||
370 | 364 | signature_method = SIGNATURE_METHOD | ||
371 | 365 | try: | ||
372 | 366 | # get the signature method object | ||
373 | 367 | signature_method = self.signature_methods[signature_method] | ||
374 | 368 | except: | ||
375 | 369 | signature_method_names = ', '.join(self.signature_methods.keys()) | ||
376 | 370 | raise OAuthError('Signature method %s not supported try one of the following: %s' % (signature_method, signature_method_names)) | ||
377 | 371 | |||
378 | 372 | return signature_method | ||
379 | 373 | |||
380 | 374 | def _get_consumer(self, oauth_request): | ||
381 | 375 | consumer_key = oauth_request.get_parameter('oauth_consumer_key') | ||
382 | 376 | if not consumer_key: | ||
383 | 377 | raise OAuthError('Invalid consumer key') | ||
384 | 378 | consumer = self.data_store.lookup_consumer(consumer_key) | ||
385 | 379 | if not consumer: | ||
386 | 380 | raise OAuthError('Invalid consumer') | ||
387 | 381 | return consumer | ||
388 | 382 | |||
389 | 383 | # try to find the token for the provided request token key | ||
390 | 384 | def _get_token(self, oauth_request, token_type='access'): | ||
391 | 385 | token_field = oauth_request.get_parameter('oauth_token') | ||
392 | 386 | token = self.data_store.lookup_token(token_type, token_field) | ||
393 | 387 | if not token: | ||
394 | 388 | raise OAuthError('Invalid %s token: %s' % (token_type, token_field)) | ||
395 | 389 | return token | ||
396 | 390 | |||
397 | 391 | def _check_signature(self, oauth_request, consumer, token): | ||
398 | 392 | timestamp, nonce = oauth_request._get_timestamp_nonce() | ||
399 | 393 | self._check_timestamp(timestamp) | ||
400 | 394 | self._check_nonce(consumer, token, nonce) | ||
401 | 395 | signature_method = self._get_signature_method(oauth_request) | ||
402 | 396 | try: | ||
403 | 397 | signature = oauth_request.get_parameter('oauth_signature') | ||
404 | 398 | except: | ||
405 | 399 | raise OAuthError('Missing signature') | ||
406 | 400 | # attempt to construct the same signature | ||
407 | 401 | built = signature_method.build_signature(oauth_request, consumer, token) | ||
408 | 402 | if signature != built: | ||
409 | 403 | key, base = signature_method.build_signature_base_string(oauth_request, consumer, token) | ||
410 | 404 | raise OAuthError('Signature does not match. Expected: %s Got: %s Expected signature base string: %s' % (built, signature, base)) | ||
411 | 405 | |||
412 | 406 | def _check_timestamp(self, timestamp): | ||
413 | 407 | # verify that timestamp is recentish | ||
414 | 408 | timestamp = int(timestamp) | ||
415 | 409 | now = int(time.time()) | ||
416 | 410 | lapsed = now - timestamp | ||
417 | 411 | if lapsed > self.timestamp_threshold: | ||
418 | 412 | raise OAuthError('Expired timestamp: given %d and now %s has a greater difference than threshold %d' % (timestamp, now, self.timestamp_threshold)) | ||
419 | 413 | |||
420 | 414 | def _check_nonce(self, consumer, token, nonce): | ||
421 | 415 | # verify that the nonce is uniqueish | ||
422 | 416 | try: | ||
423 | 417 | self.data_store.lookup_nonce(consumer, token, nonce) | ||
424 | 418 | raise OAuthError('Nonce already used: %s' % str(nonce)) | ||
425 | 419 | except: | ||
426 | 420 | pass | ||
427 | 421 | |||
428 | 422 | # OAuthClient is a worker to attempt to execute a request | ||
429 | 423 | class OAuthClient(object): | ||
430 | 424 | consumer = None | ||
431 | 425 | token = None | ||
432 | 426 | |||
433 | 427 | def __init__(self, oauth_consumer, oauth_token): | ||
434 | 428 | self.consumer = oauth_consumer | ||
435 | 429 | self.token = oauth_token | ||
436 | 430 | |||
437 | 431 | def get_consumer(self): | ||
438 | 432 | return self.consumer | ||
439 | 433 | |||
440 | 434 | def get_token(self): | ||
441 | 435 | return self.token | ||
442 | 436 | |||
443 | 437 | def fetch_request_token(self, oauth_request): | ||
444 | 438 | # -> OAuthToken | ||
445 | 439 | raise NotImplementedError | ||
446 | 440 | |||
447 | 441 | def fetch_access_token(self, oauth_request): | ||
448 | 442 | # -> OAuthToken | ||
449 | 443 | raise NotImplementedError | ||
450 | 444 | |||
451 | 445 | def access_resource(self, oauth_request): | ||
452 | 446 | # -> some protected resource | ||
453 | 447 | raise NotImplementedError | ||
454 | 448 | |||
455 | 449 | # OAuthDataStore is a database abstraction used to lookup consumers and tokens | ||
456 | 450 | class OAuthDataStore(object): | ||
457 | 451 | |||
458 | 452 | def lookup_consumer(self, key): | ||
459 | 453 | # -> OAuthConsumer | ||
460 | 454 | raise NotImplementedError | ||
461 | 455 | |||
462 | 456 | def lookup_token(self, oauth_consumer, token_type, token_token): | ||
463 | 457 | # -> OAuthToken | ||
464 | 458 | raise NotImplementedError | ||
465 | 459 | |||
466 | 460 | def lookup_nonce(self, oauth_consumer, oauth_token, nonce, timestamp): | ||
467 | 461 | # -> OAuthToken | ||
468 | 462 | raise NotImplementedError | ||
469 | 463 | |||
470 | 464 | def fetch_request_token(self, oauth_consumer): | ||
471 | 465 | # -> OAuthToken | ||
472 | 466 | raise NotImplementedError | ||
473 | 467 | |||
474 | 468 | def fetch_access_token(self, oauth_consumer, oauth_token): | ||
475 | 469 | # -> OAuthToken | ||
476 | 470 | raise NotImplementedError | ||
477 | 471 | |||
478 | 472 | def authorize_request_token(self, oauth_token, user): | ||
479 | 473 | # -> OAuthToken | ||
480 | 474 | raise NotImplementedError | ||
481 | 475 | |||
482 | 476 | # OAuthSignatureMethod is a strategy class that implements a signature method | ||
483 | 477 | class OAuthSignatureMethod(object): | ||
484 | 478 | def get_name(): | ||
485 | 479 | # -> str | ||
486 | 480 | raise NotImplementedError | ||
487 | 481 | |||
488 | 482 | def build_signature_base_string(oauth_request, oauth_consumer, oauth_token): | ||
489 | 483 | # -> str key, str raw | ||
490 | 484 | raise NotImplementedError | ||
491 | 485 | |||
492 | 486 | def build_signature(oauth_request, oauth_consumer, oauth_token): | ||
493 | 487 | # -> str | ||
494 | 488 | raise NotImplementedError | ||
495 | 489 | |||
496 | 490 | class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod): | ||
497 | 491 | |||
498 | 492 | def get_name(self): | ||
499 | 493 | return 'HMAC-SHA1' | ||
500 | 494 | |||
501 | 495 | def build_signature_base_string(self, oauth_request, consumer, token): | ||
502 | 496 | sig = ( | ||
503 | 497 | escape(oauth_request.get_normalized_http_method()), | ||
504 | 498 | escape(oauth_request.get_normalized_http_url()), | ||
505 | 499 | escape(oauth_request.get_normalized_parameters()), | ||
506 | 500 | ) | ||
507 | 501 | |||
508 | 502 | key = '%s&' % escape(consumer.secret) | ||
509 | 503 | if token: | ||
510 | 504 | key += escape(token.secret) | ||
511 | 505 | raw = '&'.join(sig) | ||
512 | 506 | return key, raw | ||
513 | 507 | |||
514 | 508 | def build_signature(self, oauth_request, consumer, token): | ||
515 | 509 | # build the base signature string | ||
516 | 510 | key, raw = self.build_signature_base_string(oauth_request, consumer, token) | ||
517 | 511 | |||
518 | 512 | # hmac object | ||
519 | 513 | try: | ||
520 | 514 | import hashlib # 2.5 | ||
521 | 515 | hashed = hmac.new(key, raw, hashlib.sha1) | ||
522 | 516 | except: | ||
523 | 517 | import sha # deprecated | ||
524 | 518 | hashed = hmac.new(key, raw, sha) | ||
525 | 519 | |||
526 | 520 | # calculate the digest base 64 | ||
527 | 521 | return base64.b64encode(hashed.digest()) | ||
528 | 522 | |||
529 | 523 | class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod): | ||
530 | 524 | |||
531 | 525 | def get_name(self): | ||
532 | 526 | return 'PLAINTEXT' | ||
533 | 527 | |||
534 | 528 | def build_signature_base_string(self, oauth_request, consumer, token): | ||
535 | 529 | # concatenate the consumer key and secret | ||
536 | 530 | sig = escape(consumer.secret) | ||
537 | 531 | if token: | ||
538 | 532 | sig = '&'.join((sig, escape(token.secret))) | ||
539 | 533 | return sig | ||
540 | 534 | |||
541 | 535 | def build_signature(self, oauth_request, consumer, token): | ||
542 | 536 | return self.build_signature_base_string(oauth_request, consumer, token) | ||
543 | diff --git a/lib/lp/services/webapp/authentication.py b/lib/lp/services/webapp/authentication.py | |||
544 | index acab459..81a2384 100644 | |||
545 | --- a/lib/lp/services/webapp/authentication.py | |||
546 | +++ b/lib/lp/services/webapp/authentication.py | |||
547 | @@ -14,7 +14,8 @@ __all__ = [ | |||
548 | 14 | 14 | ||
549 | 15 | import binascii | 15 | import binascii |
550 | 16 | 16 | ||
552 | 17 | from contrib.oauth import OAuthRequest | 17 | from oauthlib import oauth1 |
553 | 18 | from oauthlib.oauth1.rfc5849.utils import parse_authorization_header | ||
554 | 18 | import six | 19 | import six |
555 | 19 | from zope.authentication.interfaces import ILoginPassword | 20 | from zope.authentication.interfaces import ILoginPassword |
556 | 20 | from zope.component import getUtility | 21 | from zope.component import getUtility |
557 | @@ -274,6 +275,15 @@ class LaunchpadPrincipal: | |||
558 | 274 | return self.title | 275 | return self.title |
559 | 275 | 276 | ||
560 | 276 | 277 | ||
561 | 278 | def _parse_oauth_authorization_header(header): | ||
562 | 279 | # http://oauth.net/core/1.0/#encoding_parameters says "Text names | ||
563 | 280 | # and values MUST be encoded as UTF-8 octets before percent-encoding | ||
564 | 281 | # them", so we can reasonably fail if this hasn't been done. | ||
565 | 282 | return dict(oauth1.rfc5849.signature.collect_parameters( | ||
566 | 283 | headers={"Authorization": six.ensure_text(header)}, | ||
567 | 284 | exclude_oauth_signature=False)) | ||
568 | 285 | |||
569 | 286 | |||
570 | 277 | def get_oauth_authorization(request): | 287 | def get_oauth_authorization(request): |
571 | 278 | """Retrieve OAuth authorization information from a request. | 288 | """Retrieve OAuth authorization information from a request. |
572 | 279 | 289 | ||
573 | @@ -286,10 +296,7 @@ def get_oauth_authorization(request): | |||
574 | 286 | """ | 296 | """ |
575 | 287 | header = request._auth | 297 | header = request._auth |
576 | 288 | if header is not None and header.startswith("OAuth "): | 298 | if header is not None and header.startswith("OAuth "): |
581 | 289 | # http://oauth.net/core/1.0/#encoding_parameters says "Text names | 299 | return _parse_oauth_authorization_header(header) |
578 | 290 | # and values MUST be encoded as UTF-8 octets before percent-encoding | ||
579 | 291 | # them", so we can reasonably fail if this hasn't been done. | ||
580 | 292 | return OAuthRequest._split_header(six.ensure_text(header)) | ||
582 | 293 | else: | 300 | else: |
583 | 294 | return request.form | 301 | return request.form |
584 | 295 | 302 | ||
585 | diff --git a/lib/lp/services/webapp/tests/test_authentication.py b/lib/lp/services/webapp/tests/test_authentication.py | |||
586 | index d69e248..a348b0b 100644 | |||
587 | --- a/lib/lp/services/webapp/tests/test_authentication.py | |||
588 | +++ b/lib/lp/services/webapp/tests/test_authentication.py | |||
589 | @@ -8,9 +8,10 @@ __metaclass__ = type | |||
590 | 8 | 8 | ||
591 | 9 | import unittest | 9 | import unittest |
592 | 10 | 10 | ||
596 | 11 | from contrib.oauth import OAuthRequest | 11 | from lp.services.webapp.authentication import ( |
597 | 12 | 12 | _parse_oauth_authorization_header, | |
598 | 13 | from lp.services.webapp.authentication import check_oauth_signature | 13 | check_oauth_signature, |
599 | 14 | ) | ||
600 | 14 | from lp.services.webapp.servers import LaunchpadTestRequest | 15 | from lp.services.webapp.servers import LaunchpadTestRequest |
601 | 15 | from lp.testing import ( | 16 | from lp.testing import ( |
602 | 16 | TestCase, | 17 | TestCase, |
603 | @@ -31,20 +32,21 @@ class TestOAuthParsing(TestCase): | |||
604 | 31 | 32 | ||
605 | 32 | def test_split_oauth(self): | 33 | def test_split_oauth(self): |
606 | 33 | # OAuth headers are parsed correctly: see bug 314507. | 34 | # OAuth headers are parsed correctly: see bug 314507. |
609 | 34 | # This was really a bug in the underlying contrib/oauth.py module, but | 35 | # This was originally a bug in the underlying contrib/oauth.py |
610 | 35 | # it has no standalone test case. | 36 | # module, but it's useful to test that parsing works as we expect |
611 | 37 | # for whatever OAuth library we're currently using. | ||
612 | 36 | # | 38 | # |
613 | 37 | # Note that the 'realm' parameter is not returned, because it's not | 39 | # Note that the 'realm' parameter is not returned, because it's not |
614 | 38 | # included in the OAuth calculations. | 40 | # included in the OAuth calculations. |
616 | 39 | headers = OAuthRequest._split_header( | 41 | headers = _parse_oauth_authorization_header( |
617 | 40 | 'OAuth realm="foo", oauth_consumer_key="justtesting"') | 42 | 'OAuth realm="foo", oauth_consumer_key="justtesting"') |
618 | 41 | self.assertEqual(headers, | 43 | self.assertEqual(headers, |
619 | 42 | {'oauth_consumer_key': 'justtesting'}) | 44 | {'oauth_consumer_key': 'justtesting'}) |
621 | 43 | headers = OAuthRequest._split_header( | 45 | headers = _parse_oauth_authorization_header( |
622 | 44 | 'OAuth oauth_consumer_key="justtesting"') | 46 | 'OAuth oauth_consumer_key="justtesting"') |
623 | 45 | self.assertEqual(headers, | 47 | self.assertEqual(headers, |
624 | 46 | {'oauth_consumer_key': 'justtesting'}) | 48 | {'oauth_consumer_key': 'justtesting'}) |
626 | 47 | headers = OAuthRequest._split_header( | 49 | headers = _parse_oauth_authorization_header( |
627 | 48 | 'OAuth oauth_consumer_key="justtesting", realm="realm"') | 50 | 'OAuth oauth_consumer_key="justtesting", realm="realm"') |
628 | 49 | self.assertEqual(headers, | 51 | self.assertEqual(headers, |
629 | 50 | {'oauth_consumer_key': 'justtesting'}) | 52 | {'oauth_consumer_key': 'justtesting'}) |
630 | diff --git a/lib/lp/services/webapp/tests/test_publication.py b/lib/lp/services/webapp/tests/test_publication.py | |||
631 | index a2a4953..7a539d4 100644 | |||
632 | --- a/lib/lp/services/webapp/tests/test_publication.py | |||
633 | +++ b/lib/lp/services/webapp/tests/test_publication.py | |||
634 | @@ -7,13 +7,8 @@ __metaclass__ = type | |||
635 | 7 | 7 | ||
636 | 8 | import sys | 8 | import sys |
637 | 9 | 9 | ||
638 | 10 | from contrib.oauth import ( | ||
639 | 11 | OAuthConsumer, | ||
640 | 12 | OAuthRequest, | ||
641 | 13 | OAuthSignatureMethod_PLAINTEXT, | ||
642 | 14 | OAuthToken, | ||
643 | 15 | ) | ||
644 | 16 | from fixtures import FakeLogger | 10 | from fixtures import FakeLogger |
645 | 11 | from oauthlib import oauth1 | ||
646 | 17 | from storm.database import ( | 12 | from storm.database import ( |
647 | 18 | STATE_DISCONNECTED, | 13 | STATE_DISCONNECTED, |
648 | 19 | STATE_RECONNECT, | 14 | STATE_RECONNECT, |
649 | @@ -115,16 +110,16 @@ class TestWebServicePublication(TestCaseWithFactory): | |||
650 | 115 | person, permission=OAuthPermission.READ_PUBLIC, context=None) | 110 | person, permission=OAuthPermission.READ_PUBLIC, context=None) |
651 | 116 | access_token, access_secret = request_token.createAccessToken() | 111 | access_token, access_secret = request_token.createAccessToken() |
652 | 117 | 112 | ||
663 | 118 | # Use oauth.OAuthRequest just to generate a dictionary containing all | 113 | # Make an OAuth signature using the access token we just created for |
664 | 119 | # the parameters we need to use in a valid OAuth request, using the | 114 | # our new person. |
665 | 120 | # access token we just created for our new person. | 115 | client = oauth1.Client( |
666 | 121 | oauth_consumer = OAuthConsumer(consumer.key, u'') | 116 | consumer.key, |
667 | 122 | oauth_token = OAuthToken(access_token.key, access_secret) | 117 | resource_owner_key=access_token.key, |
668 | 123 | oauth_request = OAuthRequest.from_consumer_and_token( | 118 | resource_owner_secret=access_secret, |
669 | 124 | oauth_consumer, oauth_token) | 119 | signature_method=oauth1.SIGNATURE_PLAINTEXT) |
670 | 125 | oauth_request.sign_request( | 120 | _, headers, _ = client.sign('/dummy') |
671 | 126 | OAuthSignatureMethod_PLAINTEXT(), oauth_consumer, oauth_token) | 121 | return LaunchpadTestRequest( |
672 | 127 | return LaunchpadTestRequest(form=oauth_request.parameters) | 122 | environ={'HTTP_AUTHORIZATION': headers['Authorization']}) |
673 | 128 | 123 | ||
674 | 129 | def test_getPrincipal_for_person_and_account_with_different_ids(self): | 124 | def test_getPrincipal_for_person_and_account_with_different_ids(self): |
675 | 130 | # WebServicePublication.getPrincipal() does not rely on accounts | 125 | # WebServicePublication.getPrincipal() does not rely on accounts |
676 | diff --git a/lib/lp/testing/pages.py b/lib/lp/testing/pages.py | |||
677 | index 71045f0..f0d37ed 100644 | |||
678 | --- a/lib/lp/testing/pages.py | |||
679 | +++ b/lib/lp/testing/pages.py | |||
680 | @@ -27,13 +27,8 @@ from bs4.element import ( | |||
681 | 27 | ProcessingInstruction, | 27 | ProcessingInstruction, |
682 | 28 | Tag, | 28 | Tag, |
683 | 29 | ) | 29 | ) |
684 | 30 | from contrib.oauth import ( | ||
685 | 31 | OAuthConsumer, | ||
686 | 32 | OAuthRequest, | ||
687 | 33 | OAuthSignatureMethod_PLAINTEXT, | ||
688 | 34 | OAuthToken, | ||
689 | 35 | ) | ||
690 | 36 | from lazr.restful.testing.webservice import WebServiceCaller | 30 | from lazr.restful.testing.webservice import WebServiceCaller |
691 | 31 | from oauthlib import oauth1 | ||
692 | 37 | import six | 32 | import six |
693 | 38 | from six.moves.urllib.parse import urljoin | 33 | from six.moves.urllib.parse import urljoin |
694 | 39 | from soupsieve import escape as css_escape | 34 | from soupsieve import escape as css_escape |
695 | @@ -147,20 +142,17 @@ class LaunchpadWebServiceCaller(WebServiceCaller): | |||
696 | 147 | calls. | 142 | calls. |
697 | 148 | """ | 143 | """ |
698 | 149 | if oauth_consumer_key is not None and oauth_access_key is not None: | 144 | if oauth_consumer_key is not None and oauth_access_key is not None: |
699 | 150 | # XXX cjwatson 2016-01-25: Callers should be updated to pass | ||
700 | 151 | # Unicode directly, but that's a big change. | ||
701 | 152 | oauth_consumer_key = six.ensure_text(oauth_consumer_key) | ||
702 | 153 | self.consumer = OAuthConsumer(oauth_consumer_key, u'') | ||
703 | 154 | if oauth_access_secret is None: | 145 | if oauth_access_secret is None: |
704 | 155 | oauth_access_secret = SAMPLEDATA_ACCESS_SECRETS.get( | 146 | oauth_access_secret = SAMPLEDATA_ACCESS_SECRETS.get( |
705 | 156 | oauth_access_key, u'') | 147 | oauth_access_key, u'') |
709 | 157 | self.access_token = OAuthToken( | 148 | self.oauth_client = oauth1.Client( |
710 | 158 | oauth_access_key, oauth_access_secret) | 149 | oauth_consumer_key, |
711 | 159 | # This shouldn't be here, but many old tests expect it. | 150 | resource_owner_key=oauth_access_key, |
712 | 151 | resource_owner_secret=oauth_access_secret, | ||
713 | 152 | signature_method=oauth1.SIGNATURE_PLAINTEXT) | ||
714 | 160 | logout() | 153 | logout() |
715 | 161 | else: | 154 | else: |
718 | 162 | self.consumer = None | 155 | self.oauth_client = None |
717 | 163 | self.access_token = None | ||
719 | 164 | self.handle_errors = handle_errors | 156 | self.handle_errors = handle_errors |
720 | 165 | if default_api_version is not None: | 157 | if default_api_version is not None: |
721 | 166 | self.default_api_version = default_api_version | 158 | self.default_api_version = default_api_version |
722 | @@ -169,13 +161,9 @@ class LaunchpadWebServiceCaller(WebServiceCaller): | |||
723 | 169 | default_api_version = "beta" | 161 | default_api_version = "beta" |
724 | 170 | 162 | ||
725 | 171 | def addHeadersTo(self, full_url, full_headers): | 163 | def addHeadersTo(self, full_url, full_headers): |
733 | 172 | if self.consumer is not None and self.access_token is not None: | 164 | if self.oauth_client is not None: |
734 | 173 | request = OAuthRequest.from_consumer_and_token( | 165 | _, oauth_headers, _ = self.oauth_client.sign( |
735 | 174 | self.consumer, self.access_token, http_url=full_url) | 166 | full_url, realm=OAUTH_REALM) |
729 | 175 | request.sign_request( | ||
730 | 176 | OAuthSignatureMethod_PLAINTEXT(), self.consumer, | ||
731 | 177 | self.access_token) | ||
732 | 178 | oauth_headers = request.to_header(OAUTH_REALM) | ||
736 | 179 | full_headers.update({ | 167 | full_headers.update({ |
737 | 180 | wsgi_native_string(key): wsgi_native_string(value) | 168 | wsgi_native_string(key): wsgi_native_string(value) |
738 | 181 | for key, value in oauth_headers.items()}) | 169 | for key, value in oauth_headers.items()}) |
739 | diff --git a/setup.py b/setup.py | |||
740 | index f6d5b6b..173e1c9 100644 | |||
741 | --- a/setup.py | |||
742 | +++ b/setup.py | |||
743 | @@ -199,6 +199,7 @@ setup( | |||
744 | 199 | 'meliae', | 199 | 'meliae', |
745 | 200 | 'mock', | 200 | 'mock', |
746 | 201 | 'oauth', | 201 | 'oauth', |
747 | 202 | 'oauthlib', | ||
748 | 202 | 'oops', | 203 | 'oops', |
749 | 203 | 'oops_amqp', | 204 | 'oops_amqp', |
750 | 204 | 'oops_datedir_repo', | 205 | 'oops_datedir_repo', |
This is a good amount of red.