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
17Provides a Flask extension that makes using OAuth2 web server flow easier.
18The extension includes views that handle the entire auth flow and a
19``@required`` decorator to automatically ensure that user credentials are
20available.
21
22
23Configuration
24=============
25
26To configure, you'll need a set of OAuth2 web application credentials from the
27`Google Developer's Console <https://console.developers.google.com/project/_/\
28apiui/credential>`__.
29
30.. code-block:: python
31
32    from oauth2client.contrib.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_FILE'] = '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
47Usage
48=====
49
50Once configured, you can use the :meth:`UserOAuth2.required` decorator to
51ensure 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
67If you want credentials to be optional for a view, you can leave the decorator
68off 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
81When 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
84available.
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
95URLs & Trigging Authorization
96=============================
97
98The extension will add two new routes to your application:
99
100    * ``"oauth2.authorize"`` -> ``/oauth2authorize``
101    * ``"oauth2.callback"`` -> ``/oauth2callback``
102
103When configuring your OAuth2 credentials on the Google Developer's Console, be
104sure to add ``http[s]://[your-app-url]/oauth2callback`` as an authorized
105callback url.
106
107Typically you don't not need to use these routes directly, just be sure to
108decorate any views that require credentials with ``@oauth2.required``. If
109needed, you can trigger authorization at any time by redirecting the user
110to 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
120Incremental Auth
121================
122
123This extension also supports `Incremental Auth <https://developers.google.com\
124/identity/protocols/OAuth2WebServer?hl=en#incrementalAuth>`__. To enable it,
125configure the extension with ``include_granted_scopes``.
126
127.. code-block:: python
128
129    oauth2 = UserOAuth2(app, include_granted_scopes=True)
130
131Then 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
146The decorator will ensure that the the user has authorized all specified scopes
147before allowing them to access the view, and will also ensure that credentials
148do not lose any previously authorized scopes.
149
150
151Storage
152=======
153
154By default, the extension uses a Flask session-based storage solution. This
155means that credentials are only available for the duration of a session. It
156also means that with Flask's default configuration, the credentials will be
157visible in the session cookie. It's highly recommended to use database-backed
158session and to use https whenever handling user credentials.
159
160If you need the credentials to be available longer than a user session or
161available outside of a request context, you will need to implement your own
162:class:`oauth2client.Storage`.
163"""
164
165from functools import wraps
166import hashlib
167import json
168import os
169import pickle
170
171try:
172    from flask import Blueprint
173    from flask import _app_ctx_stack
174    from flask import current_app
175    from flask import redirect
176    from flask import request
177    from flask import session
178    from flask import url_for
179except ImportError:  # pragma: NO COVER
180    raise ImportError('The flask utilities require flask 0.9 or newer.')
181
182import httplib2
183import six.moves.http_client as httplib
184
185from oauth2client import client
186from oauth2client import clientsecrets
187from oauth2client.contrib import dictionary_storage
188
189
190__author__ = 'jonwayne@google.com (Jon Wayne Parrott)'
191
192_DEFAULT_SCOPES = ('email',)
193_CREDENTIALS_KEY = 'google_oauth2_credentials'
194_FLOW_KEY = 'google_oauth2_flow_{0}'
195_CSRF_KEY = 'google_oauth2_csrf_token'
196
197
198def _get_flow_for_token(csrf_token):
199    """Retrieves the flow instance associated with a given CSRF token from
200    the Flask session."""
201    flow_pickle = session.pop(
202        _FLOW_KEY.format(csrf_token), None)
203
204    if flow_pickle is None:
205        return None
206    else:
207        return pickle.loads(flow_pickle)
208
209
210class UserOAuth2(object):
211    """Flask extension for making OAuth 2.0 easier.
212
213    Configuration values:
214
215        * ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` path to a client secrets json
216          file, obtained from the credentials screen in the Google Developers
217          console.
218        * ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This
219          is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` is not
220          specified.
221        * ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client
222          secret. This is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE``
223          is not specified.
224
225    If app is specified, all arguments will be passed along to init_app.
226
227    If no app is specified, then you should call init_app in your application
228    factory to finish initialization.
229    """
230
231    def __init__(self, app=None, *args, **kwargs):
232        self.app = app
233        if app is not None:
234            self.init_app(app, *args, **kwargs)
235
236    def init_app(self, app, scopes=None, client_secrets_file=None,
237                 client_id=None, client_secret=None, authorize_callback=None,
238                 storage=None, **kwargs):
239        """Initialize this extension for the given app.
240
241        Arguments:
242            app: A Flask application.
243            scopes: Optional list of scopes to authorize.
244            client_secrets_file: Path to a file containing client secrets. You
245                can also specify the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE config
246                value.
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 = dictionary_storage.DictionaryStorage(
266                session, key=_CREDENTIALS_KEY)
267        self.storage = storage
268
269        if scopes is None:
270            scopes = app.config.get('GOOGLE_OAUTH2_SCOPES', _DEFAULT_SCOPES)
271        self.scopes = scopes
272
273        self._load_config(client_secrets_file, client_id, client_secret)
274
275        app.register_blueprint(self._create_blueprint())
276
277    def _load_config(self, client_secrets_file, client_id, client_secret):
278        """Loads oauth2 configuration in order of priority.
279
280        Priority:
281            1. Config passed to the constructor or init_app.
282            2. Config passed via the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE app
283               config.
284            3. Config passed via the GOOGLE_OAUTH2_CLIENT_ID and
285               GOOGLE_OAUTH2_CLIENT_SECRET app config.
286
287        Raises:
288            ValueError if no config could be found.
289        """
290        if client_id and client_secret:
291            self.client_id, self.client_secret = client_id, client_secret
292            return
293
294        if client_secrets_file:
295            self._load_client_secrets(client_secrets_file)
296            return
297
298        if 'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE' in self.app.config:
299            self._load_client_secrets(
300                self.app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_FILE'])
301            return
302
303        try:
304            self.client_id, self.client_secret = (
305                self.app.config['GOOGLE_OAUTH2_CLIENT_ID'],
306                self.app.config['GOOGLE_OAUTH2_CLIENT_SECRET'])
307        except KeyError:
308            raise ValueError(
309                'OAuth2 configuration could not be found. Either specify the '
310                'client_secrets_file or client_id and client_secret or set '
311                'the app configuration variables '
312                'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE or '
313                'GOOGLE_OAUTH2_CLIENT_ID and GOOGLE_OAUTH2_CLIENT_SECRET.')
314
315    def _load_client_secrets(self, filename):
316        """Loads client secrets from the given filename."""
317        client_type, client_info = clientsecrets.loadfile(filename)
318        if client_type != clientsecrets.TYPE_WEB:
319            raise ValueError(
320                'The flow specified in {0} is not supported.'.format(
321                    client_type))
322
323        self.client_id = client_info['client_id']
324        self.client_secret = client_info['client_secret']
325
326    def _make_flow(self, return_url=None, **kwargs):
327        """Creates a Web Server Flow"""
328        # Generate a CSRF token to prevent malicious requests.
329        csrf_token = hashlib.sha256(os.urandom(1024)).hexdigest()
330
331        session[_CSRF_KEY] = csrf_token
332
333        state = json.dumps({
334            'csrf_token': csrf_token,
335            'return_url': return_url
336        })
337
338        kw = self.flow_kwargs.copy()
339        kw.update(kwargs)
340
341        extra_scopes = kw.pop('scopes', [])
342        scopes = set(self.scopes).union(set(extra_scopes))
343
344        flow = client.OAuth2WebServerFlow(
345            client_id=self.client_id,
346            client_secret=self.client_secret,
347            scope=scopes,
348            state=state,
349            redirect_uri=url_for('oauth2.callback', _external=True),
350            **kw)
351
352        flow_key = _FLOW_KEY.format(csrf_token)
353        session[flow_key] = pickle.dumps(flow)
354
355        return flow
356
357    def _create_blueprint(self):
358        bp = Blueprint('oauth2', __name__)
359        bp.add_url_rule('/oauth2authorize', 'authorize', self.authorize_view)
360        bp.add_url_rule('/oauth2callback', 'callback', self.callback_view)
361
362        return bp
363
364    def authorize_view(self):
365        """Flask view that starts the authorization flow.
366
367        Starts flow by redirecting the user to the OAuth2 provider.
368        """
369        args = request.args.to_dict()
370
371        # Scopes will be passed as mutliple args, and to_dict() will only
372        # return one. So, we use getlist() to get all of the scopes.
373        args['scopes'] = request.args.getlist('scopes')
374
375        return_url = args.pop('return_url', None)
376        if return_url is None:
377            return_url = request.referrer or '/'
378
379        flow = self._make_flow(return_url=return_url, **args)
380        auth_url = flow.step1_get_authorize_url()
381
382        return redirect(auth_url)
383
384    def callback_view(self):
385        """Flask view that handles the user's return from OAuth2 provider.
386
387        On return, exchanges the authorization code for credentials and stores
388        the credentials.
389        """
390        if 'error' in request.args:
391            reason = request.args.get(
392                'error_description', request.args.get('error', ''))
393            return ('Authorization failed: {0}'.format(reason),
394                    httplib.BAD_REQUEST)
395
396        try:
397            encoded_state = request.args['state']
398            server_csrf = session[_CSRF_KEY]
399            code = request.args['code']
400        except KeyError:
401            return 'Invalid request', httplib.BAD_REQUEST
402
403        try:
404            state = json.loads(encoded_state)
405            client_csrf = state['csrf_token']
406            return_url = state['return_url']
407        except (ValueError, KeyError):
408            return 'Invalid request state', httplib.BAD_REQUEST
409
410        if client_csrf != server_csrf:
411            return 'Invalid request state', httplib.BAD_REQUEST
412
413        flow = _get_flow_for_token(server_csrf)
414
415        if flow is None:
416            return 'Invalid request state', httplib.BAD_REQUEST
417
418        # Exchange the auth code for credentials.
419        try:
420            credentials = flow.step2_exchange(code)
421        except client.FlowExchangeError as exchange_error:
422            current_app.logger.exception(exchange_error)
423            content = 'An error occurred: {0}'.format(exchange_error)
424            return content, httplib.BAD_REQUEST
425
426        # Save the credentials to the storage.
427        self.storage.put(credentials)
428
429        if self.authorize_callback:
430            self.authorize_callback(credentials)
431
432        return redirect(return_url)
433
434    @property
435    def credentials(self):
436        """The credentials for the current user or None if unavailable."""
437        ctx = _app_ctx_stack.top
438
439        if not hasattr(ctx, _CREDENTIALS_KEY):
440            ctx.google_oauth2_credentials = self.storage.get()
441
442        return ctx.google_oauth2_credentials
443
444    def has_credentials(self):
445        """Returns True if there are valid credentials for the current user."""
446        if not self.credentials:
447            return False
448        # Is the access token expired? If so, do we have an refresh token?
449        elif (self.credentials.access_token_expired and
450                not self.credentials.refresh_token):
451            return False
452        else:
453            return True
454
455    @property
456    def email(self):
457        """Returns the user's email address or None if there are no credentials.
458
459        The email address is provided by the current credentials' id_token.
460        This should not be used as unique identifier as the user can change
461        their email. If you need a unique identifier, use user_id.
462        """
463        if not self.credentials:
464            return None
465        try:
466            return self.credentials.id_token['email']
467        except KeyError:
468            current_app.logger.error(
469                'Invalid id_token {0}'.format(self.credentials.id_token))
470
471    @property
472    def user_id(self):
473        """Returns the a unique identifier for the user
474
475        Returns None if there are no credentials.
476
477        The id is provided by the current credentials' id_token.
478        """
479        if not self.credentials:
480            return None
481        try:
482            return self.credentials.id_token['sub']
483        except KeyError:
484            current_app.logger.error(
485                'Invalid id_token {0}'.format(self.credentials.id_token))
486
487    def authorize_url(self, return_url, **kwargs):
488        """Creates a URL that can be used to start the authorization flow.
489
490        When the user is directed to the URL, the authorization flow will
491        begin. Once complete, the user will be redirected to the specified
492        return URL.
493
494        Any kwargs are passed into the flow constructor.
495        """
496        return url_for('oauth2.authorize', return_url=return_url, **kwargs)
497
498    def required(self, decorated_function=None, scopes=None,
499                 **decorator_kwargs):
500        """Decorator to require OAuth2 credentials for a view.
501
502        If credentials are not available for the current user, then they will
503        be redirected to the authorization flow. Once complete, the user will
504        be redirected back to the original page.
505        """
506
507        def curry_wrapper(wrapped_function):
508            @wraps(wrapped_function)
509            def required_wrapper(*args, **kwargs):
510                return_url = decorator_kwargs.pop('return_url', request.url)
511
512                requested_scopes = set(self.scopes)
513                if scopes is not None:
514                    requested_scopes |= set(scopes)
515                if self.has_credentials():
516                    requested_scopes |= self.credentials.scopes
517
518                requested_scopes = list(requested_scopes)
519
520                # Does the user have credentials and does the credentials have
521                # all of the needed scopes?
522                if (self.has_credentials() and
523                        self.credentials.has_scopes(requested_scopes)):
524                    return wrapped_function(*args, **kwargs)
525                # Otherwise, redirect to authorization
526                else:
527                    auth_url = self.authorize_url(
528                        return_url,
529                        scopes=requested_scopes,
530                        **decorator_kwargs)
531
532                    return redirect(auth_url)
533
534            return required_wrapper
535
536        if decorated_function:
537            return curry_wrapper(decorated_function)
538        else:
539            return curry_wrapper
540
541    def http(self, *args, **kwargs):
542        """Returns an authorized http instance.
543
544        Can only be called if there are valid credentials for the user, such
545        as inside of a view that is decorated with @required.
546
547        Args:
548            *args: Positional arguments passed to httplib2.Http constructor.
549            **kwargs: Positional arguments passed to httplib2.Http constructor.
550
551        Raises:
552            ValueError if no credentials are available.
553        """
554        if not self.credentials:
555            raise ValueError('No credentials available.')
556        return self.credentials.authorize(httplib2.Http(*args, **kwargs))
557