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
179    import markupsafe
180except ImportError:  # pragma: NO COVER
181    raise ImportError('The flask utilities require flask 0.9 or newer.')
182
183import six.moves.http_client as httplib
184
185from oauth2client import client
186from oauth2client import clientsecrets
187from oauth2client import transport
188from oauth2client.contrib import dictionary_storage
189
190
191_DEFAULT_SCOPES = ('email',)
192_CREDENTIALS_KEY = 'google_oauth2_credentials'
193_FLOW_KEY = 'google_oauth2_flow_{0}'
194_CSRF_KEY = 'google_oauth2_csrf_token'
195
196
197def _get_flow_for_token(csrf_token):
198    """Retrieves the flow instance associated with a given CSRF token from
199    the Flask session."""
200    flow_pickle = session.pop(
201        _FLOW_KEY.format(csrf_token), None)
202
203    if flow_pickle is None:
204        return None
205    else:
206        return pickle.loads(flow_pickle)
207
208
209class UserOAuth2(object):
210    """Flask extension for making OAuth 2.0 easier.
211
212    Configuration values:
213
214        * ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` path to a client secrets json
215          file, obtained from the credentials screen in the Google Developers
216          console.
217        * ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This
218          is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` is not
219          specified.
220        * ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client
221          secret. This is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE``
222          is not specified.
223
224    If app is specified, all arguments will be passed along to init_app.
225
226    If no app is specified, then you should call init_app in your application
227    factory to finish initialization.
228    """
229
230    def __init__(self, app=None, *args, **kwargs):
231        self.app = app
232        if app is not None:
233            self.init_app(app, *args, **kwargs)
234
235    def init_app(self, app, scopes=None, client_secrets_file=None,
236                 client_id=None, client_secret=None, authorize_callback=None,
237                 storage=None, **kwargs):
238        """Initialize this extension for the given app.
239
240        Arguments:
241            app: A Flask application.
242            scopes: Optional list of scopes to authorize.
243            client_secrets_file: Path to a file containing client secrets. You
244                can also specify the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE config
245                value.
246            client_id: If not specifying a client secrets file, specify the
247                OAuth2 client id. You can also specify the
248                GOOGLE_OAUTH2_CLIENT_ID config value. You must also provide a
249                client secret.
250            client_secret: The OAuth2 client secret. You can also specify the
251                GOOGLE_OAUTH2_CLIENT_SECRET config value.
252            authorize_callback: A function that is executed after successful
253                user authorization.
254            storage: A oauth2client.client.Storage subclass for storing the
255                credentials. By default, this is a Flask session based storage.
256            kwargs: Any additional args are passed along to the Flow
257                constructor.
258        """
259        self.app = app
260        self.authorize_callback = authorize_callback
261        self.flow_kwargs = kwargs
262
263        if storage is None:
264            storage = dictionary_storage.DictionaryStorage(
265                session, key=_CREDENTIALS_KEY)
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 '
310                'the 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 = client.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            reason = markupsafe.escape(reason)
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(
557            transport.get_http_object(*args, **kwargs))
558