Google OAuth2即使使用access_type ='offline'也不会发出刷新令牌吗?

时间:2018-08-16 18:20:56

标签: python django google-api

这个问题类似于Not receiving Google OAuth refresh token,但是我已经按照接受的解决方案的注释中的建议指定了access_type='offline'

我正在编写一个Django应用,以使用Google API发送日历邀请,这基本上是对https://developers.google.com/api-client-library/python/auth/web-app所提供的Flask示例的改编,其中我创建了一个模型GoogleCredentials进行存储凭据持久存储在数据库中,而不是会话中。

以下是视图:

import logging
from django.conf import settings
from django.shortcuts import redirect
from django.http import JsonResponse
from django.urls import reverse
from django.contrib.auth.decorators import login_required
import google.oauth2.credentials
import google_auth_oauthlib.flow
import googleapiclient.discovery
from lucy_web.models import GoogleCredentials

logger = logging.getLogger(__name__)


# Client configuration for an OAuth 2.0 web server application
# (cf. https://developers.google.com/identity/protocols/OAuth2WebServer)
# This is constructed from environment variables rather than from a
# client_secret.json file, since the Aptible deployment process would
# require us to check that into version control, which is not in accordance
# with the 12-factor principles.
# The client_secret.json containing this information can be downloaded from
# https://console.cloud.google.com/apis/credentials?organizationId=22827866999&project=cleo-212520
CLIENT_CONFIG = {'web': {
    'client_id': settings.GOOGLE_CLIENT_ID,
    'project_id': settings.GOOGLE_PROJECT_ID,
    'auth_uri': 'https://accounts.google.com/o/oauth2/auth',
    'token_uri': 'https://www.googleapis.com/oauth2/v3/token',
    'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs',
    'client_secret': settings.GOOGLE_CLIENT_SECRET,
    'redirect_uris': settings.GOOGLE_REDIRECT_URIS,
    'javascript_origins': settings.GOOGLE_JAVASCRIPT_ORIGINS}}

# This scope will allow the application to manage the user's calendars
SCOPES = ['https://www.googleapis.com/auth/calendar']
API_SERVICE_NAME = 'calendar'
API_VERSION = 'v3'


@login_required
def authorize(request):
    authorization_url, state = _get_authorization_url(request)
    request.session['state'] = state
    return redirect(to=authorization_url)


@login_required
def oauth2callback(request):
    flow = _get_flow(request, state=request.session['state'])

    # Note: to test this locally, set OAUTHLIB_INSECURE_TRANSPORT=1 in your .env file
    # (cf. https://stackoverflow.com/questions/27785375/testing-flask-oauthlib-locally-without-https)
    flow.fetch_token(authorization_response=request.get_raw_uri())
    _save_credentials(user=request.user, credentials=flow.credentials)
    return redirect(to=reverse('create-meeting'))


@login_required
def create_meeting(request):
    # Retrieve the user's credentials from the database, redirecting
    # to the authorization page if none are found
    credentials = _get_credentials(user=request.user)
    if not credentials:
        return redirect(to=reverse('authorize'))

    calendar = googleapiclient.discovery.build(
        API_SERVICE_NAME, API_VERSION, credentials=credentials)

    calendars = calendar.calendarList().list().execute()

    return JsonResponse(calendars)


def _get_credentials(user):
    """
    Retrieve a user's google.oauth2.credentials.Credentials from the database.
    """
    try:
        _credentials = GoogleCredentials.objects.get(user=user)
    except GoogleCredentials.DoesNotExist:
        return

    return google.oauth2.credentials.Credentials(**_credentials.to_dict())


def _save_credentials(user, credentials):
    """
    Store a user's google.oauth2.credentials.Credentials in the database.
    """
    gc, _ = GoogleCredentials.objects.get_or_create(user=user)
    gc.update_from_credentials(credentials)


def _get_authorization_url(request):
    flow = _get_flow(request)

    # Generate URL for request to Google's OAuth 2.0 server
    return flow.authorization_url(
        # Enable offline access so that you can refresh an access token without
        # re-prompting the user for permission. Recommended for web server apps.
        access_type='offline',
        login_hint=settings.SCHEDULING_EMAIL,
        # Enable incremental authorization. Recommended as a best practice.
        include_granted_scopes='true')


def _get_flow(request, **kwargs):
    # Use the information in the client_secret.json to identify
    # the application requesting authorization.
    flow = google_auth_oauthlib.flow.Flow.from_client_config(
        client_config=CLIENT_CONFIG,
        scopes=SCOPES,
        **kwargs)

    # Indicate where the API server will redirect the user after the user completes
    # the authorization flow. The redirect URI is required.
    flow.redirect_uri = request.build_absolute_uri(reverse('oauth2callback'))
    return flow

请注意,我已将access_type='offline'传递给flow.authorization_url()。这是GoogleCredentials模型:

from django.db import models
from django.contrib.postgres.fields import ArrayField
from .timestamped_model import TimeStampedModel
from .user import User


