• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 Django web framework.
16
17Provides Django views and helpers the make using the OAuth2 web server
18flow easier. It includes an ``oauth_required`` decorator to automatically
19ensure that user credentials are available, and an ``oauth_enabled`` decorator
20to check if the user has authorized, and helper shortcuts to create the
21authorization URL otherwise.
22
23There are two basic use cases supported. The first is using Google OAuth as the
24primary form of authentication, which is the simpler approach recommended
25for applications without their own user system.
26
27The second use case is adding Google OAuth credentials to an
28existing Django model containing a Django user field. Most of the
29configuration is the same, except for `GOOGLE_OAUTH_MODEL_STORAGE` in
30settings.py. See "Adding Credentials To An Existing Django User System" for
31usage differences.
32
33Only Django versions 1.8+ are supported.
34
35Configuration
36===============
37
38To configure, you'll need a set of OAuth2 web application credentials from
39`Google Developer's Console <https://console.developers.google.com/project/_/apiui/credential>`.
40
41Add the helper to your INSTALLED_APPS:
42
43.. code-block:: python
44   :caption: settings.py
45   :name: installed_apps
46
47    INSTALLED_APPS = (
48        # other apps
49        "django.contrib.sessions.middleware"
50        "oauth2client.contrib.django_util"
51    )
52
53This helper also requires the Django Session Middleware, so
54``django.contrib.sessions.middleware`` should be in INSTALLED_APPS as well.
55
56Add the client secrets created earlier to the settings. You can either
57specify the path to the credentials file in JSON format
58
59.. code-block:: python
60   :caption:  settings.py
61   :name: secrets_file
62
63   GOOGLE_OAUTH2_CLIENT_SECRETS_JSON=/path/to/client-secret.json
64
65Or, directly configure the client Id and client secret.
66
67
68.. code-block:: python
69   :caption: settings.py
70   :name: secrets_config
71
72   GOOGLE_OAUTH2_CLIENT_ID=client-id-field
73   GOOGLE_OAUTH2_CLIENT_SECRET=client-secret-field
74
75By default, the default scopes for the required decorator only contains the
76``email`` scopes. You can change that default in the settings.
77
78.. code-block:: python
79   :caption: settings.py
80   :name: scopes
81
82   GOOGLE_OAUTH2_SCOPES = ('email', 'https://www.googleapis.com/auth/calendar',)
83
84By default, the decorators will add an `oauth` object to the Django request
85object, and include all of its state and helpers inside that object. If the
86`oauth` name conflicts with another usage, it can be changed
87
88.. code-block:: python
89   :caption: settings.py
90   :name: request_prefix
91
92   # changes request.oauth to request.google_oauth
93   GOOGLE_OAUTH2_REQUEST_ATTRIBUTE = 'google_oauth'
94
95Add the oauth2 routes to your application's urls.py urlpatterns.
96
97.. code-block:: python
98   :caption: urls.py
99   :name: urls
100
101   from oauth2client.contrib.django_util.site import urls as oauth2_urls
102
103   urlpatterns += [url(r'^oauth2/', include(oauth2_urls))]
104
105To require OAuth2 credentials for a view, use the `oauth2_required` decorator.
106This creates a credentials object with an id_token, and allows you to create
107an `http` object to build service clients with. These are all attached to the
108request.oauth
109
110.. code-block:: python
111   :caption: views.py
112   :name: views_required
113
114   from oauth2client.contrib.django_util.decorators import oauth_required
115
116   @oauth_required
117   def requires_default_scopes(request):
118      email = request.oauth.credentials.id_token['email']
119      service = build(serviceName='calendar', version='v3',
120                    http=request.oauth.http,
121                   developerKey=API_KEY)
122      events = service.events().list(calendarId='primary').execute()['items']
123      return HttpResponse("email: {0} , calendar: {1}".format(
124                           email,str(events)))
125      return HttpResponse(
126          "email: {0} , calendar: {1}".format(email, str(events)))
127
128To make OAuth2 optional and provide an authorization link in your own views.
129
130.. code-block:: python
131   :caption: views.py
132   :name: views_enabled2
133
134   from oauth2client.contrib.django_util.decorators import oauth_enabled
135
136   @oauth_enabled
137   def optional_oauth2(request):
138       if request.oauth.has_credentials():
139           # this could be passed into a view
140           # request.oauth.http is also initialized
141           return HttpResponse("User email: {0}".format(
142               request.oauth.credentials.id_token['email']))
143       else:
144           return HttpResponse(
145               'Here is an OAuth Authorize link: <a href="{0}">Authorize'
146               '</a>'.format(request.oauth.get_authorize_redirect()))
147
148If a view needs a scope not included in the default scopes specified in
149the settings, you can use [incremental auth](https://developers.google.com/identity/sign-in/web/incremental-auth)
150and specify additional scopes in the decorator arguments.
151
152.. code-block:: python
153   :caption: views.py
154   :name: views_required_additional_scopes
155
156   @oauth_enabled(scopes=['https://www.googleapis.com/auth/drive'])
157   def drive_required(request):
158       if request.oauth.has_credentials():
159           service = build(serviceName='drive', version='v2',
160                http=request.oauth.http,
161                developerKey=API_KEY)
162           events = service.files().list().execute()['items']
163           return HttpResponse(str(events))
164       else:
165           return HttpResponse(
166               'Here is an OAuth Authorize link: <a href="{0}">Authorize'
167               '</a>'.format(request.oauth.get_authorize_redirect()))
168
169
170To provide a callback on authorization being completed, use the
171oauth2_authorized signal:
172
173.. code-block:: python
174   :caption: views.py
175   :name: signals
176
177   from oauth2client.contrib.django_util.signals import oauth2_authorized
178
179   def test_callback(sender, request, credentials, **kwargs):
180       print("Authorization Signal Received {0}".format(
181               credentials.id_token['email']))
182
183   oauth2_authorized.connect(test_callback)
184
185Adding Credentials To An Existing Django User System
186=====================================================
187
188As an alternative to storing the credentials in the session, the helper
189can be configured to store the fields on a Django model. This might be useful
190if you need to use the credentials outside the context of a user request. It
191also prevents the need for a logged in user to repeat the OAuth flow when
192starting a new session.
193
194To use, change ``settings.py``
195
196.. code-block:: python
197   :caption:  settings.py
198   :name: storage_model_config
199
200   GOOGLE_OAUTH2_STORAGE_MODEL = {
201       'model': 'path.to.model.MyModel',
202       'user_property': 'user_id',
203       'credentials_property': 'credential'
204    }
205
206Where ``path.to.model`` class is the fully qualified name of a
207``django.db.model`` class containing a ``django.contrib.auth.models.User``
208field with the name specified by `user_property` and a
209:class:`oauth2client.contrib.django_util.models.CredentialsField` with the name
210specified by `credentials_property`. For the sample configuration given,
211our model would look like
212
213.. code-block:: python
214   :caption: models.py
215   :name: storage_model_model
216
217   from django.contrib.auth.models import User
218   from oauth2client.contrib.django_util.models import CredentialsField
219
220   class MyModel(models.Model):
221       #  ... other fields here ...
222       user = models.OneToOneField(User)
223       credential = CredentialsField()
224"""
225
226import importlib
227
228import django.conf
229from django.core import exceptions
230from django.core import urlresolvers
231import httplib2
232from six.moves.urllib import parse
233
234from oauth2client import clientsecrets
235from oauth2client.contrib import dictionary_storage
236from oauth2client.contrib.django_util import storage
237
238GOOGLE_OAUTH2_DEFAULT_SCOPES = ('email',)
239GOOGLE_OAUTH2_REQUEST_ATTRIBUTE = 'oauth'
240
241
242def _load_client_secrets(filename):
243    """Loads client secrets from the given filename.
244
245    Args:
246        filename: The name of the file containing the JSON secret key.
247
248    Returns:
249        A 2-tuple, the first item containing the client id, and the second
250        item containing a client secret.
251    """
252    client_type, client_info = clientsecrets.loadfile(filename)
253
254    if client_type != clientsecrets.TYPE_WEB:
255        raise ValueError(
256            'The flow specified in {} is not supported, only the WEB flow '
257            'type  is supported.'.format(client_type))
258    return client_info['client_id'], client_info['client_secret']
259
260
261def _get_oauth2_client_id_and_secret(settings_instance):
262    """Initializes client id and client secret based on the settings.
263
264    Args:
265        settings_instance: An instance of ``django.conf.settings``.
266
267    Returns:
268        A 2-tuple, the first item is the client id and the second
269         item is the client secret.
270    """
271    secret_json = getattr(settings_instance,
272                          'GOOGLE_OAUTH2_CLIENT_SECRETS_JSON', None)
273    if secret_json is not None:
274        return _load_client_secrets(secret_json)
275    else:
276        client_id = getattr(settings_instance, "GOOGLE_OAUTH2_CLIENT_ID",
277                            None)
278        client_secret = getattr(settings_instance,
279                                "GOOGLE_OAUTH2_CLIENT_SECRET", None)
280        if client_id is not None and client_secret is not None:
281            return client_id, client_secret
282        else:
283            raise exceptions.ImproperlyConfigured(
284                "Must specify either GOOGLE_OAUTH2_CLIENT_SECRETS_JSON, or "
285                "both GOOGLE_OAUTH2_CLIENT_ID and "
286                "GOOGLE_OAUTH2_CLIENT_SECRET in settings.py")
287
288
289def _get_storage_model():
290    """This configures whether the credentials will be stored in the session
291    or the Django ORM based on the settings. By default, the credentials
292    will be stored in the session, unless `GOOGLE_OAUTH2_STORAGE_MODEL`
293    is found in the settings. Usually, the ORM storage is used to integrate
294    credentials into an existing Django user system.
295
296    Returns:
297        A tuple containing three strings, or None. If
298        ``GOOGLE_OAUTH2_STORAGE_MODEL`` is configured, the tuple
299        will contain the fully qualifed path of the `django.db.model`,
300        the name of the ``django.contrib.auth.models.User`` field on the
301        model, and the name of the
302        :class:`oauth2client.contrib.django_util.models.CredentialsField`
303        field on the model. If Django ORM storage is not configured,
304        this function returns None.
305    """
306    storage_model_settings = getattr(django.conf.settings,
307                                     'GOOGLE_OAUTH2_STORAGE_MODEL', None)
308    if storage_model_settings is not None:
309        return (storage_model_settings['model'],
310                storage_model_settings['user_property'],
311                storage_model_settings['credentials_property'])
312    else:
313        return None, None, None
314
315
316class OAuth2Settings(object):
317    """Initializes Django OAuth2 Helper Settings
318
319    This class loads the OAuth2 Settings from the Django settings, and then
320    provides those settings as attributes to the rest of the views and
321    decorators in the module.
322
323    Attributes:
324      scopes: A list of OAuth2 scopes that the decorators and views will use
325              as defaults.
326      request_prefix: The name of the attribute that the decorators use to
327                    attach the UserOAuth2 object to the Django request object.
328      client_id: The OAuth2 Client ID.
329      client_secret: The OAuth2 Client Secret.
330    """
331
332    def __init__(self, settings_instance):
333        self.scopes = getattr(settings_instance, 'GOOGLE_OAUTH2_SCOPES',
334                              GOOGLE_OAUTH2_DEFAULT_SCOPES)
335        self.request_prefix = getattr(settings_instance,
336                                      'GOOGLE_OAUTH2_REQUEST_ATTRIBUTE',
337                                      GOOGLE_OAUTH2_REQUEST_ATTRIBUTE)
338        self.client_id, self.client_secret = \
339            _get_oauth2_client_id_and_secret(settings_instance)
340
341        if ('django.contrib.sessions.middleware.SessionMiddleware'
342           not in settings_instance.MIDDLEWARE_CLASSES):
343            raise exceptions.ImproperlyConfigured(
344                  'The Google OAuth2 Helper requires session middleware to '
345                  'be installed. Edit your MIDDLEWARE_CLASSES setting'
346                  ' to include \'django.contrib.sessions.middleware.'
347                  'SessionMiddleware\'.')
348        (self.storage_model, self.storage_model_user_property,
349         self.storage_model_credentials_property) = _get_storage_model()
350
351
352oauth2_settings = OAuth2Settings(django.conf.settings)
353
354_CREDENTIALS_KEY = 'google_oauth2_credentials'
355
356
357def get_storage(request):
358    """ Gets a Credentials storage object provided by the Django OAuth2 Helper
359    object.
360
361    Args:
362        request: Reference to the current request object.
363
364    Returns:
365       An :class:`oauth2.client.Storage` object.
366    """
367    storage_model = oauth2_settings.storage_model
368    user_property = oauth2_settings.storage_model_user_property
369    credentials_property = oauth2_settings.storage_model_credentials_property
370
371    if storage_model:
372        module_name, class_name = storage_model.rsplit('.', 1)
373        module = importlib.import_module(module_name)
374        storage_model_class = getattr(module, class_name)
375        return storage.DjangoORMStorage(storage_model_class,
376                                        user_property,
377                                        request.user,
378                                        credentials_property)
379    else:
380        # use session
381        return dictionary_storage.DictionaryStorage(
382            request.session, key=_CREDENTIALS_KEY)
383
384
385def _redirect_with_params(url_name, *args, **kwargs):
386    """Helper method to create a redirect response with URL params.
387
388    This builds a redirect string that converts kwargs into a
389    query string.
390
391    Args:
392        url_name: The name of the url to redirect to.
393        kwargs: the query string param and their values to build.
394
395    Returns:
396        A properly formatted redirect string.
397    """
398    url = urlresolvers.reverse(url_name, args=args)
399    params = parse.urlencode(kwargs, True)
400    return "{0}?{1}".format(url, params)
401
402
403def _credentials_from_request(request):
404    """Gets the authorized credentials for this flow, if they exist."""
405    # ORM storage requires a logged in user
406    if (oauth2_settings.storage_model is None or
407            request.user.is_authenticated()):
408        return get_storage(request).get()
409    else:
410        return None
411
412
413class UserOAuth2(object):
414    """Class to create oauth2 objects on Django request objects containing
415    credentials and helper methods.
416    """
417
418    def __init__(self, request, scopes=None, return_url=None):
419        """Initialize the Oauth2 Object.
420
421        Args:
422            request: Django request object.
423            scopes: Scopes desired for this OAuth2 flow.
424            return_url: The url to return to after the OAuth flow is complete,
425                 defaults to the request's current URL path.
426        """
427        self.request = request
428        self.return_url = return_url or request.get_full_path()
429        if scopes:
430            self._scopes = set(oauth2_settings.scopes) | set(scopes)
431        else:
432            self._scopes = set(oauth2_settings.scopes)
433
434    def get_authorize_redirect(self):
435        """Creates a URl to start the OAuth2 authorization flow."""
436        get_params = {
437            'return_url': self.return_url,
438            'scopes': self._get_scopes()
439        }
440
441        return _redirect_with_params('google_oauth:authorize', **get_params)
442
443    def has_credentials(self):
444        """Returns True if there are valid credentials for the current user
445        and required scopes."""
446        credentials = _credentials_from_request(self.request)
447        return (credentials and not credentials.invalid and
448                credentials.has_scopes(self._get_scopes()))
449
450    def _get_scopes(self):
451        """Returns the scopes associated with this object, kept up to
452         date for incremental auth."""
453        if _credentials_from_request(self.request):
454            return (self._scopes |
455                    _credentials_from_request(self.request).scopes)
456        else:
457            return self._scopes
458
459    @property
460    def scopes(self):
461        """Returns the scopes associated with this OAuth2 object."""
462        # make sure previously requested custom scopes are maintained
463        # in future authorizations
464        return self._get_scopes()
465
466    @property
467    def credentials(self):
468        """Gets the authorized credentials for this flow, if they exist."""
469        return _credentials_from_request(self.request)
470
471    @property
472    def http(self):
473        """Helper method to create an HTTP client authorized with OAuth2
474        credentials."""
475        if self.has_credentials():
476            return self.credentials.authorize(httplib2.Http())
477        return None
478