1 """
2 The MIT License
3
4 Copyright (c) 2007-2010 Leah Culver, Joe Stump, Mark Paschal, Vic Fryzel
5
6 Permission is hereby granted, free of charge, to any person obtaining a copy
7 of this software and associated documentation files (the "Software"), to deal
8 in the Software without restriction, including without limitation the rights
9 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 copies of the Software, and to permit persons to whom the Software is
11 furnished to do so, subject to the following conditions:
12
13 The above copyright notice and this permission notice shall be included in
14 all copies or substantial portions of the Software.
15
16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 THE SOFTWARE.
23 """
24
25 import urllib
26 import time
27 import random
28 import urlparse
29 import hmac
30 import binascii
31
32 try:
33 from urlparse import parse_qs, parse_qsl
34 except ImportError:
35 from cgi import parse_qs, parse_qsl
36
37
38 VERSION = '1.0'
39 HTTP_METHOD = 'GET'
40 SIGNATURE_METHOD = 'PLAINTEXT'
41
42
43 -class Error(RuntimeError):
44 """Generic exception class."""
45
46 - def __init__(self, message='OAuth error occurred.'):
48
49 @property
51 """A hack to get around the deprecation errors in 2.6."""
52 return self._message
53
56
60
63 """Optional WWW-Authenticate header (401 error)"""
64 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
65
68 """Build an XOAUTH string for use in SMTP/IMPA authentication."""
69 request = Request.from_consumer_and_token(consumer, token,
70 "GET", url)
71
72 signing_method = SignatureMethod_HMAC_SHA1()
73 request.sign_request(signing_method, consumer, token)
74
75 params = []
76 for k, v in sorted(request.iteritems()):
77 if v is not None:
78 params.append('%s="%s"' % (k, escape(v)))
79
80 return "%s %s %s" % ("GET", url, ','.join(params))
81
84 """Escape a URL including any /."""
85 return urllib.quote(s, safe='~')
86
89 """Get seconds since epoch (UTC)."""
90 return int(time.time())
91
94 """Generate pseudorandom number."""
95 return ''.join([str(random.randint(0, 9)) for i in range(length)])
96
99 """Generate pseudorandom number."""
100 return ''.join([str(random.randint(0, 9)) for i in range(length)])
101
104 """A consumer of OAuth-protected services.
105
106 The OAuth consumer is a "third-party" service that wants to access
107 protected resources from an OAuth service provider on behalf of an end
108 user. It's kind of the OAuth client.
109
110 Usually a consumer must be registered with the service provider by the
111 developer of the consumer software. As part of that process, the service
112 provider gives the consumer a *key* and a *secret* with which the consumer
113 software can identify itself to the service. The consumer will include its
114 key in each request to identify itself, but will use its secret only when
115 signing requests, to prove that the request is from that particular
116 registered consumer.
117
118 Once registered, the consumer can then use its consumer credentials to ask
119 the service provider for a request token, kicking off the OAuth
120 authorization process.
121 """
122
123 key = None
124 secret = None
125
127 self.key = key
128 self.secret = secret
129
130 if self.key is None or self.secret is None:
131 raise ValueError("Key and secret must be set.")
132
134 data = {'oauth_consumer_key': self.key,
135 'oauth_consumer_secret': self.secret}
136
137 return urllib.urlencode(data)
138
141 """An OAuth credential used to request authorization or a protected
142 resource.
143
144 Tokens in OAuth comprise a *key* and a *secret*. The key is included in
145 requests to identify the token being used, but the secret is used only in
146 the signature, to prove that the requester is who the server gave the
147 token to.
148
149 When first negotiating the authorization, the consumer asks for a *request
150 token* that the live user authorizes with the service provider. The
151 consumer then exchanges the request token for an *access token* that can
152 be used to access protected resources.
153 """
154
155 key = None
156 secret = None
157 callback = None
158 callback_confirmed = None
159 verifier = None
160
162 self.key = key
163 self.secret = secret
164
165 if self.key is None or self.secret is None:
166 raise ValueError("Key and secret must be set.")
167
171
177
179 if self.callback and self.verifier:
180
181 parts = urlparse.urlparse(self.callback)
182 scheme, netloc, path, params, query, fragment = parts[:6]
183 if query:
184 query = '%s&oauth_verifier=%s' % (query, self.verifier)
185 else:
186 query = 'oauth_verifier=%s' % self.verifier
187 return urlparse.urlunparse((scheme, netloc, path, params,
188 query, fragment))
189 return self.callback
190
192 """Returns this token as a plain string, suitable for storage.
193
194 The resulting string includes the token's secret, so you should never
195 send or store this string where a third party can read it.
196 """
197
198 data = {
199 'oauth_token': self.key,
200 'oauth_token_secret': self.secret,
201 }
202
203 if self.callback_confirmed is not None:
204 data['oauth_callback_confirmed'] = self.callback_confirmed
205 return urllib.urlencode(data)
206
207 @staticmethod
209 """Deserializes a token from a string like one returned by
210 `to_string()`."""
211
212 if not len(s):
213 raise ValueError("Invalid parameter string.")
214
215 params = parse_qs(s, keep_blank_values=False)
216 if not len(params):
217 raise ValueError("Invalid parameter string.")
218
219 try:
220 key = params['oauth_token'][0]
221 except Exception:
222 raise ValueError("'oauth_token' not found in OAuth request.")
223
224 try:
225 secret = params['oauth_token_secret'][0]
226 except Exception:
227 raise ValueError("'oauth_token_secret' not found in "
228 "OAuth request.")
229
230 token = Token(key, secret)
231 try:
232 token.callback_confirmed = params['oauth_callback_confirmed'][0]
233 except KeyError:
234 pass
235 return token
236
239
242 name = attr.__name__
243
244 def getter(self):
245 try:
246 return self.__dict__[name]
247 except KeyError:
248 raise AttributeError(name)
249
250 def deleter(self):
251 del self.__dict__[name]
252
253 return property(getter, attr, deleter)
254
257
258 """The parameters and information for an HTTP request, suitable for
259 authorizing with OAuth credentials.
260
261 When a consumer wants to access a service's protected resources, it does
262 so using a signed HTTP request identifying itself (the consumer) with its
263 key, and providing an access token authorized by the end user to access
264 those resources.
265
266 """
267
268 version = VERSION
269
271 self.method = method
272 self.url = url
273 if parameters is not None:
274 self.update(parameters)
275
276 @setter
277 - def url(self, value):
278 self.__dict__['url'] = value
279 if value is not None:
280 scheme, netloc, path, params, query, fragment = urlparse.urlparse(value)
281
282
283 if scheme == 'http' and netloc[-3:] == ':80':
284 netloc = netloc[:-3]
285 elif scheme == 'https' and netloc[-4:] == ':443':
286 netloc = netloc[:-4]
287 if scheme not in ('http', 'https'):
288 raise ValueError("Unsupported URL %s (%s)." % (value, scheme))
289
290
291 self.normalized_url = urlparse.urlunparse((scheme, netloc, path, None, None, None))
292 else:
293 self.normalized_url = None
294 self.__dict__['url'] = None
295
296 @setter
298 self.__dict__['method'] = value.upper()
299
301 return self['oauth_timestamp'], self['oauth_nonce']
302
304 """Get any non-OAuth parameters."""
305 return dict([(k, v) for k, v in self.iteritems()
306 if not k.startswith('oauth_')])
307
309 """Serialize as a header for an HTTPAuth request."""
310 oauth_params = ((k, v) for k, v in self.items()
311 if k.startswith('oauth_'))
312 stringy_params = ((k, escape(str(v))) for k, v in oauth_params)
313 header_params = ('%s="%s"' % (k, v) for k, v in stringy_params)
314 params_header = ', '.join(header_params)
315
316 auth_header = 'OAuth realm="%s"' % realm
317 if params_header:
318 auth_header = "%s, %s" % (auth_header, params_header)
319
320 return {'Authorization': auth_header}
321
322 - def to_postdata(self):
323 """Serialize as post data for a POST request."""
324
325
326
327 return urllib.urlencode(self, True).replace('+', '%20')
328
330 """Serialize as a URL for a GET request."""
331 base_url = urlparse.urlparse(self.url)
332 query = parse_qs(base_url.query)
333 for k, v in self.items():
334 query.setdefault(k, []).append(v)
335 url = (base_url.scheme, base_url.netloc, base_url.path, base_url.params,
336 urllib.urlencode(query, True), base_url.fragment)
337 return urlparse.urlunparse(url)
338
340 ret = self.get(parameter)
341 if ret is None:
342 raise Error('Parameter not found: %s' % parameter)
343
344 return ret
345
347 """Return a string that contains the parameters that must be signed."""
348 items = []
349 for key, value in self.iteritems():
350 if key == 'oauth_signature':
351 continue
352
353
354 if hasattr(value, '__iter__'):
355 items.extend((key, item) for item in value)
356 else:
357 items.append((key, value))
358
359
360 query = urlparse.urlparse(self.url)[4]
361 items.extend(self._split_url_string(query).items())
362
363 encoded_str = urllib.urlencode(sorted(items))
364
365
366
367
368 return encoded_str.replace('+', '%20')
369
371 """Set the signature parameter to the result of sign."""
372
373 if 'oauth_consumer_key' not in self:
374 self['oauth_consumer_key'] = consumer.key
375
376 if token and 'oauth_token' not in self:
377 self['oauth_token'] = token.key
378
379 self['oauth_signature_method'] = signature_method.name
380 self['oauth_signature'] = signature_method.sign(self, consumer, token)
381
382 @classmethod
384 """Get seconds since epoch (UTC)."""
385 return str(int(time.time()))
386
387 @classmethod
389 """Generate pseudorandom number."""
390 return str(random.randint(0, 100000000))
391
392 @classmethod
393 - def from_request(cls, http_method, http_url, headers=None, parameters=None,
394 query_string=None):
395 """Combines multiple parameter sources."""
396 if parameters is None:
397 parameters = {}
398
399
400 if headers and 'Authorization' in headers:
401 auth_header = headers['Authorization']
402
403 if auth_header[:6] == 'OAuth ':
404 auth_header = auth_header[6:]
405 try:
406
407 header_params = cls._split_header(auth_header)
408 parameters.update(header_params)
409 except:
410 raise Error('Unable to parse OAuth parameters from '
411 'Authorization header.')
412
413
414 if query_string:
415 query_params = cls._split_url_string(query_string)
416 parameters.update(query_params)
417
418
419 param_str = urlparse.urlparse(http_url)[4]
420 url_params = cls._split_url_string(param_str)
421 parameters.update(url_params)
422
423 if parameters:
424 return cls(http_method, http_url, parameters)
425
426 return None
427
428 @classmethod
431 if not parameters:
432 parameters = {}
433
434 defaults = {
435 'oauth_consumer_key': consumer.key,
436 'oauth_timestamp': cls.make_timestamp(),
437 'oauth_nonce': cls.make_nonce(),
438 'oauth_version': cls.version,
439 }
440
441 defaults.update(parameters)
442 parameters = defaults
443
444 if token:
445 parameters['oauth_token'] = token.key
446 if token.verifier:
447 parameters['oauth_verifier'] = token.verifier
448
449 return Request(http_method, http_url, parameters)
450
451 @classmethod
454
455 if not parameters:
456 parameters = {}
457
458 parameters['oauth_token'] = token.key
459
460 if callback:
461 parameters['oauth_callback'] = callback
462
463 return cls(http_method, http_url, parameters)
464
465 @staticmethod
467 """Turn Authorization: header into parameters."""
468 params = {}
469 parts = header.split(',')
470 for param in parts:
471
472 if param.find('realm') > -1:
473 continue
474
475 param = param.strip()
476
477 param_parts = param.split('=', 1)
478
479 params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"'))
480 return params
481
482 @staticmethod
484 """Turn URL string into parameters."""
485 parameters = parse_qs(param_str, keep_blank_values=False)
486 for k, v in parameters.iteritems():
487 parameters[k] = urllib.unquote(v[0])
488 return parameters
489
492 """A skeletal implementation of a service provider, providing protected
493 resources to requests from authorized consumers.
494
495 This class implements the logic to check requests for authorization. You
496 can use it with your web server or web framework to protect certain
497 resources with OAuth.
498 """
499
500 timestamp_threshold = 300
501 version = VERSION
502 signature_methods = None
503
504 - def __init__(self, signature_methods=None):
506
510
518
520 """Optional support for the authenticate header."""
521 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
522
534
536 """Figure out the signature with some defaults."""
537 try:
538 signature_method = request.get_parameter('oauth_signature_method')
539 except:
540 signature_method = SIGNATURE_METHOD
541
542 try:
543
544 signature_method = self.signature_methods[signature_method]
545 except:
546 signature_method_names = ', '.join(self.signature_methods.keys())
547 raise Error('Signature method %s not supported try one of the following: %s' % (signature_method, signature_method_names))
548
549 return signature_method
550
553
574
576 """Verify that timestamp is recentish."""
577 timestamp = int(timestamp)
578 now = int(time.time())
579 lapsed = now - timestamp
580 if lapsed > self.timestamp_threshold:
581 raise Error('Expired timestamp: given %d and now %s has a '
582 'greater difference than threshold %d' % (timestamp, now,
583 self.timestamp_threshold))
584
587 """A way of signing requests.
588
589 The OAuth protocol lets consumers and service providers pick a way to sign
590 requests. This interface shows the methods expected by the other `oauth`
591 modules for signing requests. Subclass it and implement its methods to
592 provide a new way to sign requests.
593 """
594
596 """Calculates the string that needs to be signed.
597
598 This method returns a 2-tuple containing the starting key for the
599 signing and the message to be signed. The latter may be used in error
600 messages to help clients debug their software.
601
602 """
603 raise NotImplementedError
604
605 - def sign(self, request, consumer, token):
606 """Returns the signature for the given request, based on the consumer
607 and token also provided.
608
609 You should use your implementation of `signing_base()` to build the
610 message to sign. Otherwise it may be less useful for debugging.
611
612 """
613 raise NotImplementedError
614
615 - def check(self, request, consumer, token, signature):
616 """Returns whether the given signature is the correct signature for
617 the given consumer and token signing the given request."""
618 built = self.sign(request, consumer, token)
619 return built == signature
620
623 name = 'HMAC-SHA1'
624
640
641 - def sign(self, request, consumer, token):
642 """Builds the base signature string."""
643 key, raw = self.signing_base(request, consumer, token)
644
645
646 try:
647 from hashlib import sha1 as sha
648 except ImportError:
649 import sha
650
651 hashed = hmac.new(key, raw, sha)
652
653
654 return binascii.b2a_base64(hashed.digest())[:-1]
655
656
657 -class SignatureMethod_PLAINTEXT(SignatureMethod):
658
659 name = 'PLAINTEXT'
660
661 - def signing_base(self, request, consumer, token):
662 """Concatenates the consumer key and secret with the token's
663 secret."""
664 sig = '%s&' % escape(consumer.secret)
665 if token:
666 sig = sig + escape(token.secret)
667 return sig, sig
668
669 - def sign(self, request, consumer, token):
670 key, raw = self.signing_base(request, consumer, token)
671 return raw
672