class GoogleCredentials(TimeStampedModel):
    """
    Model for saving Google credentials to a persistent database (cf. https://developers.google.com/api-client-library/python/auth/web-app)
    The user's ID is used as the primary key, following https://github.com/google/google-api-python-client/blob/master/samples/django_sample/plus/models.py.
    (Note that we don't use oauth2client's CredentialsField as that library is deprecated).
    """
    user = models.OneToOneField(
        User,
        primary_key=True,
        limit_choices_to={'is_staff': True},
        # Deleting a user will automatically delete his/her Google credentials
        on_delete=models.CASCADE)
    token = models.CharField(max_length=255, null=True)
    refresh_token = models.CharField(max_length=255, null=True)
    token_uri = models.CharField(max_length=255, null=True)
    client_id = models.CharField(max_length=255, null=True)
    client_secret = models.CharField(max_length=255, null=True)
    scopes = ArrayField(models.CharField(max_length=255), null=True)

    def to_dict(self):
        """
        Return a dictionary of the fields required to construct
        a google.oauth2.credentials.Credentials object
        """
        return dict(
            token=self.token,
            refresh_token=self.refresh_token,
            token_uri=self.token_uri,
            client_id=self.client_id,
            client_secret=self.client_secret,
            scopes=self.scopes)

    def update_from_credentials(self, credentials):
        self.token = credentials.token
        self.refresh_token = credentials.refresh_token
        self.token_uri = credentials.token_uri
        self.client_id = credentials.client_id
        self.client_secret = credentials.client_secret
        self.scopes = credentials.scopes
        self.save()

在开发服务器运行的情况下,如果我转到localhost:8000/authorize(已连接到authorize()视图),然后我检查了第一个凭证,则发现refresh_tokenNone

(lucy-web-CVxkrCFK) bash-3.2$ python manage.py shell
Python 3.7.0 (v3.7.0:1bf9cc5093, Jun 26 2018, 23:26:24) 
Type 'copyright', 'credits' or 'license' for more information
IPython 6.4.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from lucy_web.models import *

In [2]: GoogleCredentials.objects.all()
Out[2]: <QuerySet [<GoogleCredentials: GoogleCredentials object (2154)>]>

In [3]: gc = GoogleCredentials.objects.first()

In [4]: gc.__dict__
Out[4]: 
{'_state': <django.db.models.base.ModelState at 0x111f91630>,
 'created_at': datetime.datetime(2018, 8, 15, 17, 58, 33, 626971, tzinfo=<UTC>),
 'updated_at': datetime.datetime(2018, 8, 15, 23, 8, 38, 634449, tzinfo=<UTC>),
 'user_id': 2154,
 'token': 'ya29foobar6tA',
 'refresh_token': None,
 'token_uri': 'https://www.googleapis.com/oauth2/v3/token',
 'client_id': '8214foobar13-unernto9l5ievs2pi0l6fir12fus1o46.apps.googleusercontent.com',
 'client_secret': 'bZt6foobarQj10y',
 'scopes': ['https://www.googleapis.com/auth/calendar']}

最初,这不是问题,但是过一会儿,如果我进入create_meeting()视图,就会得到一个RefreshError,我已经在{{ 1}}:

google.oauth2.credentials

换句话说,我需要一个@_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): if (self._refresh_token is None or self._token_uri is None or self._client_id is None or self._client_secret is None): raise exceptions.RefreshError( 'The credentials do not contain the necessary fields need to ' 'refresh the access token. You must specify refresh_token, ' 'token_uri, client_id, and client_secret.') 来防止出现此错误。为什么在这种情况下Google API不返回一个?

3 个答案:

答案 0 :(得分:4)

更仔细地遵循接受的答案,我发现可以通过删除Web应用程序对我的帐户的访问权限并重新添加来获得刷新令牌。我导航到https://myaccount.google.com/permissions,并删除了对“ Cleo”应用程序的访问权限:

enter image description here

然后我转到localhost:8000/authorize(链接到authorize()视图),再次查找保存的凭据,它们具有刷新令牌:

In [24]: from lucy_web.models import *

In [25]: gc = GoogleCredentials.objects.first()

In [26]: gc.__dict__
Out[26]: 
{'_state': <django.db.models.base.ModelState at 0x109133e10>,
 'created_at': datetime.datetime(2018, 8, 15, 17, 58, 33, 626971, tzinfo=<UTC>),
 'updated_at': datetime.datetime(2018, 8, 16, 22, 37, 48, 108105, tzinfo=<UTC>),
 'user_id': 2154,
 'token': 'ya29.Glv6BbcPkVoFfoobarHGifJUlEKP7kvwO5G1myTDOw9UYfl1LKAGxt',
 'refresh_token': '1/iafoobar4z1OxFtNljiLrmS0',
 'token_uri': 'https://www.googleapis.com/oauth2/v3/token',
 'client_id': '821409068013-unerntfoobarir12fus1o46.apps.googleusercontent.com',
 'client_secret': 'bZt6lfoobarpI8Qj10y',
 'scopes': ['https://www.googleapis.com/auth/calendar']}

答案 1 :(得分:0)

访问在 1 小时后被撤销,解决方法是使用 refresh_token。但此令牌仅在第一个授权请求时提供(当出现允许日历的 UI 时)。要解决此问题,请在不保存令牌后:

  1. 撤销对应用程序的访问权限:https://myaccount.google.com/u/0/permissions
  2. 再次请求访问
  3. 保存refresh_token
  4. 使用 refresh_token

在正常操作中,只需:

  1. 请求访问权限。
  2. 保存refresh_token
  3. 使用 refresh_token

答案 2 :(得分:0)

使用 https://gist.github.com/fxyw/6759e1ef9b91aadc29c54248493af742 中的方法 改编自https://gist.github.com/ikai/5905078