├── .gitignore ├── LICENSE ├── README.rst ├── bottle_auth ├── __init__.py ├── bottle_auth.py ├── core │ ├── __init__.py │ ├── auth.py │ ├── escape.py │ ├── exception.py │ ├── httpclient.py │ └── httputil.py ├── custom.py ├── decorator.py └── social │ ├── __init__.py │ ├── facebook.py │ ├── google.py │ └── twitter.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Thiago Avelino 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | bottle-auth 2 | =========== 3 | 4 | Bottle plugin authentication, support with Google, Twitter and Facebook 5 | 6 | 7 | Example 8 | ------- 9 | 10 | .. code-block:: python 11 | 12 | from bottle import Bottle, request, run 13 | from bottle.ext import auth 14 | from bottle.ext.auth.decorator import login 15 | from bottle.ext.auth.social.facebook import Facebook 16 | from pprint import pformat 17 | 18 | facebook = Facebook('fb-key', 'fb-secret', 19 | 'http://127.0.0.1:3333/', 'email') 20 | 21 | app = Bottle() 22 | plugin = auth.AuthPlugin(facebook) 23 | app.install(plugin) 24 | 25 | 26 | @app.route('/') 27 | @login() 28 | def home(): 29 | user = auth.get_user(request.environ) 30 | return "Home page {}".format(pformat(user)) 31 | 32 | 33 | run(app=app, host='0.0.0.0', port='3333', debug=True) 34 | 35 | 36 | Application in production: `https://github.com/avelino/mining/blob/master/mining/auth.py `_ 37 | 38 | 39 | Google 40 | ------ 41 | 42 | Create project 43 | ++++++++++++++ 44 | 45 | 1. Sign into your Google Apps account in your browser 46 | 2. Visit `https://code.google.com/apis/console#access `_ in the same browser 47 | 3. On the left menu, Create a new Project 48 | 4. To start, you don’t need any Services, so select the API Access tab rom the left menu and “Create an OAuth 2.0 client ID…” 49 | 5. Fill out the Client ID form for a **web application** and use *localhost:8000* as your hostname 50 | 51 | 52 | Facebook 53 | -------- 54 | 55 | Create project 56 | ++++++++++++++ 57 | 58 | 1. Sign into your Facebook account in your browser 59 | 2. Visit `https://developers.facebook.com/ `_ in the same browser 60 | 3. Click Apps > Create a New App in the navigation bar 61 | 4. Enter Display Name, then choose a category, then click Create app 62 | 5. Fill out the Client ID form for a **web application** and use *localhost:8000* as your hostname 63 | -------------------------------------------------------------------------------- /bottle_auth/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import bottle 4 | import inspect 5 | 6 | 7 | class AuthPlugin(object): 8 | name = 'auth' 9 | 10 | def __init__(self, engine, keyword='auth'): 11 | """ 12 | :param engine: Auth engine created function 13 | :param keyword: Keyword used to inject auth in a route 14 | """ 15 | self.engine = engine 16 | self.keyword = keyword 17 | 18 | def setup(self, app): 19 | """ Make sure that other installed plugins don't affect the same 20 | keyword argument and check if metadata is available.""" 21 | for other in app.plugins: 22 | if not isinstance(other, AuthPlugin): 23 | continue 24 | if other.keyword == self.keyword: 25 | raise bottle.PluginError("Found another auth plugin " 26 | "with conflicting settings (" 27 | "non-unique keyword).") 28 | 29 | def apply(self, callback, context): 30 | args = inspect.getargspec(context['callback'])[0] 31 | 32 | if self.keyword not in args: 33 | return callback 34 | 35 | def wrapper(*args, **kwargs): 36 | kwargs[self.keyword] = self.engine 37 | return callback(*args, **kwargs) 38 | 39 | return wrapper 40 | -------------------------------------------------------------------------------- /bottle_auth/bottle_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import bottle 4 | import AuthPlugin 5 | 6 | 7 | if not hasattr(bottle, 'PluginError'): 8 | class PluginError(bottle.BottleException): 9 | pass 10 | bottle.PluginError = PluginError 11 | 12 | 13 | Plugin = AuthPlugin 14 | -------------------------------------------------------------------------------- /bottle_auth/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avelino/bottle-auth/db07e526864aeac05ee68444b47e5db29540ce18/bottle_auth/core/__init__.py -------------------------------------------------------------------------------- /bottle_auth/core/auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2009 Facebook 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | """Implementations of various third-party authentication schemes. 18 | 19 | All the classes in this file are class Mixins designed to be used with 20 | web.py RequestHandler classes. The primary methods for each service are 21 | authenticate_redirect(), authorize_redirect(), and get_authenticated_user(). 22 | The former should be called to redirect the user to, e.g., the OpenID 23 | authentication page on the third party service, and the latter should 24 | be called upon return to get the user data from the data returned by 25 | the third party service. 26 | 27 | They all take slightly different arguments due to the fact all these 28 | services implement authentication and authorization slightly differently. 29 | See the individual service classes below for complete documentation. 30 | 31 | Example usage for Google OpenID:: 32 | 33 | class GoogleHandler(tornado.web.RequestHandler, tornado.auth.GoogleMixin): 34 | @tornado.web.asynchronous 35 | def get(self): 36 | if self.get_argument("openid.mode", None): 37 | self.get_authenticated_user(self.async_callback(self._on_auth)) 38 | return 39 | self.authenticate_redirect() 40 | 41 | def _on_auth(self, user): 42 | if not user: 43 | raise tornado.web.HTTPError(500, "Google auth failed") 44 | # Save the user with, e.g., set_secure_cookie() 45 | 46 | """ 47 | 48 | import base64 49 | import binascii 50 | import cgi 51 | import hashlib 52 | import hmac 53 | import logging 54 | import time 55 | import urllib 56 | import urlparse 57 | import uuid 58 | import pprint 59 | 60 | from bottle_auth.core import httpclient 61 | from bottle_auth.core import escape 62 | from bottle_auth.core.escape import _unicode 63 | from bottle_auth.core.httputil import url_concat, bytes_type, b 64 | 65 | import webob 66 | import functools 67 | import re 68 | 69 | log = logging.getLogger('bottleauth.auth') 70 | 71 | 72 | class HTTPError(Exception): 73 | def __init__(self, code, description): 74 | self.code = code 75 | self.description = description 76 | 77 | 78 | class HTTPRedirect(Exception): 79 | def __init__(self, url): 80 | self.url = url 81 | 82 | 83 | class WebobRequestWrapper(object): 84 | def __init__(self, inst): 85 | self.inst = inst 86 | 87 | def full_url(self): 88 | return self.inst.url 89 | 90 | @property 91 | def uri(self): 92 | return self.inst.url 93 | 94 | @property 95 | def host(self): 96 | return self.inst.host 97 | 98 | @property 99 | def params(self): 100 | return self.inst.params 101 | 102 | @property 103 | def arguments(self): 104 | return self.inst.GET.dict_of_lists() 105 | 106 | 107 | class WebobResponseWrapper(object): 108 | def __init__(self, inst): 109 | self.inst = inst 110 | 111 | def set_cookie(self, name, value): 112 | self.inst.set_cookie(name, value) 113 | 114 | def get_cookie(self, name, default=None): 115 | return self.inst.cookies.get(name, default) 116 | 117 | def delete_cookie(self, name): 118 | self.inst.delete_cookie(name) 119 | 120 | 121 | class GenericAuth(object): 122 | """Generic base class to emulate a tornado.Request 123 | using the current WSGI environ. 124 | """ 125 | 126 | def __init__(self, request, settings=None, cookie_monster=None): 127 | self.settings = settings or {} 128 | 129 | if not isinstance(request, webob.Request): 130 | request = webob.Request(request) 131 | 132 | self.request = WebobRequestWrapper(request) 133 | 134 | if isinstance(cookie_monster, webob.Response): 135 | self.cookie_monster = WebobResponseWrapper(cookie_monster) 136 | else: 137 | self.cookie_monster = cookie_monster 138 | 139 | def redirect(self, url): 140 | raise HTTPRedirect(url) 141 | 142 | def require_setting(self, name, feature="this feature"): 143 | """Raises an exception if the given app setting is not defined.""" 144 | if name not in self.settings: 145 | raise Exception("You must define the '%s' setting in your " 146 | "application to use %s" % (name, feature)) 147 | 148 | _ARG_DEFAULT = [] 149 | 150 | def get_argument(self, name, default=_ARG_DEFAULT, strip=True): 151 | """Returns the value of the argument with the given name. 152 | 153 | If default is not provided, the argument is considered to be 154 | required, and we throw an HTTP 400 exception if it is missing. 155 | 156 | If the argument appears in the url more than once, we return the 157 | last value. 158 | 159 | The returned value is always unicode. 160 | """ 161 | args = self.get_arguments(name, strip=strip) 162 | if not args: 163 | if default is self._ARG_DEFAULT: 164 | raise HTTPError(400, "Missing argument %s" % name) 165 | return default 166 | return args[-1] 167 | 168 | def get_arguments(self, name, strip=True): 169 | """Returns a list of the arguments with the given name. 170 | 171 | If the argument is not present, returns an empty list. 172 | 173 | The returned values are always unicode. 174 | """ 175 | values = [] 176 | for v in self.request.params.getall(name): 177 | v = self.decode_argument(v, name=name) 178 | if isinstance(v, unicode): 179 | # Get rid of any weird control chars (unless decoding gave 180 | # us bytes, in which case leave it alone) 181 | v = re.sub(r"[\x00-\x08\x0e-\x1f]", " ", v) 182 | if strip: 183 | v = v.strip() 184 | values.append(v) 185 | return values 186 | 187 | def decode_argument(self, value, name=None): 188 | """Decodes an argument from the request. 189 | 190 | The argument has been percent-decoded and is now a byte string. 191 | By default, this method decodes the argument as utf-8 and returns 192 | a unicode string, but this may be overridden in subclasses. 193 | 194 | This method is used as a filter for both get_argument() and for 195 | values extracted from the url and passed to get()/post()/etc. 196 | 197 | The name of the argument is provided if known, but may be None 198 | (e.g. for unnamed groups in the url regex). 199 | """ 200 | return _unicode(value) 201 | 202 | def async_callback(self, callback, *args, **kwargs): 203 | """Obsolete - catches exceptions from the wrapped function. 204 | 205 | This function is unnecessary since Tornado 1.1. 206 | """ 207 | if callback is None: 208 | return None 209 | if args or kwargs: 210 | callback = functools.partial(callback, *args, **kwargs) 211 | 212 | #FIXME what about the exception wrapper? 213 | 214 | return callback 215 | 216 | def get_cookie(self, name, default=None): 217 | """Gets the value of the cookie with the given name, else default.""" 218 | assert self.cookie_monster, 'Cookie Monster not set' 219 | return self.cookie_monster.get_cookie(name, default) 220 | 221 | def set_cookie(self, name, value, domain=None, expires=None, path="/", 222 | expires_days=None, **kwargs): 223 | """Sets the given cookie name/value with the given options. 224 | 225 | Additional keyword arguments are set on the Cookie.Morsel 226 | directly. 227 | See http://docs.python.org/library/cookie.html#morsel-objects 228 | for available attributes. 229 | """ 230 | assert self.cookie_monster, 'Cookie Monster not set' 231 | #, domain=domain, path=path) 232 | self.cookie_monster.set_cookie(name, value) 233 | 234 | def clear_cookie(self, name, path="/", domain=None): 235 | """Deletes the cookie with the given name.""" 236 | assert self.cookie_monster, 'Cookie Monster not set' 237 | #, path=path, domain=domain) 238 | self.cookie_monster.delete_cookie(name) 239 | 240 | 241 | class OpenIdMixin(GenericAuth): 242 | """Abstract implementation of OpenID and Attribute Exchange. 243 | 244 | See GoogleMixin below for example implementations. 245 | """ 246 | def authenticate_redirect( 247 | self, callback_uri=None, ax_attrs=["name", "email", "language", 248 | "username"]): 249 | 250 | """Returns the authentication URL for this service. 251 | 252 | After authentication, the service will redirect back to the given 253 | callback URI. 254 | 255 | We request the given attributes for the authenticated user by 256 | default (name, email, language, and username). If you don't need 257 | all those attributes for your app, you can request fewer with 258 | the ax_attrs keyword argument. 259 | """ 260 | callback_uri = callback_uri or self.request.uri 261 | args = self._openid_args(callback_uri, ax_attrs=ax_attrs) 262 | self.redirect(self._OPENID_ENDPOINT + "?" + urllib.urlencode(args)) 263 | 264 | def get_authenticated_user(self, callback): 265 | """Fetches the authenticated user data upon redirect. 266 | 267 | This method should be called by the handler that receives the 268 | redirect from the authenticate_redirect() or authorize_redirect() 269 | methods. 270 | """ 271 | # Verify the OpenID response via direct request to the OP 272 | # Recommendation @hmarrao, ref #3 273 | args = dict((k, unicode(v[-1]).encode('utf-8')) for k, v in self.request.arguments.iteritems()) 274 | args["openid.mode"] = u"check_authentication" 275 | url = self._OPENID_ENDPOINT 276 | http = httpclient.AsyncHTTPClient() 277 | log.debug("OpenID requesting {0} at uri {1}".format(args, url)) 278 | http.fetch(url, self.async_callback( 279 | self._on_authentication_verified, callback), 280 | method="POST", body=urllib.urlencode(args)) 281 | 282 | def _openid_args(self, callback_uri, ax_attrs=[], oauth_scope=None): 283 | url = urlparse.urljoin(self.request.full_url(), callback_uri) 284 | args = { 285 | "openid.ns": "http://specs.openid.net/auth/2.0", 286 | "openid.claimed_id": "http://specs.openid.net/auth/2.0/" 287 | "identifier_select", 288 | "openid.identity": "http://specs.openid.net/auth/2.0/" 289 | "identifier_select", 290 | "openid.return_to": url, 291 | "openid.realm": urlparse.urljoin(url, '/'), 292 | "openid.mode": "checkid_setup", 293 | } 294 | if ax_attrs: 295 | args.update({ 296 | "openid.ns.ax": "http://openid.net/srv/ax/1.0", 297 | "openid.ax.mode": "fetch_request", 298 | }) 299 | ax_attrs = set(ax_attrs) 300 | required = [] 301 | if "name" in ax_attrs: 302 | ax_attrs -= set(["name", "firstname", "fullname", "lastname"]) 303 | required += ["firstname", "fullname", "lastname"] 304 | args.update({ 305 | "openid.ax.type.firstname": 306 | "http://axschema.org/namePerson/first", 307 | "openid.ax.type.fullname": 308 | "http://axschema.org/namePerson", 309 | "openid.ax.type.lastname": 310 | "http://axschema.org/namePerson/last", 311 | }) 312 | known_attrs = { 313 | "email": "http://axschema.org/contact/email", 314 | "language": "http://axschema.org/pref/language", 315 | "username": "http://axschema.org/namePerson/friendly", 316 | } 317 | for name in ax_attrs: 318 | args["openid.ax.type." + name] = known_attrs[name] 319 | required.append(name) 320 | args["openid.ax.required"] = ",".join(required) 321 | if oauth_scope: 322 | args.update({ 323 | "openid.ns.oauth": 324 | "http://specs.openid.net/extensions/oauth/1.0", 325 | "openid.oauth.consumer": self.request.host.split(":")[0], 326 | "openid.oauth.scope": oauth_scope, 327 | }) 328 | return args 329 | 330 | def _on_authentication_verified(self, callback, response): 331 | log.debug('Verifying token {0}'.format(pprint.pformat({ 332 | 'status_code': response.status_code, 333 | 'headers': response.headers, 334 | 'error': response.error, 335 | 'body': response.body, 336 | }))) 337 | 338 | if response.error or b("is_valid:true") not in response.body: 339 | log.warning("Invalid OpenID response: %s", response.error or 340 | response.body) 341 | callback(None) 342 | return 343 | 344 | # Make sure we got back at least an email from attribute exchange 345 | ax_ns = None 346 | for name in self.request.arguments.iterkeys(): 347 | if name.startswith("openid.ns.") and \ 348 | self.get_argument(name) == u"http://openid.net/srv/ax/1.0": 349 | ax_ns = name[10:] 350 | break 351 | def get_ax_arg(uri): 352 | log.debug('Getting {0}'.format(uri)) 353 | if not ax_ns: return u"" 354 | prefix = "openid." + ax_ns + ".type." 355 | ax_name = None 356 | for name in self.request.arguments.iterkeys(): 357 | if self.get_argument(name) == uri and name.startswith(prefix): 358 | part = name[len(prefix):] 359 | ax_name = "openid." + ax_ns + ".value." + part 360 | break 361 | if not ax_name: return u"" 362 | return self.get_argument(ax_name, u"") 363 | 364 | email = get_ax_arg("http://axschema.org/contact/email") 365 | name = get_ax_arg("http://axschema.org/namePerson") 366 | first_name = get_ax_arg("http://axschema.org/namePerson/first") 367 | last_name = get_ax_arg("http://axschema.org/namePerson/last") 368 | username = get_ax_arg("http://axschema.org/namePerson/friendly") 369 | locale = get_ax_arg("http://axschema.org/pref/language").lower() 370 | user = dict() 371 | name_parts = [] 372 | if first_name: 373 | user["first_name"] = first_name 374 | name_parts.append(first_name) 375 | if last_name: 376 | user["last_name"] = last_name 377 | name_parts.append(last_name) 378 | if name: 379 | user["name"] = name 380 | elif name_parts: 381 | user["name"] = u" ".join(name_parts) 382 | elif email: 383 | user["name"] = email.split("@")[0] 384 | if email: user["email"] = email 385 | if locale: user["locale"] = locale 386 | if username: user["username"] = username 387 | user['claimed_id'] = self.request.arguments.get('openid.claimed_id')[-1] 388 | log.debug('Final step, got claimed_id {0}'.format(user['claimed_id'])) 389 | callback(user) 390 | 391 | 392 | class OAuthMixin(GenericAuth): 393 | """Abstract implementation of OAuth. 394 | 395 | See TwitterMixin and FriendFeedMixin below for example implementations. 396 | """ 397 | 398 | def authorize_redirect(self, callback_uri=None, extra_params=None): 399 | """Redirects the user to obtain OAuth authorization for this service. 400 | 401 | Twitter and FriendFeed both require that you register a Callback 402 | URL with your application. You should call this method to log the 403 | user in, and then call get_authenticated_user() in the handler 404 | you registered as your Callback URL to complete the authorization 405 | process. 406 | 407 | This method sets a cookie called _oauth_request_token which is 408 | subsequently used (and cleared) in get_authenticated_user for 409 | security purposes. 410 | """ 411 | if callback_uri and getattr(self, "_OAUTH_NO_CALLBACKS", False): 412 | raise Exception("This service does not support oauth_callback") 413 | http = httpclient.AsyncHTTPClient() 414 | if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": 415 | http.fetch(self._oauth_request_token_url(callback_uri=callback_uri, 416 | extra_params=extra_params), 417 | self.async_callback( 418 | self._on_request_token, 419 | self._OAUTH_AUTHORIZE_URL, 420 | callback_uri)) 421 | else: 422 | http.fetch(self._oauth_request_token_url(), self.async_callback( 423 | self._on_request_token, self._OAUTH_AUTHORIZE_URL, callback_uri)) 424 | 425 | 426 | def get_authenticated_user(self, callback): 427 | """Gets the OAuth authorized user and access token on callback. 428 | 429 | This method should be called from the handler for your registered 430 | OAuth Callback URL to complete the registration process. We call 431 | callback with the authenticated user, which in addition to standard 432 | attributes like 'name' includes the 'access_key' attribute, which 433 | contains the OAuth access you can use to make authorized requests 434 | to this service on behalf of the user. 435 | 436 | """ 437 | request_key = self.get_argument("oauth_token") 438 | oauth_verifier = self.get_argument("oauth_verifier", None) 439 | request_cookie = self.get_cookie("_oauth_request_token") 440 | if not request_cookie: 441 | log.warning("Missing OAuth request token cookie") 442 | callback(None) 443 | return 444 | self.clear_cookie("_oauth_request_token") 445 | cookie_key, cookie_secret = [base64.b64decode(i) for i in request_cookie.split("|")] 446 | if cookie_key != request_key: 447 | log.warning("Request token does not match cookie") 448 | callback(None) 449 | return 450 | token = dict(key=cookie_key, secret=cookie_secret) 451 | if oauth_verifier: 452 | token["verifier"] = oauth_verifier 453 | http = httpclient.AsyncHTTPClient() 454 | http.fetch(self._oauth_access_token_url(token), self.async_callback( 455 | self._on_access_token, callback)) 456 | 457 | def _oauth_request_token_url(self, callback_uri= None, extra_params=None): 458 | consumer_token = self._oauth_consumer_token() 459 | url = self._OAUTH_REQUEST_TOKEN_URL 460 | args = dict( 461 | oauth_consumer_key=consumer_token["key"], 462 | oauth_signature_method="HMAC-SHA1", 463 | oauth_timestamp=str(int(time.time())), 464 | oauth_nonce=binascii.b2a_hex(uuid.uuid4().bytes), 465 | oauth_version=getattr(self, "_OAUTH_VERSION", "1.0a"), 466 | ) 467 | if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": 468 | if callback_uri: 469 | args["oauth_callback"] = urlparse.urljoin( 470 | self.request.full_url(), callback_uri) 471 | if extra_params: args.update(extra_params) 472 | signature = _oauth10a_signature(consumer_token, "GET", url, args) 473 | else: 474 | signature = _oauth_signature(consumer_token, "GET", url, args) 475 | 476 | args["oauth_signature"] = signature 477 | return url + "?" + urllib.urlencode(args) 478 | 479 | def _on_request_token(self, authorize_url, callback_uri, response): 480 | if response.error: 481 | raise Exception("Could not get request token") 482 | request_token = _oauth_parse_response(response.body) 483 | data = "|".join([base64.b64encode(request_token["key"]), 484 | base64.b64encode(request_token["secret"])]) 485 | self.set_cookie("_oauth_request_token", data) 486 | args = dict(oauth_token=request_token["key"]) 487 | if callback_uri: 488 | args["oauth_callback"] = urlparse.urljoin( 489 | self.request.full_url(), callback_uri) 490 | self.redirect(authorize_url + "?" + urllib.urlencode(args)) 491 | 492 | def _oauth_access_token_url(self, request_token): 493 | consumer_token = self._oauth_consumer_token() 494 | url = self._OAUTH_ACCESS_TOKEN_URL 495 | args = dict( 496 | oauth_consumer_key=consumer_token["key"], 497 | oauth_token=request_token["key"], 498 | oauth_signature_method="HMAC-SHA1", 499 | oauth_timestamp=str(int(time.time())), 500 | oauth_nonce=binascii.b2a_hex(uuid.uuid4().bytes), 501 | oauth_version=getattr(self, "_OAUTH_VERSION", "1.0a"), 502 | ) 503 | if "verifier" in request_token: 504 | args["oauth_verifier"]=request_token["verifier"] 505 | 506 | if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": 507 | signature = _oauth10a_signature(consumer_token, "GET", url, args, 508 | request_token) 509 | else: 510 | signature = _oauth_signature(consumer_token, "GET", url, args, 511 | request_token) 512 | 513 | args["oauth_signature"] = signature 514 | return url + "?" + urllib.urlencode(args) 515 | 516 | def _on_access_token(self, callback, response): 517 | if response.error: 518 | log.warning("Could not fetch access token") 519 | callback(None) 520 | return 521 | 522 | access_token = _oauth_parse_response(response.body) 523 | user = self._oauth_get_user(access_token, self.async_callback( 524 | self._on_oauth_get_user, access_token, callback)) 525 | 526 | def _oauth_get_user(self, access_token, callback): 527 | raise NotImplementedError() 528 | 529 | def _on_oauth_get_user(self, access_token, callback, user): 530 | if not user: 531 | callback(None) 532 | return 533 | user["access_token"] = access_token 534 | callback(user) 535 | 536 | def _oauth_request_parameters(self, url, access_token, parameters={}, 537 | method="GET"): 538 | """Returns the OAuth parameters as a dict for the given request. 539 | 540 | parameters should include all POST arguments and query string arguments 541 | that will be sent with the request. 542 | """ 543 | consumer_token = self._oauth_consumer_token() 544 | base_args = dict( 545 | oauth_consumer_key=consumer_token["key"], 546 | oauth_token=access_token["key"], 547 | oauth_signature_method="HMAC-SHA1", 548 | oauth_timestamp=str(int(time.time())), 549 | oauth_nonce=binascii.b2a_hex(uuid.uuid4().bytes), 550 | oauth_version=getattr(self, "_OAUTH_VERSION", "1.0a"), 551 | ) 552 | args = {} 553 | args.update(base_args) 554 | args.update(parameters) 555 | if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": 556 | signature = _oauth10a_signature(consumer_token, method, url, args, 557 | access_token) 558 | else: 559 | signature = _oauth_signature(consumer_token, method, url, args, 560 | access_token) 561 | base_args["oauth_signature"] = signature 562 | return base_args 563 | 564 | class OAuth2Mixin(GenericAuth): 565 | """Abstract implementation of OAuth v 2.""" 566 | 567 | def authorize_redirect(self, redirect_uri=None, client_id=None, 568 | client_secret=None, extra_params=None ): 569 | """Redirects the user to obtain OAuth authorization for this service. 570 | 571 | Some providers require that you register a Callback 572 | URL with your application. You should call this method to log the 573 | user in, and then call get_authenticated_user() in the handler 574 | you registered as your Callback URL to complete the authorization 575 | process. 576 | """ 577 | args = { 578 | "redirect_uri": redirect_uri, 579 | "client_id": client_id 580 | } 581 | if extra_params: args.update(extra_params) 582 | self.redirect( 583 | url_concat(self._OAUTH_AUTHORIZE_URL, args)) 584 | 585 | def _oauth_request_token_url(self, redirect_uri= None, client_id = None, 586 | client_secret=None, code=None, 587 | extra_params=None): 588 | url = self._OAUTH_ACCESS_TOKEN_URL 589 | args = dict( 590 | redirect_uri=redirect_uri, 591 | code=code, 592 | client_id=client_id, 593 | client_secret=client_secret, 594 | ) 595 | if extra_params: args.update(extra_params) 596 | return url_concat(url, args) 597 | 598 | class TwitterMixin(OAuthMixin): 599 | """Twitter OAuth authentication. 600 | 601 | To authenticate with Twitter, register your application with 602 | Twitter at http://twitter.com/apps. Then copy your Consumer Key and 603 | Consumer Secret to the application settings 'twitter_consumer_key' and 604 | 'twitter_consumer_secret'. Use this Mixin on the handler for the URL 605 | you registered as your application's Callback URL. 606 | 607 | When your application is set up, you can use this Mixin like this 608 | to authenticate the user with Twitter and get access to their stream:: 609 | 610 | class TwitterHandler(tornado.web.RequestHandler, 611 | tornado.auth.TwitterMixin): 612 | @tornado.web.asynchronous 613 | def get(self): 614 | if self.get_argument("oauth_token", None): 615 | self.get_authenticated_user(self.async_callback(self._on_auth)) 616 | return 617 | self.authorize_redirect() 618 | 619 | def _on_auth(self, user): 620 | if not user: 621 | raise tornado.web.HTTPError(500, "Twitter auth failed") 622 | # Save the user using, e.g., set_secure_cookie() 623 | 624 | The user object returned by get_authenticated_user() includes the 625 | attributes 'username', 'name', and all of the custom Twitter user 626 | attributes describe at 627 | http://apiwiki.twitter.com/Twitter-REST-API-Method%3A-users%C2%A0show 628 | in addition to 'access_token'. You should save the access token with 629 | the user; it is required to make requests on behalf of the user later 630 | with twitter_request(). 631 | """ 632 | _OAUTH_REQUEST_TOKEN_URL = "http://api.twitter.com/oauth/request_token" 633 | _OAUTH_ACCESS_TOKEN_URL = "http://api.twitter.com/oauth/access_token" 634 | _OAUTH_AUTHORIZE_URL = "http://api.twitter.com/oauth/authorize" 635 | _OAUTH_AUTHENTICATE_URL = "http://api.twitter.com/oauth/authenticate" 636 | _OAUTH_NO_CALLBACKS = False 637 | 638 | 639 | def authenticate_redirect(self): 640 | """Just like authorize_redirect(), but auto-redirects if authorized. 641 | 642 | This is generally the right interface to use if you are using 643 | Twitter for single-sign on. 644 | """ 645 | http = httpclient.AsyncHTTPClient() 646 | http.fetch(self._oauth_request_token_url(), self.async_callback( 647 | self._on_request_token, self._OAUTH_AUTHENTICATE_URL, None)) 648 | 649 | def twitter_request(self, path, callback, access_token=None, 650 | post_args=None, **args): 651 | """Fetches the given API path, e.g., "/statuses/user_timeline/btaylor" 652 | 653 | The path should not include the format (we automatically append 654 | ".json" and parse the JSON output). 655 | 656 | If the request is a POST, post_args should be provided. Query 657 | string arguments should be given as keyword arguments. 658 | 659 | All the Twitter methods are documented at 660 | http://apiwiki.twitter.com/Twitter-API-Documentation. 661 | 662 | Many methods require an OAuth access token which you can obtain 663 | through authorize_redirect() and get_authenticated_user(). The 664 | user returned through that process includes an 'access_token' 665 | attribute that can be used to make authenticated requests via 666 | this method. Example usage:: 667 | 668 | class MainHandler(tornado.web.RequestHandler, 669 | tornado.auth.TwitterMixin): 670 | @tornado.web.authenticated 671 | @tornado.web.asynchronous 672 | def get(self): 673 | self.twitter_request( 674 | "/statuses/update", 675 | post_args={"status": "Testing Tornado Web Server"}, 676 | access_token=user["access_token"], 677 | callback=self.async_callback(self._on_post)) 678 | 679 | def _on_post(self, new_entry): 680 | if not new_entry: 681 | # Call failed; perhaps missing permission? 682 | self.authorize_redirect() 683 | return 684 | self.finish("Posted a message!") 685 | 686 | """ 687 | # Add the OAuth resource request signature if we have credentials 688 | url = "http://api.twitter.com/1" + path + ".json" 689 | if access_token: 690 | all_args = {} 691 | all_args.update(args) 692 | all_args.update(post_args or {}) 693 | consumer_token = self._oauth_consumer_token() 694 | method = "POST" if post_args is not None else "GET" 695 | oauth = self._oauth_request_parameters( 696 | url, access_token, all_args, method=method) 697 | args.update(oauth) 698 | if args: url += "?" + urllib.urlencode(args) 699 | callback = self.async_callback(self._on_twitter_request, callback) 700 | http = httpclient.AsyncHTTPClient() 701 | if post_args is not None: 702 | http.fetch(url, method="POST", body=urllib.urlencode(post_args), 703 | callback=callback) 704 | else: 705 | http.fetch(url, callback=callback) 706 | 707 | def _on_twitter_request(self, callback, response): 708 | if response.error: 709 | log.warning("Error response %s fetching %s", response.error, 710 | response.request.url) 711 | callback(None) 712 | return 713 | callback(escape.json_decode(response.body)) 714 | 715 | def _oauth_consumer_token(self): 716 | self.require_setting("twitter_consumer_key", "Twitter OAuth") 717 | self.require_setting("twitter_consumer_secret", "Twitter OAuth") 718 | return dict( 719 | key=self.settings["twitter_consumer_key"], 720 | secret=self.settings["twitter_consumer_secret"]) 721 | 722 | def _oauth_get_user(self, access_token, callback): 723 | callback = self.async_callback(self._parse_user_response, callback) 724 | self.twitter_request( 725 | "/users/show/" + access_token["screen_name"], 726 | access_token=access_token, callback=callback) 727 | 728 | def _parse_user_response(self, callback, user): 729 | if user: 730 | user["username"] = user["screen_name"] 731 | callback(user) 732 | 733 | 734 | class FriendFeedMixin(OAuthMixin): 735 | """FriendFeed OAuth authentication. 736 | 737 | To authenticate with FriendFeed, register your application with 738 | FriendFeed at http://friendfeed.com/api/applications. Then 739 | copy your Consumer Key and Consumer Secret to the application settings 740 | 'friendfeed_consumer_key' and 'friendfeed_consumer_secret'. Use 741 | this Mixin on the handler for the URL you registered as your 742 | application's Callback URL. 743 | 744 | When your application is set up, you can use this Mixin like this 745 | to authenticate the user with FriendFeed and get access to their feed:: 746 | 747 | class FriendFeedHandler(tornado.web.RequestHandler, 748 | tornado.auth.FriendFeedMixin): 749 | @tornado.web.asynchronous 750 | def get(self): 751 | if self.get_argument("oauth_token", None): 752 | self.get_authenticated_user(self.async_callback(self._on_auth)) 753 | return 754 | self.authorize_redirect() 755 | 756 | def _on_auth(self, user): 757 | if not user: 758 | raise tornado.web.HTTPError(500, "FriendFeed auth failed") 759 | # Save the user using, e.g., set_secure_cookie() 760 | 761 | The user object returned by get_authenticated_user() includes the 762 | attributes 'username', 'name', and 'description' in addition to 763 | 'access_token'. You should save the access token with the user; 764 | it is required to make requests on behalf of the user later with 765 | friendfeed_request(). 766 | """ 767 | _OAUTH_VERSION = "1.0" 768 | _OAUTH_REQUEST_TOKEN_URL = "https://friendfeed.com/account/oauth/request_token" 769 | _OAUTH_ACCESS_TOKEN_URL = "https://friendfeed.com/account/oauth/access_token" 770 | _OAUTH_AUTHORIZE_URL = "https://friendfeed.com/account/oauth/authorize" 771 | _OAUTH_NO_CALLBACKS = True 772 | _OAUTH_VERSION = "1.0" 773 | 774 | 775 | def friendfeed_request(self, path, callback, access_token=None, 776 | post_args=None, **args): 777 | """Fetches the given relative API path, e.g., "/bret/friends" 778 | 779 | If the request is a POST, post_args should be provided. Query 780 | string arguments should be given as keyword arguments. 781 | 782 | All the FriendFeed methods are documented at 783 | http://friendfeed.com/api/documentation. 784 | 785 | Many methods require an OAuth access token which you can obtain 786 | through authorize_redirect() and get_authenticated_user(). The 787 | user returned through that process includes an 'access_token' 788 | attribute that can be used to make authenticated requests via 789 | this method. Example usage:: 790 | 791 | class MainHandler(tornado.web.RequestHandler, 792 | tornado.auth.FriendFeedMixin): 793 | @tornado.web.authenticated 794 | @tornado.web.asynchronous 795 | def get(self): 796 | self.friendfeed_request( 797 | "/entry", 798 | post_args={"body": "Testing Tornado Web Server"}, 799 | access_token=self.current_user["access_token"], 800 | callback=self.async_callback(self._on_post)) 801 | 802 | def _on_post(self, new_entry): 803 | if not new_entry: 804 | # Call failed; perhaps missing permission? 805 | self.authorize_redirect() 806 | return 807 | self.finish("Posted a message!") 808 | 809 | """ 810 | # Add the OAuth resource request signature if we have credentials 811 | url = "http://friendfeed-api.com/v2" + path 812 | if access_token: 813 | all_args = {} 814 | all_args.update(args) 815 | all_args.update(post_args or {}) 816 | consumer_token = self._oauth_consumer_token() 817 | method = "POST" if post_args is not None else "GET" 818 | oauth = self._oauth_request_parameters( 819 | url, access_token, all_args, method=method) 820 | args.update(oauth) 821 | if args: url += "?" + urllib.urlencode(args) 822 | callback = self.async_callback(self._on_friendfeed_request, callback) 823 | http = httpclient.AsyncHTTPClient() 824 | if post_args is not None: 825 | http.fetch(url, method="POST", body=urllib.urlencode(post_args), 826 | callback=callback) 827 | else: 828 | http.fetch(url, callback=callback) 829 | 830 | def _on_friendfeed_request(self, callback, response): 831 | if response.error: 832 | log.warning("Error response %s fetching %s", response.error, 833 | response.request.url) 834 | callback(None) 835 | return 836 | callback(escape.json_decode(response.body)) 837 | 838 | def _oauth_consumer_token(self): 839 | self.require_setting("friendfeed_consumer_key", "FriendFeed OAuth") 840 | self.require_setting("friendfeed_consumer_secret", "FriendFeed OAuth") 841 | return dict( 842 | key=self.settings["friendfeed_consumer_key"], 843 | secret=self.settings["friendfeed_consumer_secret"]) 844 | 845 | def _oauth_get_user(self, access_token, callback): 846 | callback = self.async_callback(self._parse_user_response, callback) 847 | self.friendfeed_request( 848 | "/feedinfo/" + access_token["username"], 849 | include="id,name,description", access_token=access_token, 850 | callback=callback) 851 | 852 | def _parse_user_response(self, callback, user): 853 | if user: 854 | user["username"] = user["id"] 855 | callback(user) 856 | 857 | 858 | class GoogleMixin(OpenIdMixin, OAuthMixin): 859 | """Google Open ID / OAuth authentication. 860 | 861 | No application registration is necessary to use Google for authentication 862 | or to access Google resources on behalf of a user. To authenticate with 863 | Google, redirect with authenticate_redirect(). On return, parse the 864 | response with get_authenticated_user(). We send a dict containing the 865 | values for the user, including 'email', 'name', and 'locale'. 866 | Example usage:: 867 | 868 | class GoogleHandler(tornado.web.RequestHandler, tornado.auth.GoogleMixin): 869 | @tornado.web.asynchronous 870 | def get(self): 871 | if self.get_argument("openid.mode", None): 872 | self.get_authenticated_user(self.async_callback(self._on_auth)) 873 | return 874 | self.authenticate_redirect() 875 | 876 | def _on_auth(self, user): 877 | if not user: 878 | raise tornado.web.HTTPError(500, "Google auth failed") 879 | # Save the user with, e.g., set_secure_cookie() 880 | 881 | """ 882 | _OPENID_ENDPOINT = "https://www.google.com/accounts/o8/ud" 883 | _OAUTH_ACCESS_TOKEN_URL = "https://www.google.com/accounts/OAuthGetAccessToken" 884 | 885 | def authorize_redirect(self, oauth_scope, callback_uri=None, 886 | ax_attrs=["name","email","language","username"]): 887 | """Authenticates and authorizes for the given Google resource. 888 | 889 | Some of the available resources are: 890 | 891 | * Gmail Contacts - http://www.google.com/m8/feeds/ 892 | * Calendar - http://www.google.com/calendar/feeds/ 893 | * Finance - http://finance.google.com/finance/feeds/ 894 | 895 | You can authorize multiple resources by separating the resource 896 | URLs with a space. 897 | """ 898 | callback_uri = callback_uri or self.request.uri 899 | args = self._openid_args(callback_uri, ax_attrs=ax_attrs, 900 | oauth_scope=oauth_scope) 901 | self.redirect(self._OPENID_ENDPOINT + "?" + urllib.urlencode(args)) 902 | 903 | def get_authenticated_user(self, callback): 904 | """Fetches the authenticated user data upon redirect.""" 905 | # Look to see if we are doing combined OpenID/OAuth 906 | oauth_ns = "" 907 | for name, values in self.request.arguments.iteritems(): 908 | if name.startswith("openid.ns.") and \ 909 | values[-1] == u"http://specs.openid.net/extensions/oauth/1.0": 910 | oauth_ns = name[10:] 911 | break 912 | token = self.get_argument("openid." + oauth_ns + ".request_token", "") 913 | if token: 914 | http = httpclient.AsyncHTTPClient() 915 | token = dict(key=token, secret="") 916 | http.fetch(self._oauth_access_token_url(token), 917 | self.async_callback(self._on_access_token, callback)) 918 | else: 919 | OpenIdMixin.get_authenticated_user(self, callback) 920 | 921 | def _oauth_consumer_token(self): 922 | self.require_setting("google_consumer_key", "Google OAuth") 923 | self.require_setting("google_consumer_secret", "Google OAuth") 924 | return dict( 925 | key=self.settings["google_consumer_key"], 926 | secret=self.settings["google_consumer_secret"]) 927 | 928 | def _oauth_get_user(self, access_token, callback): 929 | OpenIdMixin.get_authenticated_user(self, callback) 930 | 931 | class FacebookMixin(GenericAuth): 932 | """Facebook Connect authentication. 933 | 934 | New applications should consider using `FacebookGraphMixin` below instead 935 | of this class. 936 | 937 | To authenticate with Facebook, register your application with 938 | Facebook at http://www.facebook.com/developers/apps.php. Then 939 | copy your API Key and Application Secret to the application settings 940 | 'facebook_api_key' and 'facebook_secret'. 941 | 942 | When your application is set up, you can use this Mixin like this 943 | to authenticate the user with Facebook:: 944 | 945 | class FacebookHandler(tornado.web.RequestHandler, 946 | tornado.auth.FacebookMixin): 947 | @tornado.web.asynchronous 948 | def get(self): 949 | if self.get_argument("session", None): 950 | self.get_authenticated_user(self.async_callback(self._on_auth)) 951 | return 952 | self.authenticate_redirect() 953 | 954 | def _on_auth(self, user): 955 | if not user: 956 | raise tornado.web.HTTPError(500, "Facebook auth failed") 957 | # Save the user using, e.g., set_secure_cookie() 958 | 959 | The user object returned by get_authenticated_user() includes the 960 | attributes 'facebook_uid' and 'name' in addition to session attributes 961 | like 'session_key'. You should save the session key with the user; it is 962 | required to make requests on behalf of the user later with 963 | facebook_request(). 964 | """ 965 | def authenticate_redirect(self, callback_uri=None, cancel_uri=None, 966 | extended_permissions=None): 967 | """Authenticates/installs this app for the current user.""" 968 | self.require_setting("facebook_api_key", "Facebook Connect") 969 | callback_uri = callback_uri or self.request.uri 970 | args = { 971 | "api_key": self.settings["facebook_api_key"], 972 | "v": "1.0", 973 | "fbconnect": "true", 974 | "display": "page", 975 | "next": urlparse.urljoin(self.request.full_url(), callback_uri), 976 | "return_session": "true", 977 | } 978 | if cancel_uri: 979 | args["cancel_url"] = urlparse.urljoin( 980 | self.request.full_url(), cancel_uri) 981 | if extended_permissions: 982 | if isinstance(extended_permissions, (unicode, bytes_type)): 983 | extended_permissions = [extended_permissions] 984 | args["req_perms"] = ",".join(extended_permissions) 985 | self.redirect("http://www.facebook.com/login.php?" + 986 | urllib.urlencode(args)) 987 | 988 | def authorize_redirect(self, extended_permissions, callback_uri=None, 989 | cancel_uri=None): 990 | """Redirects to an authorization request for the given FB resource. 991 | 992 | The available resource names are listed at 993 | http://wiki.developers.facebook.com/index.php/Extended_permission. 994 | The most common resource types include: 995 | 996 | * publish_stream 997 | * read_stream 998 | * email 999 | * sms 1000 | 1001 | extended_permissions can be a single permission name or a list of 1002 | names. To get the session secret and session key, call 1003 | get_authenticated_user() just as you would with 1004 | authenticate_redirect(). 1005 | """ 1006 | self.authenticate_redirect(callback_uri, cancel_uri, 1007 | extended_permissions) 1008 | 1009 | def get_authenticated_user(self, callback): 1010 | """Fetches the authenticated Facebook user. 1011 | 1012 | The authenticated user includes the special Facebook attributes 1013 | 'session_key' and 'facebook_uid' in addition to the standard 1014 | user attributes like 'name'. 1015 | """ 1016 | self.require_setting("facebook_api_key", "Facebook Connect") 1017 | session = escape.json_decode(self.get_argument("session")) 1018 | self.facebook_request( 1019 | method="facebook.users.getInfo", 1020 | callback=self.async_callback( 1021 | self._on_get_user_info, callback, session), 1022 | session_key=session["session_key"], 1023 | uids=session["uid"], 1024 | fields="uid,first_name,last_name,name,locale,pic_square," \ 1025 | "profile_url,username") 1026 | 1027 | def facebook_request(self, method, callback, **args): 1028 | """Makes a Facebook API REST request. 1029 | 1030 | We automatically include the Facebook API key and signature, but 1031 | it is the callers responsibility to include 'session_key' and any 1032 | other required arguments to the method. 1033 | 1034 | The available Facebook methods are documented here: 1035 | http://wiki.developers.facebook.com/index.php/API 1036 | 1037 | Here is an example for the stream.get() method:: 1038 | 1039 | class MainHandler(tornado.web.RequestHandler, 1040 | tornado.auth.FacebookMixin): 1041 | @tornado.web.authenticated 1042 | @tornado.web.asynchronous 1043 | def get(self): 1044 | self.facebook_request( 1045 | method="stream.get", 1046 | callback=self.async_callback(self._on_stream), 1047 | session_key=self.current_user["session_key"]) 1048 | 1049 | def _on_stream(self, stream): 1050 | if stream is None: 1051 | # Not authorized to read the stream yet? 1052 | self.redirect(self.authorize_redirect("read_stream")) 1053 | return 1054 | self.render("stream.html", stream=stream) 1055 | 1056 | """ 1057 | self.require_setting("facebook_api_key", "Facebook Connect") 1058 | self.require_setting("facebook_secret", "Facebook Connect") 1059 | if not method.startswith("facebook."): 1060 | method = "facebook." + method 1061 | args["api_key"] = self.settings["facebook_api_key"] 1062 | args["v"] = "1.0" 1063 | args["method"] = method 1064 | args["call_id"] = str(long(time.time() * 1e6)) 1065 | args["format"] = "json" 1066 | args["sig"] = self._signature(args) 1067 | url = "http://api.facebook.com/restserver.php?" + \ 1068 | urllib.urlencode(args) 1069 | http = httpclient.AsyncHTTPClient() 1070 | http.fetch(url, callback=self.async_callback( 1071 | self._parse_response, callback)) 1072 | 1073 | def _on_get_user_info(self, callback, session, users): 1074 | if users is None: 1075 | callback(None) 1076 | return 1077 | callback({ 1078 | "name": users[0]["name"], 1079 | "first_name": users[0]["first_name"], 1080 | "last_name": users[0]["last_name"], 1081 | "uid": users[0]["uid"], 1082 | "locale": users[0]["locale"], 1083 | "pic_square": users[0]["pic_square"], 1084 | "profile_url": users[0]["profile_url"], 1085 | "username": users[0].get("username"), 1086 | "session_key": session["session_key"], 1087 | "session_expires": session.get("expires"), 1088 | }) 1089 | 1090 | def _parse_response(self, callback, response): 1091 | if response.error: 1092 | log.warning("HTTP error from Facebook: %s", response.error) 1093 | callback(None) 1094 | return 1095 | try: 1096 | json = escape.json_decode(response.body) 1097 | except Exception: 1098 | log.warning("Invalid JSON from Facebook: %r", response.body) 1099 | callback(None) 1100 | return 1101 | if isinstance(json, dict) and json.get("error_code"): 1102 | log.warning("Facebook error: %d: %r", json["error_code"], 1103 | json.get("error_msg")) 1104 | callback(None) 1105 | return 1106 | callback(json) 1107 | 1108 | def _signature(self, args): 1109 | parts = ["%s=%s" % (n, args[n]) for n in sorted(args.keys())] 1110 | body = "".join(parts) + self.settings["facebook_secret"] 1111 | if isinstance(body, unicode): body = body.encode("utf-8") 1112 | return hashlib.md5(body).hexdigest() 1113 | 1114 | class FacebookGraphMixin(OAuth2Mixin): 1115 | """Facebook authentication using the new Graph API and OAuth2.""" 1116 | _OAUTH_ACCESS_TOKEN_URL = "https://graph.facebook.com/oauth/access_token?" 1117 | _OAUTH_AUTHORIZE_URL = "https://graph.facebook.com/oauth/authorize?" 1118 | _OAUTH_NO_CALLBACKS = False 1119 | 1120 | def get_authenticated_user(self, redirect_uri, client_id, client_secret, 1121 | code, callback, fields=None): 1122 | """Handles the login for the Facebook user, returning a user object. 1123 | 1124 | Example usage:: 1125 | 1126 | class FacebookGraphLoginHandler(LoginHandler, tornado.auth.FacebookGraphMixin): 1127 | @tornado.web.asynchronous 1128 | def get(self): 1129 | if self.get_argument("code", False): 1130 | self.get_authenticated_user( 1131 | redirect_uri='/auth/facebookgraph/', 1132 | client_id=self.settings["facebook_api_key"], 1133 | client_secret=self.settings["facebook_secret"], 1134 | code=self.get_argument("code"), 1135 | callback=self.async_callback( 1136 | self._on_login)) 1137 | return 1138 | self.authorize_redirect(redirect_uri='/auth/facebookgraph/', 1139 | client_id=self.settings["facebook_api_key"], 1140 | extra_params={"scope": "read_stream,offline_access"}) 1141 | 1142 | def _on_login(self, user): 1143 | log.error(user) 1144 | self.finish() 1145 | 1146 | """ 1147 | http = httpclient.AsyncHTTPClient() 1148 | args = { 1149 | "redirect_uri": redirect_uri, 1150 | "code": code, 1151 | "client_id": client_id, 1152 | "client_secret": client_secret, 1153 | } 1154 | 1155 | #fields = set(['id', 'name', 'first_name', 'last_name', 1156 | # 'locale', 'picture', 'link']) 1157 | #if extra_fields: fields.update(extra_fields) 1158 | if fields: 1159 | fields = fields.split(',') 1160 | 1161 | http.fetch(self._oauth_request_token_url(**args), 1162 | self.async_callback(self._on_access_token, redirect_uri, client_id, 1163 | client_secret, callback, fields)) 1164 | 1165 | def _on_access_token(self, redirect_uri, client_id, client_secret, 1166 | callback, fields, response): 1167 | if response.error: 1168 | log.warning('Facebook auth error: %s' % str(response)) 1169 | callback(None) 1170 | return 1171 | 1172 | args = escape.parse_qs_bytes(escape.native_str(response.body)) 1173 | session = { 1174 | "access_token": args["access_token"][-1], 1175 | "expires": args.get("expires") 1176 | } 1177 | 1178 | if fields is not None: 1179 | self.facebook_request( 1180 | path="/me", 1181 | callback=self.async_callback( 1182 | self._on_get_user_info, callback, session, fields), 1183 | access_token=session["access_token"], 1184 | fields=",".join(fields) 1185 | ) 1186 | else: 1187 | self.facebook_request( 1188 | path="/me", 1189 | callback=self.async_callback( 1190 | self._on_get_user_info, callback, session, fields), 1191 | access_token=session["access_token"], 1192 | ) 1193 | 1194 | 1195 | def _on_get_user_info(self, callback, session, fields, user): 1196 | if user is None: 1197 | callback(None) 1198 | return 1199 | 1200 | fieldmap = {} 1201 | if fields is None: 1202 | fieldmap.update(user) 1203 | else: 1204 | for field in fields: 1205 | fieldmap[field] = user.get(field) 1206 | fieldmap.update({"access_token": session["access_token"], "session_expires": session.get("expires")}) 1207 | callback(fieldmap) 1208 | 1209 | def facebook_request(self, path, callback, access_token=None, 1210 | post_args=None, **args): 1211 | """Fetches the given relative API path, e.g., "/btaylor/picture" 1212 | 1213 | If the request is a POST, post_args should be provided. Query 1214 | string arguments should be given as keyword arguments. 1215 | 1216 | An introduction to the Facebook Graph API can be found at 1217 | http://developers.facebook.com/docs/api 1218 | 1219 | Many methods require an OAuth access token which you can obtain 1220 | through authorize_redirect() and get_authenticated_user(). The 1221 | user returned through that process includes an 'access_token' 1222 | attribute that can be used to make authenticated requests via 1223 | this method. Example usage:: 1224 | 1225 | class MainHandler(tornado.web.RequestHandler, 1226 | tornado.auth.FacebookGraphMixin): 1227 | @tornado.web.authenticated 1228 | @tornado.web.asynchronous 1229 | def get(self): 1230 | self.facebook_request( 1231 | "/me/feed", 1232 | post_args={"message": "I am posting from my Tornado application!"}, 1233 | access_token=self.current_user["access_token"], 1234 | callback=self.async_callback(self._on_post)) 1235 | 1236 | def _on_post(self, new_entry): 1237 | if not new_entry: 1238 | # Call failed; perhaps missing permission? 1239 | self.authorize_redirect() 1240 | return 1241 | self.finish("Posted a message!") 1242 | 1243 | """ 1244 | url = "https://graph.facebook.com" + path 1245 | all_args = {} 1246 | if access_token: 1247 | all_args["access_token"] = access_token 1248 | all_args.update(args) 1249 | all_args.update(post_args or {}) 1250 | if all_args: url += "?" + urllib.urlencode(all_args) 1251 | callback = self.async_callback(self._on_facebook_request, callback) 1252 | http = httpclient.AsyncHTTPClient() 1253 | if post_args is not None: 1254 | http.fetch(url, method="POST", body=urllib.urlencode(post_args), 1255 | callback=callback) 1256 | else: 1257 | http.fetch(url, callback=callback) 1258 | 1259 | def _on_facebook_request(self, callback, response): 1260 | if response.error: 1261 | log.warning("Error response %s fetching %s", response.error, 1262 | response.request.url) 1263 | callback(None) 1264 | return 1265 | callback(escape.json_decode(response.body)) 1266 | 1267 | def _oauth_signature(consumer_token, method, url, parameters={}, token=None): 1268 | """Calculates the HMAC-SHA1 OAuth signature for the given request. 1269 | 1270 | See http://oauth.net/core/1.0/#signing_process 1271 | """ 1272 | parts = urlparse.urlparse(url) 1273 | scheme, netloc, path = parts[:3] 1274 | normalized_url = scheme.lower() + "://" + netloc.lower() + path 1275 | 1276 | base_elems = [] 1277 | base_elems.append(method.upper()) 1278 | base_elems.append(normalized_url) 1279 | base_elems.append("&".join("%s=%s" % (k, _oauth_escape(str(v))) 1280 | for k, v in sorted(parameters.items()))) 1281 | base_string = "&".join(_oauth_escape(e) for e in base_elems) 1282 | 1283 | key_elems = [consumer_token["secret"]] 1284 | key_elems.append(token["secret"] if token else "") 1285 | key = "&".join(key_elems) 1286 | 1287 | hash = hmac.new(key, base_string, hashlib.sha1) 1288 | return binascii.b2a_base64(hash.digest())[:-1] 1289 | 1290 | def _oauth10a_signature(consumer_token, method, url, parameters={}, token=None): 1291 | """Calculates the HMAC-SHA1 OAuth 1.0a signature for the given request. 1292 | 1293 | See http://oauth.net/core/1.0a/#signing_process 1294 | """ 1295 | parts = urlparse.urlparse(url) 1296 | scheme, netloc, path = parts[:3] 1297 | normalized_url = scheme.lower() + "://" + netloc.lower() + path 1298 | 1299 | base_elems = [] 1300 | base_elems.append(method.upper()) 1301 | base_elems.append(normalized_url) 1302 | base_elems.append("&".join("%s=%s" % (k, _oauth_escape(str(v))) 1303 | for k, v in sorted(parameters.items()))) 1304 | 1305 | base_string = "&".join(_oauth_escape(e) for e in base_elems) 1306 | key_elems = [urllib.quote(consumer_token["secret"], safe='~')] 1307 | key_elems.append(urllib.quote(token["secret"], safe='~') if token else "") 1308 | key = "&".join(key_elems) 1309 | 1310 | hash = hmac.new(key, base_string, hashlib.sha1) 1311 | return binascii.b2a_base64(hash.digest())[:-1] 1312 | 1313 | def _oauth_escape(val): 1314 | if isinstance(val, unicode): 1315 | val = val.encode("utf-8") 1316 | return urllib.quote(val, safe="~") 1317 | 1318 | 1319 | def _oauth_parse_response(body): 1320 | p = cgi.parse_qs(body, keep_blank_values=False) 1321 | token = dict(key=p["oauth_token"][0], secret=p["oauth_token_secret"][0]) 1322 | 1323 | # Add the extra parameters the Provider included to the token 1324 | special = ("oauth_token", "oauth_token_secret") 1325 | token.update((k, p[k][0]) for k in p if k not in special) 1326 | return token 1327 | 1328 | 1329 | -------------------------------------------------------------------------------- /bottle_auth/core/escape.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2009 Facebook 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | """Escaping/unescaping methods for HTML, JSON, URLs, and others. 18 | 19 | Also includes a few other miscellaneous string manipulation functions that 20 | have crept in over time. 21 | """ 22 | 23 | import htmlentitydefs 24 | import re 25 | import sys 26 | import urllib 27 | 28 | # Python3 compatibility: On python2.5, introduce the bytes alias from 2.6 29 | try: bytes 30 | except Exception: bytes = str 31 | 32 | try: 33 | from urlparse import parse_qs # Python 2.6+ 34 | except ImportError: 35 | from cgi import parse_qs 36 | 37 | # json module is in the standard library as of python 2.6; fall back to 38 | # simplejson if present for older versions. 39 | try: 40 | import json 41 | assert hasattr(json, "loads") and hasattr(json, "dumps") 42 | _json_decode = json.loads 43 | _json_encode = json.dumps 44 | except Exception: 45 | try: 46 | import simplejson 47 | _json_decode = lambda s: simplejson.loads(_unicode(s)) 48 | _json_encode = lambda v: simplejson.dumps(v) 49 | except ImportError: 50 | try: 51 | # For Google AppEngine 52 | from django.utils import simplejson 53 | _json_decode = lambda s: simplejson.loads(_unicode(s)) 54 | _json_encode = lambda v: simplejson.dumps(v) 55 | except ImportError: 56 | def _json_decode(s): 57 | raise NotImplementedError( 58 | "A JSON parser is required, e.g., simplejson at " 59 | "http://pypi.python.org/pypi/simplejson/") 60 | _json_encode = _json_decode 61 | 62 | 63 | _XHTML_ESCAPE_RE = re.compile('[&<>"]') 64 | _XHTML_ESCAPE_DICT = {'&': '&', '<': '<', '>': '>', '"': '"'} 65 | def xhtml_escape(value): 66 | """Escapes a string so it is valid within XML or XHTML.""" 67 | return _XHTML_ESCAPE_RE.sub(lambda match: _XHTML_ESCAPE_DICT[match.group(0)], 68 | to_basestring(value)) 69 | 70 | 71 | def xhtml_unescape(value): 72 | """Un-escapes an XML-escaped string.""" 73 | return re.sub(r"&(#?)(\w+?);", _convert_entity, _unicode(value)) 74 | 75 | 76 | def json_encode(value): 77 | """JSON-encodes the given Python object.""" 78 | # JSON permits but does not require forward slashes to be escaped. 79 | # This is useful when json data is emitted in a tags from prematurely terminating 81 | # the javscript. Some json libraries do this escaping by default, 82 | # although python's standard library does not, so we do it here. 83 | # http://stackoverflow.com/questions/1580647/json-why-are-forward-slashes-escaped 84 | return _json_encode(recursive_unicode(value)).replace("?@\[\]^`{|}~\s]))|(?:\((?:[^\s&()]|&|")*\)))+)""") 224 | 225 | 226 | def linkify(text, shorten=False, extra_params="", 227 | require_protocol=False, permitted_protocols=["http", "https"]): 228 | """Converts plain text into HTML with links. 229 | 230 | For example: ``linkify("Hello http://tornadoweb.org!")`` would return 231 | ``Hello http://tornadoweb.org!`` 232 | 233 | Parameters: 234 | 235 | shorten: Long urls will be shortened for display. 236 | 237 | extra_params: Extra text to include in the link tag, 238 | e.g. linkify(text, extra_params='rel="nofollow" class="external"') 239 | 240 | require_protocol: Only linkify urls which include a protocol. If this is 241 | False, urls such as www.facebook.com will also be linkified. 242 | 243 | permitted_protocols: List (or set) of protocols which should be linkified, 244 | e.g. linkify(text, permitted_protocols=["http", "ftp", "mailto"]). 245 | It is very unsafe to include protocols such as "javascript". 246 | """ 247 | if extra_params: 248 | extra_params = " " + extra_params.strip() 249 | 250 | def make_link(m): 251 | url = m.group(1) 252 | proto = m.group(2) 253 | if require_protocol and not proto: 254 | return url # not protocol, no linkify 255 | 256 | if proto and proto not in permitted_protocols: 257 | return url # bad protocol, no linkify 258 | 259 | href = m.group(1) 260 | if not proto: 261 | href = "http://" + href # no proto specified, use http 262 | 263 | params = extra_params 264 | 265 | # clip long urls. max_len is just an approximation 266 | max_len = 30 267 | if shorten and len(url) > max_len: 268 | before_clip = url 269 | if proto: 270 | proto_len = len(proto) + 1 + len(m.group(3) or "") # +1 for : 271 | else: 272 | proto_len = 0 273 | 274 | parts = url[proto_len:].split("/") 275 | if len(parts) > 1: 276 | # Grab the whole host part plus the first bit of the path 277 | # The path is usually not that interesting once shortened 278 | # (no more slug, etc), so it really just provides a little 279 | # extra indication of shortening. 280 | url = url[:proto_len] + parts[0] + "/" + \ 281 | parts[1][:8].split('?')[0].split('.')[0] 282 | 283 | if len(url) > max_len * 1.5: # still too long 284 | url = url[:max_len] 285 | 286 | if url != before_clip: 287 | amp = url.rfind('&') 288 | # avoid splitting html char entities 289 | if amp > max_len - 5: 290 | url = url[:amp] 291 | url += "..." 292 | 293 | if len(url) >= len(before_clip): 294 | url = before_clip 295 | else: 296 | # full url is visible on mouse-over (for those who don't 297 | # have a status bar, such as Safari by default) 298 | params += ' title="%s"' % href 299 | 300 | return u'%s' % (href, params, url) 301 | 302 | # First HTML-escape so that our strings are all safe. 303 | # The regex is modified to avoid character entites other than & so 304 | # that we won't pick up ", etc. 305 | text = _unicode(xhtml_escape(text)) 306 | return _URL_RE.sub(make_link, text) 307 | 308 | 309 | def _convert_entity(m): 310 | if m.group(1) == "#": 311 | try: 312 | return unichr(int(m.group(2))) 313 | except ValueError: 314 | return "&#%s;" % m.group(2) 315 | try: 316 | return _HTML_UNICODE_MAP[m.group(2)] 317 | except KeyError: 318 | return "&%s;" % m.group(2) 319 | 320 | 321 | def _build_unicode_map(): 322 | unicode_map = {} 323 | for name, value in htmlentitydefs.name2codepoint.iteritems(): 324 | unicode_map[name] = unichr(value) 325 | return unicode_map 326 | 327 | _HTML_UNICODE_MAP = _build_unicode_map() 328 | -------------------------------------------------------------------------------- /bottle_auth/core/exception.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | class AuthException(Exception): 6 | pass 7 | 8 | 9 | class UserDenied(AuthException): 10 | pass 11 | 12 | 13 | class NegotiationError(AuthException): 14 | pass 15 | -------------------------------------------------------------------------------- /bottle_auth/core/httpclient.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright 2008 Andi Albrecht 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """Implements the URL fetch API. 17 | 18 | http://code.google.com/appengine/docs/urlfetch/ 19 | """ 20 | 21 | import httplib 22 | import socket 23 | import urlparse 24 | 25 | # Constants 26 | GET = 'GET' 27 | POST = 'POST' 28 | HEAD = 'HEAD' 29 | PUT = 'PUT' 30 | DELETE = 'DELETE' 31 | 32 | MAX_REDIRECTS = 5 33 | 34 | REDIRECT_STATUSES = frozenset([ 35 | httplib.MOVED_PERMANENTLY, 36 | httplib.FOUND, 37 | httplib.SEE_OTHER, 38 | httplib.TEMPORARY_REDIRECT, 39 | ]) 40 | 41 | class SyncHTTPClient(object): 42 | def fetch(self, url, callback, method=GET, body=None): 43 | response = self._fetch(url, method=method, payload=body) 44 | callback(response) 45 | 46 | def _fetch(self, url, payload=None, method=GET, headers={}, allow_truncated=False): 47 | if method in [POST, PUT]: 48 | payload = payload or '' 49 | if 'Content-Type' not in headers: 50 | headers['Content-Type'] = 'application/x-www-form-urlencoded' 51 | else: 52 | payload = '' 53 | for redirect_number in xrange(MAX_REDIRECTS+1): 54 | scheme, host, path, params, query, fragment = urlparse.urlparse(url) 55 | try: 56 | if scheme == 'http': 57 | connection = httplib.HTTPConnection(host) 58 | elif scheme == 'https': 59 | connection = httplib.HTTPSConnection(host) 60 | else: 61 | raise InvalidURLError('Protocol \'%s\' is not supported.') 62 | 63 | if query != '': 64 | full_path = path + '?' + query 65 | else: 66 | full_path = path 67 | 68 | adjusted_headers = { 69 | 'Content-Length': len(payload), 70 | 'Host': host, 71 | 'Accept': '*/*', 72 | } 73 | for header in headers: 74 | adjusted_headers[header] = headers[header] 75 | 76 | try: 77 | connection.request(method, full_path, payload, 78 | adjusted_headers) 79 | http_response = connection.getresponse() 80 | http_response_data = http_response.read() 81 | finally: 82 | connection.close() 83 | 84 | if http_response.status in REDIRECT_STATUSES: 85 | newurl = http_response.getheader('Location', None) 86 | if newurl is None: 87 | raise DownloadError('Redirect is missing Location header.') 88 | else: 89 | url = urlparse.urljoin(url, newurl) 90 | method = 'GET' 91 | else: 92 | response = Response() 93 | response.body = http_response_data 94 | response.status_code = http_response.status 95 | response.request = Request(full_path) 96 | response.headers = {} 97 | for header_key, header_value in http_response.getheaders(): 98 | response.headers[header_key] = header_value 99 | return response 100 | 101 | except (httplib.error, socket.error, IOError), e: 102 | response = Response() 103 | response.request = Request(full_path) 104 | response.error = str(e) or 'unknown error' 105 | return response 106 | 107 | class Request(object): 108 | def __init__(self, url): 109 | self.url = url 110 | 111 | class Response(object): 112 | body = None 113 | content_was_truncated = False 114 | error = None 115 | status_code = -1 116 | headers = None 117 | 118 | AsyncHTTPClient = SyncHTTPClient 119 | 120 | class Error(Exception): 121 | """Base class for all URL fetch exceptions.""" 122 | 123 | 124 | class InvalidURLError(Error): 125 | """Invalid URL.""" 126 | 127 | 128 | class DownloadError(Error): 129 | """Download failed.""" 130 | 131 | 132 | class ResponseTooLargeError(Error): 133 | """Unused.""" 134 | 135 | -------------------------------------------------------------------------------- /bottle_auth/core/httputil.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright 2009 Facebook 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 6 | # not use this file except in compliance with the License. You may obtain 7 | # a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | """HTTP utility code shared by clients and servers.""" 18 | 19 | import logging 20 | import urllib 21 | import re 22 | 23 | # from tornado.util import b 24 | # Fake byte literal support: In python 2.6+, you can say b"foo" to get 25 | # a byte literal (str in 2.x, bytes in 3.x). There's no way to do this 26 | # in a way that supports 2.5, though, so we need a function wrapper 27 | # to convert our string literals. b() should only be applied to literal 28 | # latin1 strings. Once we drop support for 2.5, we can remove this function 29 | # and just use byte literals. 30 | if str is unicode: 31 | def b(s): 32 | return s.encode('latin1') 33 | bytes_type = bytes 34 | else: 35 | def b(s): 36 | return s 37 | bytes_type = str 38 | 39 | 40 | class HTTPHeaders(dict): 41 | """A dictionary that maintains Http-Header-Case for all keys. 42 | 43 | Supports multiple values per key via a pair of new methods, 44 | add() and get_list(). The regular dictionary interface returns a single 45 | value per key, with multiple values joined by a comma. 46 | 47 | >>> h = HTTPHeaders({"content-type": "text/html"}) 48 | >>> h.keys() 49 | ['Content-Type'] 50 | >>> h["Content-Type"] 51 | 'text/html' 52 | 53 | >>> h.add("Set-Cookie", "A=B") 54 | >>> h.add("Set-Cookie", "C=D") 55 | >>> h["set-cookie"] 56 | 'A=B,C=D' 57 | >>> h.get_list("set-cookie") 58 | ['A=B', 'C=D'] 59 | 60 | >>> for (k,v) in sorted(h.get_all()): 61 | ... print '%s: %s' % (k,v) 62 | ... 63 | Content-Type: text/html 64 | Set-Cookie: A=B 65 | Set-Cookie: C=D 66 | """ 67 | def __init__(self, *args, **kwargs): 68 | # Don't pass args or kwargs to dict.__init__, as it will bypass 69 | # our __setitem__ 70 | dict.__init__(self) 71 | self._as_list = {} 72 | self._last_key = None 73 | self.update(*args, **kwargs) 74 | 75 | # new public methods 76 | 77 | def add(self, name, value): 78 | """Adds a new value for the given key.""" 79 | norm_name = HTTPHeaders._normalize_name(name) 80 | self._last_key = norm_name 81 | if norm_name in self: 82 | # bypass our override of __setitem__ since it modifies _as_list 83 | dict.__setitem__(self, norm_name, self[norm_name] + ',' + value) 84 | self._as_list[norm_name].append(value) 85 | else: 86 | self[norm_name] = value 87 | 88 | def get_list(self, name): 89 | """Returns all values for the given header as a list.""" 90 | norm_name = HTTPHeaders._normalize_name(name) 91 | return self._as_list.get(norm_name, []) 92 | 93 | def get_all(self): 94 | """Returns an iterable of all (name, value) pairs. 95 | 96 | If a header has multiple values, multiple pairs will be 97 | returned with the same name. 98 | """ 99 | for name, list in self._as_list.iteritems(): 100 | for value in list: 101 | yield (name, value) 102 | 103 | def parse_line(self, line): 104 | """Updates the dictionary with a single header line. 105 | 106 | >>> h = HTTPHeaders() 107 | >>> h.parse_line("Content-Type: text/html") 108 | >>> h.get('content-type') 109 | 'text/html' 110 | """ 111 | if line[0].isspace(): 112 | # continuation of a multi-line header 113 | new_part = ' ' + line.lstrip() 114 | self._as_list[self._last_key][-1] += new_part 115 | dict.__setitem__(self, self._last_key, 116 | self[self._last_key] + new_part) 117 | else: 118 | name, value = line.split(":", 1) 119 | self.add(name, value.strip()) 120 | 121 | @classmethod 122 | def parse(cls, headers): 123 | """Returns a dictionary from HTTP header text. 124 | 125 | >>> h = HTTPHeaders.parse("Content-Type: text/html\\r\\nContent-Length: 42\\r\\n") 126 | >>> sorted(h.iteritems()) 127 | [('Content-Length', '42'), ('Content-Type', 'text/html')] 128 | """ 129 | h = cls() 130 | for line in headers.splitlines(): 131 | if line: 132 | h.parse_line(line) 133 | return h 134 | 135 | # dict implementation overrides 136 | 137 | def __setitem__(self, name, value): 138 | norm_name = HTTPHeaders._normalize_name(name) 139 | dict.__setitem__(self, norm_name, value) 140 | self._as_list[norm_name] = [value] 141 | 142 | def __getitem__(self, name): 143 | return dict.__getitem__(self, HTTPHeaders._normalize_name(name)) 144 | 145 | def __delitem__(self, name): 146 | norm_name = HTTPHeaders._normalize_name(name) 147 | dict.__delitem__(self, norm_name) 148 | del self._as_list[norm_name] 149 | 150 | def get(self, name, default=None): 151 | return dict.get(self, HTTPHeaders._normalize_name(name), default) 152 | 153 | def update(self, *args, **kwargs): 154 | # dict.update bypasses our __setitem__ 155 | for k, v in dict(*args, **kwargs).iteritems(): 156 | self[k] = v 157 | 158 | _NORMALIZED_HEADER_RE = re.compile(r'^[A-Z0-9][a-z0-9]*(-[A-Z0-9][a-z0-9]*)*$') 159 | _normalized_headers = {} 160 | 161 | @staticmethod 162 | def _normalize_name(name): 163 | """Converts a name to Http-Header-Case. 164 | 165 | >>> HTTPHeaders._normalize_name("coNtent-TYPE") 166 | 'Content-Type' 167 | """ 168 | try: 169 | return HTTPHeaders._normalized_headers[name] 170 | except KeyError: 171 | if HTTPHeaders._NORMALIZED_HEADER_RE.match(name): 172 | normalized = name 173 | else: 174 | normalized = "-".join([w.capitalize() for w in name.split("-")]) 175 | HTTPHeaders._normalized_headers[name] = normalized 176 | return normalized 177 | 178 | 179 | def url_concat(url, args): 180 | """Concatenate url and argument dictionary regardless of whether 181 | url has existing query parameters. 182 | 183 | >>> url_concat("http://example.com/foo?a=b", dict(c="d")) 184 | 'http://example.com/foo?a=b&c=d' 185 | """ 186 | if not args: return url 187 | if url[-1] not in ('?', '&'): 188 | url += '&' if ('?' in url) else '?' 189 | return url + urllib.urlencode(args) 190 | 191 | def parse_multipart_form_data(boundary, data, arguments, files): 192 | """Parses a multipart/form-data body. 193 | 194 | The boundary and data parameters are both byte strings. 195 | The dictionaries given in the arguments and files parameters 196 | will be updated with the contents of the body. 197 | """ 198 | # The standard allows for the boundary to be quoted in the header, 199 | # although it's rare (it happens at least for google app engine 200 | # xmpp). I think we're also supposed to handle backslash-escapes 201 | # here but I'll save that until we see a client that uses them 202 | # in the wild. 203 | if boundary.startswith(b('"')) and boundary.endswith(b('"')): 204 | boundary = boundary[1:-1] 205 | if data.endswith(b("\r\n")): 206 | footer_length = len(boundary) + 6 207 | else: 208 | footer_length = len(boundary) + 4 209 | parts = data[:-footer_length].split(b("--") + boundary + b("\r\n")) 210 | for part in parts: 211 | if not part: continue 212 | eoh = part.find(b("\r\n\r\n")) 213 | if eoh == -1: 214 | logging.warning("multipart/form-data missing headers") 215 | continue 216 | headers = HTTPHeaders.parse(part[:eoh].decode("utf-8")) 217 | disp_header = headers.get("Content-Disposition", "") 218 | disposition, disp_params = _parse_header(disp_header) 219 | if disposition != "form-data" or not part.endswith(b("\r\n")): 220 | logging.warning("Invalid multipart/form-data") 221 | continue 222 | value = part[eoh + 4:-2] 223 | if not disp_params.get("name"): 224 | logging.warning("multipart/form-data value missing name") 225 | continue 226 | name = disp_params["name"] 227 | if disp_params.get("filename"): 228 | ctype = headers.get("Content-Type", "application/unknown") 229 | files.setdefault(name, []).append(dict( 230 | filename=disp_params["filename"], body=value, 231 | content_type=ctype)) 232 | else: 233 | arguments.setdefault(name, []).append(value) 234 | 235 | 236 | # _parseparam and _parse_header are copied and modified from python2.7's cgi.py 237 | # The original 2.7 version of this code did not correctly support some 238 | # combinations of semicolons and double quotes. 239 | def _parseparam(s): 240 | while s[:1] == ';': 241 | s = s[1:] 242 | end = s.find(';') 243 | while end > 0 and (s.count('"', 0, end) - s.count('\\"', 0, end)) % 2: 244 | end = s.find(';', end + 1) 245 | if end < 0: 246 | end = len(s) 247 | f = s[:end] 248 | yield f.strip() 249 | s = s[end:] 250 | 251 | def _parse_header(line): 252 | """Parse a Content-type like header. 253 | 254 | Return the main content-type and a dictionary of options. 255 | 256 | """ 257 | parts = _parseparam(';' + line) 258 | key = parts.next() 259 | pdict = {} 260 | for p in parts: 261 | i = p.find('=') 262 | if i >= 0: 263 | name = p[:i].strip().lower() 264 | value = p[i+1:].strip() 265 | if len(value) >= 2 and value[0] == value[-1] == '"': 266 | value = value[1:-1] 267 | value = value.replace('\\\\', '\\').replace('\\"', '"') 268 | pdict[name] = value 269 | return key, pdict 270 | 271 | 272 | def doctests(): 273 | import doctest 274 | return doctest.DocTestSuite() 275 | 276 | if __name__ == "__main__": 277 | import doctest 278 | doctest.testmod() 279 | -------------------------------------------------------------------------------- /bottle_auth/custom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from bottle import redirect 4 | 5 | 6 | class Custom(object): 7 | def __init__(self, login_url="/login", 8 | callback_url="http://127.0.0.1:8000", 9 | field="username"): 10 | self.login_url = login_url 11 | self.callback_url = callback_url 12 | self.field = field 13 | 14 | def redirect(self, environ): 15 | return redirect(self.login_url) 16 | 17 | def get_user(self, environ): 18 | session = environ.get('beaker.session') 19 | if session.get(self.field): 20 | return session 21 | self.redirect(environ) 22 | -------------------------------------------------------------------------------- /bottle_auth/decorator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from functools import wraps 4 | 5 | from bottle import request 6 | 7 | 8 | def login(): 9 | def decorator(func): 10 | @wraps(func) 11 | def wrapper(auth, *args, **kwargs): 12 | try: 13 | auth.get_user(request.environ) 14 | return func(*args, **kwargs) 15 | except: 16 | return auth.redirect(request.environ) 17 | return wrapper 18 | return decorator 19 | -------------------------------------------------------------------------------- /bottle_auth/social/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avelino/bottle-auth/db07e526864aeac05ee68444b47e5db29540ce18/bottle_auth/social/__init__.py -------------------------------------------------------------------------------- /bottle_auth/social/facebook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*-' 3 | import logging 4 | 5 | from bottle import redirect 6 | from bottle_auth.core.exception import UserDenied, NegotiationError 7 | from bottle_auth.core.auth import FacebookGraphMixin, HTTPRedirect 8 | 9 | 10 | log = logging.getLogger('bottle-auth.facebook') 11 | 12 | 13 | class Facebook(object): 14 | 15 | PROFILE_IMAGE_URL = 'https://graph.facebook.com/{id}/picture?type=large' 16 | PROFILE_IMAGE_SMALL_URL = 'https://graph.facebook.com/{id}/picture' 17 | 18 | def __init__(self, key, secret, callback_url, scope='email'): 19 | self.settings = { 20 | 'facebook_api_key': key, 21 | 'facebook_secret': secret, 22 | } 23 | self.callback_url = callback_url 24 | self.scope = scope 25 | 26 | def redirect(self, environ): 27 | auth = FacebookGraphMixin(environ) 28 | try: 29 | auth.authorize_redirect( 30 | redirect_uri=self.callback_url, 31 | client_id=self.settings['facebook_api_key'], 32 | extra_params={'scope': self.scope}) 33 | 34 | except HTTPRedirect, e: 35 | log.debug('Redirecting Facebook user to {0}'.format(e.url)) 36 | return redirect(e.url) 37 | return None 38 | 39 | def get_user(self, environ): 40 | session = environ.get('beaker.session') 41 | if session.get("uid", None): 42 | return session 43 | 44 | auth = FacebookGraphMixin(environ) 45 | 46 | if auth.get_argument('error', None): 47 | log.debug('User denied attributes exchange') 48 | raise UserDenied() 49 | 50 | container = {} 51 | 52 | def get_user_callback(user): 53 | if not user: 54 | raise NegotiationError() 55 | 56 | container['uid'] = user.get('email') 57 | container['attrs'] = user 58 | container['parsed'] = { 59 | 'uid': user['id'], 60 | 'email': user.get('email'), 61 | 'username': user.get('username'), 62 | 'screen_name': user.get('name'), 63 | 'first_name': user.get('first_name'), 64 | 'last_name': user.get('last_name'), 65 | 'language': user.get('locale'), 66 | 'profile_url': user.get('link'), 67 | 'profile_image_small': self.PROFILE_IMAGE_SMALL_URL.format( 68 | id=user['id']), 69 | 'profile_image': self.PROFILE_IMAGE_URL.format(id=user['id']), 70 | } 71 | session.update(container) 72 | session.save() 73 | 74 | auth.get_authenticated_user( 75 | redirect_uri=self.callback_url, 76 | client_id=self.settings['facebook_api_key'], 77 | client_secret=self.settings['facebook_secret'], 78 | code=auth.get_argument('code'), 79 | callback=get_user_callback) 80 | 81 | return container 82 | 83 | def api(self, environ, path, args, access_token): 84 | auth = FacebookGraphMixin(environ) 85 | container = {} 86 | 87 | def callback(response): 88 | container['response'] = response 89 | 90 | auth.facebook_request(path, callback, access_token, args) 91 | return container.get('response') 92 | -------------------------------------------------------------------------------- /bottle_auth/social/google.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*-' 3 | import logging 4 | from urlparse import urlparse 5 | 6 | from bottle import redirect 7 | from bottle_auth.core.exception import UserDenied, NegotiationError 8 | from bottle_auth.core.auth import GoogleMixin, HTTPRedirect 9 | 10 | 11 | log = logging.getLogger('bottle-auth.google') 12 | 13 | 14 | class Google(object): 15 | def __init__(self, key, secret, callback_url, 16 | scope='name,email,language,username'): 17 | self.settings = { 18 | 'google_consumer_key': key, 19 | 'google_consumer_secret': secret, 20 | } 21 | self.callback_url = callback_url 22 | self.scope = scope 23 | 24 | def redirect(self, environ): 25 | auth = GoogleMixin(environ, self.settings) 26 | ax_attrs = self.scope.split(',') 27 | try: 28 | auth.authenticate_redirect( 29 | callback_uri=self.callback_url, 30 | ax_attrs=ax_attrs) 31 | except HTTPRedirect, e: 32 | log.debug('Redirecting Google user to {0}'.format(e.url)) 33 | return redirect(e.url) 34 | return None 35 | 36 | def get_user(self, environ): 37 | session = environ.get('beaker.session') 38 | if session.get("uid", None): 39 | return session 40 | 41 | auth = GoogleMixin(environ, self.settings) 42 | 43 | if auth.get_argument('error', None): 44 | log.debug('User denied attributes exchange') 45 | raise UserDenied() 46 | 47 | container = {} 48 | 49 | def get_user_callback(user): 50 | if not user: 51 | raise NegotiationError() 52 | 53 | container['uid'] = user['email'] 54 | container['attrs'] = user 55 | query_string = urlparse(user['claimed_id']).query 56 | params = dict( 57 | param.split('=') for param in query_string.split('&')) 58 | container['parsed'] = { 59 | 'uid': params['id'], 60 | 'email': user['email'], 61 | 'screen_name': user.get('first_name'), 62 | 'first_name': user.get('first_name'), 63 | 'last_name': user.get('last_name'), 64 | 'language': user.get('locale'), 65 | 'profile_url': None, 66 | 'profile_image_small': None, 67 | 'profile_image': None 68 | } 69 | session.update(container) 70 | session.save() 71 | 72 | auth.get_authenticated_user(get_user_callback) 73 | 74 | return container 75 | -------------------------------------------------------------------------------- /bottle_auth/social/twitter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*-' 3 | import logging 4 | import re 5 | 6 | from bottle import redirect 7 | from bottle_auth.core.exception import UserDenied, NegotiationError 8 | from bottle_auth.core.auth import TwitterMixin, HTTPRedirect 9 | 10 | log = logging.getLogger('bottle-auth.twitter') 11 | 12 | 13 | class Twitter(object): 14 | 15 | PROFILE_URL_BASE = 'https://twitter.com/' 16 | 17 | def __init__(self, key, secret, callback_url): 18 | self.settings = { 19 | 'twitter_consumer_key': key, 20 | 'twitter_consumer_secret': secret, 21 | } 22 | self.callback_url = callback_url 23 | 24 | def redirect(self, environ, cookie_monster): 25 | auth = TwitterMixin(environ, self.settings, cookie_monster) 26 | 27 | try: 28 | auth.authorize_redirect(self.callback_url) 29 | except HTTPRedirect, e: 30 | log.debug('Redirecting Twitter user to {0}'.format(e.url)) 31 | return redirect(e.url) 32 | except KeyError, e: 33 | log.warning('Negotiation error for Twitter user') 34 | raise NegotiationError 35 | return None 36 | 37 | def get_user(self, environ, cookie_monster): 38 | session = environ.get('beaker.session') 39 | if session.get("uid", None): 40 | return session 41 | 42 | auth = TwitterMixin(environ, self.settings, cookie_monster) 43 | 44 | if auth.get_argument('denied', None): 45 | log.debug('User denied attributes exchange') 46 | raise UserDenied() 47 | 48 | container = {} 49 | 50 | def get_user_callback(user): 51 | if not user: 52 | raise NegotiationError() 53 | 54 | container['uid'] = user['username'] 55 | container['attrs'] = user 56 | 57 | profile_image_small = user['profile_image_url_https'] 58 | profile_image = re.sub('_normal(?=.\w+$)', '', profile_image_small) 59 | 60 | container['parsed'] = { 61 | 'uid': user['id_str'], 62 | 'email': None, 63 | 'username': user['username'], 64 | 'screen_name': user['screen_name'], 65 | 'first_name': user.get('name'), 66 | 'last_name': None, 67 | 'language': user.get('lang'), 68 | 'profile_url': self.PROFILE_URL_BASE + user['username'], 69 | 'profile_image_small': profile_image_small, 70 | 'profile_image': profile_image, 71 | } 72 | session.update(container) 73 | session.save() 74 | 75 | auth.get_authenticated_user(get_user_callback) 76 | 77 | return container 78 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup, find_packages 4 | 5 | description = "Bottle authentication, for Personal, Google, Twitter and "\ 6 | "facebook." 7 | 8 | setup( 9 | name='bottle-auth', 10 | version="0.3.3", 11 | description=description, 12 | author="Thiago Avelino", 13 | author_email="thiago@avelino.xxx", 14 | url='https://github.com/avelino/bottle-auth', 15 | packages=find_packages(), 16 | package_dir={'bottle_auth': 'bottle_auth'}, 17 | 18 | install_requires=['webob', 'bottle', 'bottle-mongo', 'bottle-beaker'], 19 | classifiers=[ 20 | 'Environment :: Web Environment', 21 | 'Environment :: Plugins', 22 | 'Framework :: Bottle', 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Operating System :: OS Independent', 26 | 'Programming Language :: Python', 27 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 28 | 'Topic :: Software Development :: Libraries :: Python Modules' 29 | ], 30 | ) 31 | --------------------------------------------------------------------------------