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
|
"""Utilities for the Flask web framework
|
||||||
|
|
||||||
Provides a Flask extension that makes using OAuth2 web server flow easier.
|
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
|
The extension includes views that handle the entire auth flow and a
|
||||||
decorator to automatically ensure that user credentials are available.
|
``@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
|
from oauth2client.flask_util import UserOAuth2
|
||||||
|
|
||||||
@@ -34,7 +43,14 @@ To configure::
|
|||||||
oauth2 = UserOAuth2(app)
|
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.
|
# Note that app.route should be the outermost decorator.
|
||||||
@app.route('/needs_credentials')
|
@app.route('/needs_credentials')
|
||||||
@@ -47,11 +63,11 @@ To use::
|
|||||||
# Or, you can access the credentials directly
|
# Or, you can access the credentials directly
|
||||||
credentials = oauth2.credentials
|
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')
|
.. code-block:: python
|
||||||
@oauth2.required
|
:emphasize-lines: 3
|
||||||
def info():
|
|
||||||
return "Hello, {}".format(oauth2.email)
|
|
||||||
|
|
||||||
@app.route('/optional')
|
@app.route('/optional')
|
||||||
def optional():
|
def optional():
|
||||||
@@ -60,6 +76,89 @@ To use::
|
|||||||
else:
|
else:
|
||||||
return 'No credentials!'
|
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)'
|
__author__ = 'jonwayne@google.com (Jon Wayne Parrott)'
|
||||||
@@ -98,13 +197,15 @@ class UserOAuth2(object):
|
|||||||
"""Flask extension for making OAuth 2.0 easier.
|
"""Flask extension for making OAuth 2.0 easier.
|
||||||
|
|
||||||
Configuration values:
|
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.
|
console.
|
||||||
* GOOGLE_OAUTH2_CLIENT_ID the oauth2 credentials' client ID. This is
|
* ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This
|
||||||
only needed if OAUTH2_CLIENT_SECRETS_JSON is not specified.
|
is only needed if ``OAUTH2_CLIENT_SECRETS_JSON`` is not specified.
|
||||||
* GOOGLE_OAUTH2_CLIENT_SECRET the oauth2 credentials' client secret.
|
* ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client
|
||||||
This is only needed if OAUTH2_CLIENT_SECRETS_JSON is not specified.
|
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.
|
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."""
|
user to the OAuth2 provider."""
|
||||||
args = request.args.to_dict()
|
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)
|
return_url = args.pop('return_url', None)
|
||||||
if return_url is None:
|
if return_url is None:
|
||||||
return_url = request.referrer or '/'
|
return_url = request.referrer or '/'
|
||||||
@@ -348,7 +453,8 @@ class UserOAuth2(object):
|
|||||||
"""
|
"""
|
||||||
return url_for('oauth2.authorize', return_url=return_url, **kwargs)
|
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.
|
"""Decorator to require OAuth2 credentials for a view.
|
||||||
|
|
||||||
If credentials are not available for the current user, then they will
|
If credentials are not available for the current user, then they will
|
||||||
@@ -358,12 +464,28 @@ class UserOAuth2(object):
|
|||||||
def curry_wrapper(wrapped_function):
|
def curry_wrapper(wrapped_function):
|
||||||
@wraps(wrapped_function)
|
@wraps(wrapped_function)
|
||||||
def required_wrapper(*args, **kwargs):
|
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 not self.has_credentials():
|
||||||
if 'return_url' not in decorator_kwargs:
|
auth_url = self.authorize_url(
|
||||||
decorator_kwargs['return_url'] = request.url
|
return_url,
|
||||||
return redirect(self.authorize_url(**decorator_kwargs))
|
scopes=scopes,
|
||||||
else:
|
**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 wrapped_function(*args, **kwargs)
|
||||||
|
|
||||||
return required_wrapper
|
return required_wrapper
|
||||||
|
|
||||||
if decorated_function:
|
if decorated_function:
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class FlaskOAuth2Tests(unittest.TestCase):
|
|||||||
client_id='client_idz',
|
client_id='client_idz',
|
||||||
client_secret='client_secretz')
|
client_secret='client_secretz')
|
||||||
|
|
||||||
def _generate_credentials(self):
|
def _generate_credentials(self, scopes=None):
|
||||||
return OAuth2Credentials(
|
return OAuth2Credentials(
|
||||||
'access_tokenz',
|
'access_tokenz',
|
||||||
'client_idz',
|
'client_idz',
|
||||||
@@ -85,7 +85,8 @@ class FlaskOAuth2Tests(unittest.TestCase):
|
|||||||
id_token={
|
id_token={
|
||||||
'sub': '123',
|
'sub': '123',
|
||||||
'email': 'user@example.com'
|
'email': 'user@example.com'
|
||||||
})
|
},
|
||||||
|
scopes=scopes)
|
||||||
|
|
||||||
def test_explicit_configuration(self):
|
def test_explicit_configuration(self):
|
||||||
oauth2 = FlaskOAuth2(
|
oauth2 = FlaskOAuth2(
|
||||||
@@ -315,6 +316,53 @@ class FlaskOAuth2Tests(unittest.TestCase):
|
|||||||
self.assertEqual(rv.status_code, httplib.OK)
|
self.assertEqual(rv.status_code, httplib.OK)
|
||||||
self.assertTrue('Hello' in rv.data.decode('utf-8'))
|
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):
|
def test_refresh(self):
|
||||||
with self.app.test_request_context():
|
with self.app.test_request_context():
|
||||||
with mock.patch('flask.session'):
|
with mock.patch('flask.session'):
|
||||||
|
|||||||
Reference in New Issue
Block a user