Adding more detailed documentation for flask_util and finishing up incremental auth support for Flask.
This commit is contained in:
@@ -15,10 +15,19 @@
|
||||
"""Utilities for the Flask web framework
|
||||
|
||||
Provides a Flask extension that makes using OAuth2 web server flow easier.
|
||||
The extension includes views that handle the entire auth flow and a @required
|
||||
decorator to automatically ensure that user credentials are available.
|
||||
The extension includes views that handle the entire auth flow and a
|
||||
``@required`` decorator to automatically ensure that user credentials are
|
||||
available.
|
||||
|
||||
To configure::
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
||||
To configure, you'll need a set of OAuth2 client ID from the
|
||||
`Google Developer's Console <https://console.developers.google.com/project/_/\
|
||||
apiui/credential>`__.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from oauth2client.flask_util import UserOAuth2
|
||||
|
||||
@@ -34,7 +43,14 @@ To configure::
|
||||
oauth2 = UserOAuth2(app)
|
||||
|
||||
|
||||
To use::
|
||||
Usage
|
||||
=====
|
||||
|
||||
Once configured, you can use the :meth:`UserOAuth2.required` decorator to ensure
|
||||
that credentials are available within a view.
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 3,7,10
|
||||
|
||||
# Note that app.route should be the outermost decorator.
|
||||
@app.route('/needs_credentials')
|
||||
@@ -47,11 +63,11 @@ To use::
|
||||
# Or, you can access the credentials directly
|
||||
credentials = oauth2.credentials
|
||||
|
||||
If you want credentials to be optional for a view, you can leave the decorator
|
||||
off and use :meth:`UserOAuth2.has_credentials` to check.
|
||||
|
||||
@app.route('/info')
|
||||
@oauth2.required
|
||||
def info():
|
||||
return "Hello, {}".format(oauth2.email)
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 3
|
||||
|
||||
@app.route('/optional')
|
||||
def optional():
|
||||
@@ -60,6 +76,89 @@ To use::
|
||||
else:
|
||||
return 'No credentials!'
|
||||
|
||||
|
||||
When credentials are available, you can use :attr:`UserOAuth2.email` and
|
||||
:attr:`UserOAuth2.user_id` to access information from the `ID Token
|
||||
<https://developers.google.com/identity/protocols/OpenIDConnect?hl=en>`__, if
|
||||
available.
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 4
|
||||
|
||||
@app.route('/info')
|
||||
@oauth2.required
|
||||
def info():
|
||||
return "Hello, {} ({})".format(oauth2.email, oauth2.user_id)
|
||||
|
||||
|
||||
URLs & Trigging Authorization
|
||||
=============================
|
||||
|
||||
The extension will add two new routes to your application:
|
||||
|
||||
* ``"oauth2.authorize"`` -> ``/oauth2authorize``
|
||||
* ``"oauth2.callback"`` -> ``/oauth2callback``
|
||||
|
||||
When configuring your OAuth2 credentials on the Google Developer's Console, be
|
||||
sure to add ``http[s]://[your-app-url]/oauth2callback`` as an authorized
|
||||
callback url.
|
||||
|
||||
Typically you don't not need to use these routes directly, just be sure to
|
||||
decorate any views that require credentials with ``@oauth2.required``. If
|
||||
needed, you can trigger authorization at any time by redirecting the user
|
||||
to the URL returned by :meth:`UserOAuth2.authorize_url`.
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 3
|
||||
|
||||
@app.route('/login')
|
||||
def login():
|
||||
return oauth2.authorize_url("/")
|
||||
|
||||
|
||||
Incremental Auth
|
||||
================
|
||||
|
||||
This extension also supports `Incremental Auth <https://developers.google.com\
|
||||
/identity/protocols/OAuth2WebServer?hl=en#incrementalAuth>`__. To enable it,
|
||||
configure the extension with ``include_granted_scopes``.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
oauth2 = UserOAuth2(app, include_granted_scopes=True)
|
||||
|
||||
Then specify any additional scopes needed on the decorator, for example:
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 2,7
|
||||
|
||||
@app.route('/drive')
|
||||
@oauth2.required(scopes=["https://www.googleapis.com/auth/drive"])
|
||||
def requires_drive():
|
||||
...
|
||||
|
||||
@app.route('/calendar')
|
||||
@oauth2.required(scopes=["https://www.googleapis.com/auth/calendar"])
|
||||
def requires_calendar():
|
||||
...
|
||||
|
||||
The decorator will ensure that the the user has authorized all specified scopes
|
||||
before allowing them to access the view, and will also ensure that credentials
|
||||
do not lose any previously authorized scopes.
|
||||
|
||||
|
||||
Storage
|
||||
=======
|
||||
|
||||
By default, the extension uses a Flask session-based storage solution. This
|
||||
means that credentials are only available for the duration of a session. It
|
||||
also means that with Flask's default configuration, the credentials will be
|
||||
visible in the session cookie. It's highly recommended to use database-backed
|
||||
session and to use https whenever handling user credentials.
|
||||
|
||||
If you need the credentials to be available longer than a user session or
|
||||
available outside of a request context, you will need to implement your own
|
||||
:class:`oauth2client.Storage`.
|
||||
"""
|
||||
|
||||
__author__ = 'jonwayne@google.com (Jon Wayne Parrott)'
|
||||
@@ -98,13 +197,15 @@ class UserOAuth2(object):
|
||||
"""Flask extension for making OAuth 2.0 easier.
|
||||
|
||||
Configuration values:
|
||||
* GOOGLE_OAUTH2_CLIENT_SECRETS_JSON path to a client secrets json file,
|
||||
obtained from the credentials screen in the Google Developers
|
||||
|
||||
* ``GOOGLE_OAUTH2_CLIENT_SECRETS_JSON`` path to a client secrets json
|
||||
file, obtained from the credentials screen in the Google Developers
|
||||
console.
|
||||
* GOOGLE_OAUTH2_CLIENT_ID the oauth2 credentials' client ID. This is
|
||||
only needed if OAUTH2_CLIENT_SECRETS_JSON is not specified.
|
||||
* GOOGLE_OAUTH2_CLIENT_SECRET the oauth2 credentials' client secret.
|
||||
This is only needed if OAUTH2_CLIENT_SECRETS_JSON is not specified.
|
||||
* ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This
|
||||
is only needed if ``OAUTH2_CLIENT_SECRETS_JSON`` is not specified.
|
||||
* ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client
|
||||
secret. This is only needed if ``OAUTH2_CLIENT_SECRETS_JSON`` is not
|
||||
specified.
|
||||
|
||||
If app is specified, all arguments will be passed along to init_app.
|
||||
|
||||
@@ -241,6 +342,10 @@ class UserOAuth2(object):
|
||||
user to the OAuth2 provider."""
|
||||
args = request.args.to_dict()
|
||||
|
||||
# Scopes will be passed as mutliple args, and to_dict() will only
|
||||
# return one. So, we use getlist() to get all of the scopes.
|
||||
args['scopes'] = request.args.getlist('scopes')
|
||||
|
||||
return_url = args.pop('return_url', None)
|
||||
if return_url is None:
|
||||
return_url = request.referrer or '/'
|
||||
@@ -348,7 +453,8 @@ class UserOAuth2(object):
|
||||
"""
|
||||
return url_for('oauth2.authorize', return_url=return_url, **kwargs)
|
||||
|
||||
def required(self, decorated_function=None, **decorator_kwargs):
|
||||
def required(self, decorated_function=None, scopes=None,
|
||||
**decorator_kwargs):
|
||||
"""Decorator to require OAuth2 credentials for a view.
|
||||
|
||||
If credentials are not available for the current user, then they will
|
||||
@@ -358,12 +464,28 @@ class UserOAuth2(object):
|
||||
def curry_wrapper(wrapped_function):
|
||||
@wraps(wrapped_function)
|
||||
def required_wrapper(*args, **kwargs):
|
||||
|
||||
return_url = decorator_kwargs.pop('return_url', request.url)
|
||||
|
||||
# No credentials, redirect for new authorization.
|
||||
if not self.has_credentials():
|
||||
if 'return_url' not in decorator_kwargs:
|
||||
decorator_kwargs['return_url'] = request.url
|
||||
return redirect(self.authorize_url(**decorator_kwargs))
|
||||
else:
|
||||
auth_url = self.authorize_url(
|
||||
return_url,
|
||||
scopes=scopes,
|
||||
**decorator_kwargs)
|
||||
return redirect(auth_url)
|
||||
|
||||
# Existing credentials but mismatching scopes, redirect for
|
||||
# incremental authorization.
|
||||
if scopes and not self.credentials.has_scopes(scopes):
|
||||
auth_url = self.authorize_url(
|
||||
return_url,
|
||||
scopes=list(self.credentials.scopes) + scopes,
|
||||
**decorator_kwargs)
|
||||
return redirect(auth_url)
|
||||
|
||||
return wrapped_function(*args, **kwargs)
|
||||
|
||||
return required_wrapper
|
||||
|
||||
if decorated_function:
|
||||
|
||||
@@ -73,7 +73,7 @@ class FlaskOAuth2Tests(unittest.TestCase):
|
||||
client_id='client_idz',
|
||||
client_secret='client_secretz')
|
||||
|
||||
def _generate_credentials(self):
|
||||
def _generate_credentials(self, scopes=None):
|
||||
return OAuth2Credentials(
|
||||
'access_tokenz',
|
||||
'client_idz',
|
||||
@@ -85,7 +85,8 @@ class FlaskOAuth2Tests(unittest.TestCase):
|
||||
id_token={
|
||||
'sub': '123',
|
||||
'email': 'user@example.com'
|
||||
})
|
||||
},
|
||||
scopes=scopes)
|
||||
|
||||
def test_explicit_configuration(self):
|
||||
oauth2 = FlaskOAuth2(
|
||||
@@ -315,6 +316,53 @@ class FlaskOAuth2Tests(unittest.TestCase):
|
||||
self.assertEqual(rv.status_code, httplib.OK)
|
||||
self.assertTrue('Hello' in rv.data.decode('utf-8'))
|
||||
|
||||
def test_incremental_auth(self):
|
||||
self.app = flask.Flask(__name__)
|
||||
self.app.testing = True
|
||||
self.app.config['SECRET_KEY'] = 'notasecert'
|
||||
self.oauth2 = FlaskOAuth2(
|
||||
self.app,
|
||||
client_id='client_idz',
|
||||
client_secret='client_secretz',
|
||||
include_granted_scopes=True)
|
||||
|
||||
@self.app.route('/one')
|
||||
@self.oauth2.required(scopes=['one'])
|
||||
def one():
|
||||
return 'Hello'
|
||||
|
||||
@self.app.route('/two')
|
||||
@self.oauth2.required(scopes=['two', 'three'])
|
||||
def two():
|
||||
return 'Hello'
|
||||
|
||||
# No credentials, should redirect
|
||||
with self.app.test_client() as c:
|
||||
rv = c.get('/one')
|
||||
self.assertTrue('one' in rv.headers['Location'])
|
||||
self.assertEqual(rv.status_code, httplib.FOUND)
|
||||
|
||||
# Credentials for one. /one should allow, /two should redirect.
|
||||
credentials = self._generate_credentials(scopes=['one'])
|
||||
|
||||
with self.app.test_client() as c:
|
||||
with c.session_transaction() as session:
|
||||
session['google_oauth2_credentials'] = credentials.to_json()
|
||||
|
||||
rv = c.get('/one')
|
||||
self.assertEqual(rv.status_code, httplib.OK)
|
||||
|
||||
rv = c.get('/two')
|
||||
self.assertTrue('two' in rv.headers['Location'])
|
||||
self.assertEqual(rv.status_code, httplib.FOUND)
|
||||
|
||||
# Starting the authorization flow should include the
|
||||
# include_granted_scopes parameter as well as the scopes.
|
||||
rv = c.get(rv.headers['Location'][17:])
|
||||
q = urlparse.parse_qs(rv.headers['Location'].split('?', 1)[1])
|
||||
self.assertTrue('include_granted_scopes' in q)
|
||||
self.assertEqual(q['scope'][0], 'email one two three')
|
||||
|
||||
def test_refresh(self):
|
||||
with self.app.test_request_context():
|
||||
with mock.patch('flask.session'):
|
||||
|
||||
Reference in New Issue
Block a user