OLD | NEW |
(Empty) | |
| 1 # Copyright 2015 Google Inc. All rights reserved. |
| 2 # |
| 3 # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 # you may not use this file except in compliance with the License. |
| 5 # You may obtain a copy of the License at |
| 6 # |
| 7 # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 # |
| 9 # Unless required by applicable law or agreed to in writing, software |
| 10 # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 # See the License for the specific language governing permissions and |
| 13 # limitations under the License. |
| 14 |
| 15 """Utilities for the Flask web framework |
| 16 |
| 17 Provides a Flask extension that makes using OAuth2 web server flow easier. |
| 18 The extension includes views that handle the entire auth flow and a |
| 19 ``@required`` decorator to automatically ensure that user credentials are |
| 20 available. |
| 21 |
| 22 |
| 23 Configuration |
| 24 ============= |
| 25 |
| 26 To configure, you'll need a set of OAuth2 web application credentials from the |
| 27 `Google Developer's Console <https://console.developers.google.com/project/_/\ |
| 28 apiui/credential>`__. |
| 29 |
| 30 .. code-block:: python |
| 31 |
| 32 from oauth2client.flask_util import UserOAuth2 |
| 33 |
| 34 app = Flask(__name__) |
| 35 |
| 36 app.config['SECRET_KEY'] = 'your-secret-key' |
| 37 |
| 38 app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_JSON'] = 'client_secrets.json' |
| 39 |
| 40 # or, specify the client id and secret separately |
| 41 app.config['GOOGLE_OAUTH2_CLIENT_ID'] = 'your-client-id' |
| 42 app.config['GOOGLE_OAUTH2_CLIENT_SECRET'] = 'your-client-secret' |
| 43 |
| 44 oauth2 = UserOAuth2(app) |
| 45 |
| 46 |
| 47 Usage |
| 48 ===== |
| 49 |
| 50 Once configured, you can use the :meth:`UserOAuth2.required` decorator to |
| 51 ensure that credentials are available within a view. |
| 52 |
| 53 .. code-block:: python |
| 54 :emphasize-lines: 3,7,10 |
| 55 |
| 56 # Note that app.route should be the outermost decorator. |
| 57 @app.route('/needs_credentials') |
| 58 @oauth2.required |
| 59 def example(): |
| 60 # http is authorized with the user's credentials and can be used |
| 61 # to make http calls. |
| 62 http = oauth2.http() |
| 63 |
| 64 # Or, you can access the credentials directly |
| 65 credentials = oauth2.credentials |
| 66 |
| 67 If you want credentials to be optional for a view, you can leave the decorator |
| 68 off and use :meth:`UserOAuth2.has_credentials` to check. |
| 69 |
| 70 .. code-block:: python |
| 71 :emphasize-lines: 3 |
| 72 |
| 73 @app.route('/optional') |
| 74 def optional(): |
| 75 if oauth2.has_credentials(): |
| 76 return 'Credentials found!' |
| 77 else: |
| 78 return 'No credentials!' |
| 79 |
| 80 |
| 81 When credentials are available, you can use :attr:`UserOAuth2.email` and |
| 82 :attr:`UserOAuth2.user_id` to access information from the `ID Token |
| 83 <https://developers.google.com/identity/protocols/OpenIDConnect?hl=en>`__, if |
| 84 available. |
| 85 |
| 86 .. code-block:: python |
| 87 :emphasize-lines: 4 |
| 88 |
| 89 @app.route('/info') |
| 90 @oauth2.required |
| 91 def info(): |
| 92 return "Hello, {} ({})".format(oauth2.email, oauth2.user_id) |
| 93 |
| 94 |
| 95 URLs & Trigging Authorization |
| 96 ============================= |
| 97 |
| 98 The extension will add two new routes to your application: |
| 99 |
| 100 * ``"oauth2.authorize"`` -> ``/oauth2authorize`` |
| 101 * ``"oauth2.callback"`` -> ``/oauth2callback`` |
| 102 |
| 103 When configuring your OAuth2 credentials on the Google Developer's Console, be |
| 104 sure to add ``http[s]://[your-app-url]/oauth2callback`` as an authorized |
| 105 callback url. |
| 106 |
| 107 Typically you don't not need to use these routes directly, just be sure to |
| 108 decorate any views that require credentials with ``@oauth2.required``. If |
| 109 needed, you can trigger authorization at any time by redirecting the user |
| 110 to the URL returned by :meth:`UserOAuth2.authorize_url`. |
| 111 |
| 112 .. code-block:: python |
| 113 :emphasize-lines: 3 |
| 114 |
| 115 @app.route('/login') |
| 116 def login(): |
| 117 return oauth2.authorize_url("/") |
| 118 |
| 119 |
| 120 Incremental Auth |
| 121 ================ |
| 122 |
| 123 This extension also supports `Incremental Auth <https://developers.google.com\ |
| 124 /identity/protocols/OAuth2WebServer?hl=en#incrementalAuth>`__. To enable it, |
| 125 configure the extension with ``include_granted_scopes``. |
| 126 |
| 127 .. code-block:: python |
| 128 |
| 129 oauth2 = UserOAuth2(app, include_granted_scopes=True) |
| 130 |
| 131 Then specify any additional scopes needed on the decorator, for example: |
| 132 |
| 133 .. code-block:: python |
| 134 :emphasize-lines: 2,7 |
| 135 |
| 136 @app.route('/drive') |
| 137 @oauth2.required(scopes=["https://www.googleapis.com/auth/drive"]) |
| 138 def requires_drive(): |
| 139 ... |
| 140 |
| 141 @app.route('/calendar') |
| 142 @oauth2.required(scopes=["https://www.googleapis.com/auth/calendar"]) |
| 143 def requires_calendar(): |
| 144 ... |
| 145 |
| 146 The decorator will ensure that the the user has authorized all specified scopes |
| 147 before allowing them to access the view, and will also ensure that credentials |
| 148 do not lose any previously authorized scopes. |
| 149 |
| 150 |
| 151 Storage |
| 152 ======= |
| 153 |
| 154 By default, the extension uses a Flask session-based storage solution. This |
| 155 means that credentials are only available for the duration of a session. It |
| 156 also means that with Flask's default configuration, the credentials will be |
| 157 visible in the session cookie. It's highly recommended to use database-backed |
| 158 session and to use https whenever handling user credentials. |
| 159 |
| 160 If you need the credentials to be available longer than a user session or |
| 161 available outside of a request context, you will need to implement your own |
| 162 :class:`oauth2client.Storage`. |
| 163 """ |
| 164 |
| 165 import hashlib |
| 166 import json |
| 167 import os |
| 168 import pickle |
| 169 from functools import wraps |
| 170 |
| 171 import six.moves.http_client as httplib |
| 172 import httplib2 |
| 173 |
| 174 try: |
| 175 from flask import Blueprint |
| 176 from flask import _app_ctx_stack |
| 177 from flask import current_app |
| 178 from flask import redirect |
| 179 from flask import request |
| 180 from flask import session |
| 181 from flask import url_for |
| 182 except ImportError: # pragma: NO COVER |
| 183 raise ImportError('The flask utilities require flask 0.9 or newer.') |
| 184 |
| 185 from oauth2client.client import FlowExchangeError |
| 186 from oauth2client.client import OAuth2Credentials |
| 187 from oauth2client.client import OAuth2WebServerFlow |
| 188 from oauth2client.client import Storage |
| 189 from oauth2client import clientsecrets |
| 190 |
| 191 |
| 192 __author__ = 'jonwayne@google.com (Jon Wayne Parrott)' |
| 193 |
| 194 _DEFAULT_SCOPES = ('email',) |
| 195 _CREDENTIALS_KEY = 'google_oauth2_credentials' |
| 196 _FLOW_KEY = 'google_oauth2_flow_{0}' |
| 197 _CSRF_KEY = 'google_oauth2_csrf_token' |
| 198 |
| 199 |
| 200 def _get_flow_for_token(csrf_token): |
| 201 """Retrieves the flow instance associated with a given CSRF token from |
| 202 the Flask session.""" |
| 203 flow_pickle = session.get( |
| 204 _FLOW_KEY.format(csrf_token), None) |
| 205 |
| 206 if flow_pickle is None: |
| 207 return None |
| 208 else: |
| 209 return pickle.loads(flow_pickle) |
| 210 |
| 211 |
| 212 class UserOAuth2(object): |
| 213 """Flask extension for making OAuth 2.0 easier. |
| 214 |
| 215 Configuration values: |
| 216 |
| 217 * ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` path to a client secrets json |
| 218 file, obtained from the credentials screen in the Google Developers |
| 219 console. |
| 220 * ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This |
| 221 is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` is not specifi
ed. |
| 222 * ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client |
| 223 secret. This is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` i
s not |
| 224 specified. |
| 225 |
| 226 If app is specified, all arguments will be passed along to init_app. |
| 227 |
| 228 If no app is specified, then you should call init_app in your application |
| 229 factory to finish initialization. |
| 230 """ |
| 231 |
| 232 def __init__(self, app=None, *args, **kwargs): |
| 233 self.app = app |
| 234 if app is not None: |
| 235 self.init_app(app, *args, **kwargs) |
| 236 |
| 237 def init_app(self, app, scopes=None, client_secrets_file=None, |
| 238 client_id=None, client_secret=None, authorize_callback=None, |
| 239 storage=None, **kwargs): |
| 240 """Initialize this extension for the given app. |
| 241 |
| 242 Arguments: |
| 243 app: A Flask application. |
| 244 scopes: Optional list of scopes to authorize. |
| 245 client_secrets_file: Path to a file containing client secrets. You |
| 246 can also specify the GOOGLE_OAUTH2_CLIENT_SECRETS_JSON config va
lue. |
| 247 client_id: If not specifying a client secrets file, specify the |
| 248 OAuth2 client id. You can also specify the |
| 249 GOOGLE_OAUTH2_CLIENT_ID config value. You must also provide a |
| 250 client secret. |
| 251 client_secret: The OAuth2 client secret. You can also specify the |
| 252 GOOGLE_OAUTH2_CLIENT_SECRET config value. |
| 253 authorize_callback: A function that is executed after successful |
| 254 user authorization. |
| 255 storage: A oauth2client.client.Storage subclass for storing the |
| 256 credentials. By default, this is a Flask session based storage. |
| 257 kwargs: Any additional args are passed along to the Flow |
| 258 constructor. |
| 259 """ |
| 260 self.app = app |
| 261 self.authorize_callback = authorize_callback |
| 262 self.flow_kwargs = kwargs |
| 263 |
| 264 if storage is None: |
| 265 storage = FlaskSessionStorage() |
| 266 self.storage = storage |
| 267 |
| 268 if scopes is None: |
| 269 scopes = app.config.get('GOOGLE_OAUTH2_SCOPES', _DEFAULT_SCOPES) |
| 270 self.scopes = scopes |
| 271 |
| 272 self._load_config(client_secrets_file, client_id, client_secret) |
| 273 |
| 274 app.register_blueprint(self._create_blueprint()) |
| 275 |
| 276 def _load_config(self, client_secrets_file, client_id, client_secret): |
| 277 """Loads oauth2 configuration in order of priority. |
| 278 |
| 279 Priority: |
| 280 1. Config passed to the constructor or init_app. |
| 281 2. Config passed via the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE app |
| 282 config. |
| 283 3. Config passed via the GOOGLE_OAUTH2_CLIENT_ID and |
| 284 GOOGLE_OAUTH2_CLIENT_SECRET app config. |
| 285 |
| 286 Raises: |
| 287 ValueError if no config could be found. |
| 288 """ |
| 289 if client_id and client_secret: |
| 290 self.client_id, self.client_secret = client_id, client_secret |
| 291 return |
| 292 |
| 293 if client_secrets_file: |
| 294 self._load_client_secrets(client_secrets_file) |
| 295 return |
| 296 |
| 297 if 'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE' in self.app.config: |
| 298 self._load_client_secrets( |
| 299 self.app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_FILE']) |
| 300 return |
| 301 |
| 302 try: |
| 303 self.client_id, self.client_secret = ( |
| 304 self.app.config['GOOGLE_OAUTH2_CLIENT_ID'], |
| 305 self.app.config['GOOGLE_OAUTH2_CLIENT_SECRET']) |
| 306 except KeyError: |
| 307 raise ValueError( |
| 308 'OAuth2 configuration could not be found. Either specify the ' |
| 309 'client_secrets_file or client_id and client_secret or set the' |
| 310 'app configuration variables ' |
| 311 'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE or ' |
| 312 'GOOGLE_OAUTH2_CLIENT_ID and GOOGLE_OAUTH2_CLIENT_SECRET.') |
| 313 |
| 314 def _load_client_secrets(self, filename): |
| 315 """Loads client secrets from the given filename.""" |
| 316 client_type, client_info = clientsecrets.loadfile(filename) |
| 317 if client_type != clientsecrets.TYPE_WEB: |
| 318 raise ValueError( |
| 319 'The flow specified in {0} is not supported.'.format( |
| 320 client_type)) |
| 321 |
| 322 self.client_id = client_info['client_id'] |
| 323 self.client_secret = client_info['client_secret'] |
| 324 |
| 325 def _make_flow(self, return_url=None, **kwargs): |
| 326 """Creates a Web Server Flow""" |
| 327 # Generate a CSRF token to prevent malicious requests. |
| 328 csrf_token = hashlib.sha256(os.urandom(1024)).hexdigest() |
| 329 |
| 330 session[_CSRF_KEY] = csrf_token |
| 331 |
| 332 state = json.dumps({ |
| 333 'csrf_token': csrf_token, |
| 334 'return_url': return_url |
| 335 }) |
| 336 |
| 337 kw = self.flow_kwargs.copy() |
| 338 kw.update(kwargs) |
| 339 |
| 340 extra_scopes = kw.pop('scopes', []) |
| 341 scopes = set(self.scopes).union(set(extra_scopes)) |
| 342 |
| 343 flow = OAuth2WebServerFlow( |
| 344 client_id=self.client_id, |
| 345 client_secret=self.client_secret, |
| 346 scope=scopes, |
| 347 state=state, |
| 348 redirect_uri=url_for('oauth2.callback', _external=True), |
| 349 **kw) |
| 350 |
| 351 flow_key = _FLOW_KEY.format(csrf_token) |
| 352 session[flow_key] = pickle.dumps(flow) |
| 353 |
| 354 return flow |
| 355 |
| 356 def _create_blueprint(self): |
| 357 bp = Blueprint('oauth2', __name__) |
| 358 bp.add_url_rule('/oauth2authorize', 'authorize', self.authorize_view) |
| 359 bp.add_url_rule('/oauth2callback', 'callback', self.callback_view) |
| 360 |
| 361 return bp |
| 362 |
| 363 def authorize_view(self): |
| 364 """Flask view that starts the authorization flow. |
| 365 |
| 366 Starts flow by redirecting the user to the OAuth2 provider. |
| 367 """ |
| 368 args = request.args.to_dict() |
| 369 |
| 370 # Scopes will be passed as mutliple args, and to_dict() will only |
| 371 # return one. So, we use getlist() to get all of the scopes. |
| 372 args['scopes'] = request.args.getlist('scopes') |
| 373 |
| 374 return_url = args.pop('return_url', None) |
| 375 if return_url is None: |
| 376 return_url = request.referrer or '/' |
| 377 |
| 378 flow = self._make_flow(return_url=return_url, **args) |
| 379 auth_url = flow.step1_get_authorize_url() |
| 380 |
| 381 return redirect(auth_url) |
| 382 |
| 383 def callback_view(self): |
| 384 """Flask view that handles the user's return from OAuth2 provider. |
| 385 |
| 386 On return, exchanges the authorization code for credentials and stores |
| 387 the credentials. |
| 388 """ |
| 389 if 'error' in request.args: |
| 390 reason = request.args.get( |
| 391 'error_description', request.args.get('error', '')) |
| 392 return ('Authorization failed: {0}'.format(reason), |
| 393 httplib.BAD_REQUEST) |
| 394 |
| 395 try: |
| 396 encoded_state = request.args['state'] |
| 397 server_csrf = session[_CSRF_KEY] |
| 398 code = request.args['code'] |
| 399 except KeyError: |
| 400 return 'Invalid request', httplib.BAD_REQUEST |
| 401 |
| 402 try: |
| 403 state = json.loads(encoded_state) |
| 404 client_csrf = state['csrf_token'] |
| 405 return_url = state['return_url'] |
| 406 except (ValueError, KeyError): |
| 407 return 'Invalid request state', httplib.BAD_REQUEST |
| 408 |
| 409 if client_csrf != server_csrf: |
| 410 return 'Invalid request state', httplib.BAD_REQUEST |
| 411 |
| 412 flow = _get_flow_for_token(server_csrf) |
| 413 |
| 414 if flow is None: |
| 415 return 'Invalid request state', httplib.BAD_REQUEST |
| 416 |
| 417 # Exchange the auth code for credentials. |
| 418 try: |
| 419 credentials = flow.step2_exchange(code) |
| 420 except FlowExchangeError as exchange_error: |
| 421 current_app.logger.exception(exchange_error) |
| 422 content = 'An error occurred: {0}'.format(exchange_error) |
| 423 return content, httplib.BAD_REQUEST |
| 424 |
| 425 # Save the credentials to the storage. |
| 426 self.storage.put(credentials) |
| 427 |
| 428 if self.authorize_callback: |
| 429 self.authorize_callback(credentials) |
| 430 |
| 431 return redirect(return_url) |
| 432 |
| 433 @property |
| 434 def credentials(self): |
| 435 """The credentials for the current user or None if unavailable.""" |
| 436 ctx = _app_ctx_stack.top |
| 437 |
| 438 if not hasattr(ctx, _CREDENTIALS_KEY): |
| 439 ctx.google_oauth2_credentials = self.storage.get() |
| 440 |
| 441 return ctx.google_oauth2_credentials |
| 442 |
| 443 def has_credentials(self): |
| 444 """Returns True if there are valid credentials for the current user.""" |
| 445 return self.credentials and not self.credentials.invalid |
| 446 |
| 447 @property |
| 448 def email(self): |
| 449 """Returns the user's email address or None if there are no credentials. |
| 450 |
| 451 The email address is provided by the current credentials' id_token. |
| 452 This should not be used as unique identifier as the user can change |
| 453 their email. If you need a unique identifier, use user_id. |
| 454 """ |
| 455 if not self.credentials: |
| 456 return None |
| 457 try: |
| 458 return self.credentials.id_token['email'] |
| 459 except KeyError: |
| 460 current_app.logger.error( |
| 461 'Invalid id_token {0}'.format(self.credentials.id_token)) |
| 462 |
| 463 @property |
| 464 def user_id(self): |
| 465 """Returns the a unique identifier for the user |
| 466 |
| 467 Returns None if there are no credentials. |
| 468 |
| 469 The id is provided by the current credentials' id_token. |
| 470 """ |
| 471 if not self.credentials: |
| 472 return None |
| 473 try: |
| 474 return self.credentials.id_token['sub'] |
| 475 except KeyError: |
| 476 current_app.logger.error( |
| 477 'Invalid id_token {0}'.format(self.credentials.id_token)) |
| 478 |
| 479 def authorize_url(self, return_url, **kwargs): |
| 480 """Creates a URL that can be used to start the authorization flow. |
| 481 |
| 482 When the user is directed to the URL, the authorization flow will |
| 483 begin. Once complete, the user will be redirected to the specified |
| 484 return URL. |
| 485 |
| 486 Any kwargs are passed into the flow constructor. |
| 487 """ |
| 488 return url_for('oauth2.authorize', return_url=return_url, **kwargs) |
| 489 |
| 490 def required(self, decorated_function=None, scopes=None, |
| 491 **decorator_kwargs): |
| 492 """Decorator to require OAuth2 credentials for a view. |
| 493 |
| 494 If credentials are not available for the current user, then they will |
| 495 be redirected to the authorization flow. Once complete, the user will |
| 496 be redirected back to the original page. |
| 497 """ |
| 498 |
| 499 def curry_wrapper(wrapped_function): |
| 500 @wraps(wrapped_function) |
| 501 def required_wrapper(*args, **kwargs): |
| 502 return_url = decorator_kwargs.pop('return_url', request.url) |
| 503 |
| 504 requested_scopes = set(self.scopes) |
| 505 if scopes is not None: |
| 506 requested_scopes |= set(scopes) |
| 507 if self.has_credentials(): |
| 508 requested_scopes |= self.credentials.scopes |
| 509 |
| 510 requested_scopes = list(requested_scopes) |
| 511 |
| 512 # Does the user have credentials and does the credentials have |
| 513 # all of the needed scopes? |
| 514 if (self.has_credentials() and |
| 515 self.credentials.has_scopes(requested_scopes)): |
| 516 return wrapped_function(*args, **kwargs) |
| 517 # Otherwise, redirect to authorization |
| 518 else: |
| 519 auth_url = self.authorize_url( |
| 520 return_url, |
| 521 scopes=requested_scopes, |
| 522 **decorator_kwargs) |
| 523 |
| 524 return redirect(auth_url) |
| 525 |
| 526 return required_wrapper |
| 527 |
| 528 if decorated_function: |
| 529 return curry_wrapper(decorated_function) |
| 530 else: |
| 531 return curry_wrapper |
| 532 |
| 533 def http(self, *args, **kwargs): |
| 534 """Returns an authorized http instance. |
| 535 |
| 536 Can only be called if there are valid credentials for the user, such |
| 537 as inside of a view that is decorated with @required. |
| 538 |
| 539 Args: |
| 540 *args: Positional arguments passed to httplib2.Http constructor. |
| 541 **kwargs: Positional arguments passed to httplib2.Http constructor. |
| 542 |
| 543 Raises: |
| 544 ValueError if no credentials are available. |
| 545 """ |
| 546 if not self.credentials: |
| 547 raise ValueError('No credentials available.') |
| 548 return self.credentials.authorize(httplib2.Http(*args, **kwargs)) |
| 549 |
| 550 |
| 551 class FlaskSessionStorage(Storage): |
| 552 """Storage implementation that uses Flask sessions. |
| 553 |
| 554 Note that flask's default sessions are signed but not encrypted. Users |
| 555 can see their own credentials and non-https connections can intercept user |
| 556 credentials. We strongly recommend using a server-side session |
| 557 implementation. |
| 558 """ |
| 559 |
| 560 def locked_get(self): |
| 561 serialized = session.get(_CREDENTIALS_KEY) |
| 562 |
| 563 if serialized is None: |
| 564 return None |
| 565 |
| 566 credentials = OAuth2Credentials.from_json(serialized) |
| 567 credentials.set_store(self) |
| 568 |
| 569 return credentials |
| 570 |
| 571 def locked_put(self, credentials): |
| 572 session[_CREDENTIALS_KEY] = credentials.to_json() |
| 573 |
| 574 def locked_delete(self): |
| 575 if _CREDENTIALS_KEY in session: |
| 576 del session[_CREDENTIALS_KEY] |
OLD | NEW |