From 3938ac6836eb7d5c93fd2ac67e6fa873696754f8 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 17:54:12 -0400 Subject: [PATCH 1/7] remove stateful access tokens dash that run on multiple workers need to be stateless --- dash_auth/oauth.py | 36 +++--------------------------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/dash_auth/oauth.py b/dash_auth/oauth.py index b980bbd..f1dff98 100644 --- a/dash_auth/oauth.py +++ b/dash_auth/oauth.py @@ -1,9 +1,10 @@ from __future__ import absolute_import import datetime import flask -from flask_seasurf import SeaSurf import json import os +from textwrap import dedent +import itsdangerous from .auth import Auth @@ -14,7 +15,7 @@ class OAuthBase(Auth): # Name of the cookie containing the OAuth2 access token TOKEN_COOKIE_NAME = 'oauth_token' - def __init__(self, app, app_url, client_id=None): + def __init__(self, app, app_url, client_id=None, secret_key=None): Auth.__init__(self, app) self.config = { @@ -24,7 +25,6 @@ def __init__(self, app, app_url, client_id=None): self._app = app self._app_url = app_url self._oauth_client_id = client_id - self._access_codes = self.create_access_codes() app.server.add_url_rule( '{}_dash-login'.format(app.config['routes_pathname_prefix']), @@ -52,18 +52,7 @@ def __init__(self, app, app_url, client_id=None): with open(os.path.join(_current_path, 'login.js'), 'r') as f: self.login_bundle = f.read() - def create_access_codes(self): - token = SeaSurf()._generate_token() - new_access_codes = { - 'access_granted': token, - 'expiration': ( - datetime.datetime.now() + datetime.timedelta( - seconds=self.config['permissions_cache_expiry'] - ) ) - } - self._access_codes = new_access_codes - return self._access_codes def is_authorized(self): if self.TOKEN_COOKIE_NAME not in flask.request.cookies: @@ -71,19 +60,6 @@ def is_authorized(self): oauth_token = flask.request.cookies[self.TOKEN_COOKIE_NAME] - if (datetime.datetime.now() > self._access_codes['expiration']): - self.create_access_codes() - - if self.AUTH_COOKIE_NAME not in flask.request.cookies: - return self.check_view_access(oauth_token) - - access_cookie = flask.request.cookies[self.AUTH_COOKIE_NAME] - - # If there access was previously declined, - # check access again in case it has changed - if access_cookie != self._access_codes['access_granted']: - return self.check_view_access(oauth_token) - return True def check_if_authorized(self): @@ -118,12 +94,6 @@ def wrap(*args, **kwargs): # Python 3 if isinstance(response, str): response = flask.Response(response) - self.set_cookie( - response, - name=self.AUTH_COOKIE_NAME, - value=self._access_codes['access_granted'], - max_age=(60 * 60 * 24 * 7), # 1 week - ) return response return wrap From 366b309d9769d9b127ab9c26cbb2197fcf6fa236 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 19:14:22 -0400 Subject: [PATCH 2/7] set access tokens with time expiring signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secret key is by default the user’s API key. can be over-ridden by setting `app.server.secret_key` (flask’s convention) - an access cookie is created with a time-expiring signature. this is hard to guess without the user’s secret key. - if an invalid access cookie is provided or if the signature has expired, we check if the user has access to the file with the plotly api key and their oauth token (also stored as a cookie). - if they are granted access, then a new time expiring signature is set as a cookie - if access wasn’t granted, the request returns with a 403 --- dash_auth/oauth.py | 51 ++++++++++++++++++++++++++++++++++++++++ dash_auth/plotly_auth.py | 6 ++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/dash_auth/oauth.py b/dash_auth/oauth.py index f1dff98..4228ac4 100644 --- a/dash_auth/oauth.py +++ b/dash_auth/oauth.py @@ -5,6 +5,7 @@ import os from textwrap import dedent import itsdangerous +from .api_requests import credential from .auth import Auth @@ -26,6 +27,25 @@ def __init__(self, app, app_url, client_id=None, secret_key=None): self._app_url = app_url self._oauth_client_id = client_id + if secret_key is None and app.server.secret_key is None: + raise Exception(dedent(''' + app.server.secret_key is missing. + Generate a secret key in your Python session + with the following commands: + + >>> import os + >>> import base64 + >>> base64.b64encode(os.urandom(30)).decode('utf-8') + + and assign it to the property app.server.secret_key + (where app is your dash app instance). + Note that you should not do this dynamically: + you should create a key and then assign the value of + that key in your code. + ''')) + + self._signer = itsdangerous.TimestampSigner(secret_key) + app.server.add_url_rule( '{}_dash-login'.format(app.config['routes_pathname_prefix']), view_func=self.login_api, @@ -52,13 +72,34 @@ def __init__(self, app, app_url, client_id=None, secret_key=None): with open(os.path.join(_current_path, 'login.js'), 'r') as f: self.login_bundle = f.read() + def access_token_is_valid(self): + if self.AUTH_COOKIE_NAME not in flask.request.cookies: + return False + + access_token = flask.request.cookies[self.AUTH_COOKIE_NAME] + + try: + self._signer.unsign( + access_token, + max_age=self.config['permissions_cache_expiry'] ) + return True + except itsdangerous.SignatureExpired as e: + # Check access in case the user is valid but the token has expired + return False + except itsdangerous.BadSignature as e: + # Access tokens in previous versions of `dash-auth` + # weren't generated with itsdangerous + # and will raise `BadSignature` + return False def is_authorized(self): if self.TOKEN_COOKIE_NAME not in flask.request.cookies: return False oauth_token = flask.request.cookies[self.TOKEN_COOKIE_NAME] + if not self.access_token_is_valid(): + return self.check_view_access(oauth_token) return True @@ -94,6 +135,16 @@ def wrap(*args, **kwargs): # Python 3 if isinstance(response, str): response = flask.Response(response) + + # grant a new access token if expired, missing, or invalid + if not self.access_token_is_valid(): + access_token = self._signer.sign('access') + self.set_cookie( + response, + name=self.AUTH_COOKIE_NAME, + value=access_token, + max_age=(60 * 60 * 24 * 7), # 1 week + ) return response return wrap diff --git a/dash_auth/plotly_auth.py b/dash_auth/plotly_auth.py index 0be7627..4975c55 100644 --- a/dash_auth/plotly_auth.py +++ b/dash_auth/plotly_auth.py @@ -29,7 +29,11 @@ def __init__(self, app, app_name, sharing, app_url): Returns: None """ - super(PlotlyAuth, self).__init__(app, app_url) + super(PlotlyAuth, self).__init__( + app, + app_url, + secret_key=api_requests.credential('plotly_api_key') + ) self._fid = create_or_overwrite_dash_app( app_name, sharing, app_url From 3a28627be1b4da82bee5f4db35c02bc837865460 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 19:16:26 -0400 Subject: [PATCH 3/7] add itsdangerous to required packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i’m pretty sure that it’s part of flask’s required packages but putting it in just to be safe --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 24509b1..5a4f676 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,8 @@ 'flask-seasurf', 'plotly', 'dash>=0.18.3', - 'retrying' + 'retrying', + 'itsdangerous' ], include_package_data=True, url='https://plot.ly/dash', From b993abf3260faf3f98da54c7af6d3ae2a69a40fa Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 19:17:10 -0400 Subject: [PATCH 4/7] add `server = app.server` so that we can test with `gunicorn` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gunicorn` usage: ``` gunicorn usage_plotly_auth:server —workers 4 ``` --- usage_plotly_auth.py | 1 + 1 file changed, 1 insertion(+) diff --git a/usage_plotly_auth.py b/usage_plotly_auth.py index 1791080..0c72ad8 100644 --- a/usage_plotly_auth.py +++ b/usage_plotly_auth.py @@ -18,6 +18,7 @@ 'private', 'http://localhost:8050' ) +server = app.server app.layout = html.Div([ From 147e566e63ca4a8a847b83439445b2eeca87b858 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Mon, 26 Mar 2018 19:22:42 -0400 Subject: [PATCH 5/7] pep :8ball: --- dash_auth/oauth.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/dash_auth/oauth.py b/dash_auth/oauth.py index 4228ac4..1113601 100644 --- a/dash_auth/oauth.py +++ b/dash_auth/oauth.py @@ -1,11 +1,9 @@ from __future__ import absolute_import -import datetime import flask import json import os from textwrap import dedent import itsdangerous -from .api_requests import credential from .auth import Auth @@ -84,10 +82,10 @@ def access_token_is_valid(self): max_age=self.config['permissions_cache_expiry'] ) return True - except itsdangerous.SignatureExpired as e: + except itsdangerous.SignatureExpired: # Check access in case the user is valid but the token has expired return False - except itsdangerous.BadSignature as e: + except itsdangerous.BadSignature: # Access tokens in previous versions of `dash-auth` # weren't generated with itsdangerous # and will raise `BadSignature` From 4578f1ab8c35577ccbafe2547538402695481ecb Mon Sep 17 00:00:00 2001 From: chriddyp Date: Tue, 27 Mar 2018 14:42:26 -0400 Subject: [PATCH 6/7] add salt to `TimestampSigner` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this prevents malicious users from using an access token from one dash app (that they have access to) to access another dash app (that they shouldn’t have access to) --- dash_auth/oauth.py | 10 ++++++++-- dash_auth/plotly_auth.py | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/dash_auth/oauth.py b/dash_auth/oauth.py index 1113601..a4401b6 100644 --- a/dash_auth/oauth.py +++ b/dash_auth/oauth.py @@ -14,7 +14,7 @@ class OAuthBase(Auth): # Name of the cookie containing the OAuth2 access token TOKEN_COOKIE_NAME = 'oauth_token' - def __init__(self, app, app_url, client_id=None, secret_key=None): + def __init__(self, app, app_url, client_id=None, secret_key=None, salt=None): Auth.__init__(self, app) self.config = { @@ -42,7 +42,13 @@ def __init__(self, app, app_url, client_id=None, secret_key=None): that key in your code. ''')) - self._signer = itsdangerous.TimestampSigner(secret_key) + if salt is None: + raise Exception(dedent(''' + salt is missing. The salt parameter needs to a string that + is unique to this individual Dash app. + ''')) + + self._signer = itsdangerous.TimestampSigner(secret_key, salt=salt) app.server.add_url_rule( '{}_dash-login'.format(app.config['routes_pathname_prefix']), diff --git a/dash_auth/plotly_auth.py b/dash_auth/plotly_auth.py index 4975c55..80a4073 100644 --- a/dash_auth/plotly_auth.py +++ b/dash_auth/plotly_auth.py @@ -32,7 +32,8 @@ def __init__(self, app, app_name, sharing, app_url): super(PlotlyAuth, self).__init__( app, app_url, - secret_key=api_requests.credential('plotly_api_key') + secret_key=api_requests.credential('plotly_api_key'), + salt=app_name ) self._fid = create_or_overwrite_dash_app( From cf7d413aa1b43951ebaa0ca6df81cda6fbbef435 Mon Sep 17 00:00:00 2001 From: chriddyp Date: Tue, 27 Mar 2018 14:47:31 -0400 Subject: [PATCH 7/7] pep :8ball: --- dash_auth/oauth.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dash_auth/oauth.py b/dash_auth/oauth.py index a4401b6..2e102f9 100644 --- a/dash_auth/oauth.py +++ b/dash_auth/oauth.py @@ -14,7 +14,13 @@ class OAuthBase(Auth): # Name of the cookie containing the OAuth2 access token TOKEN_COOKIE_NAME = 'oauth_token' - def __init__(self, app, app_url, client_id=None, secret_key=None, salt=None): + def __init__( + self, + app, + app_url, + client_id=None, + secret_key=None, + salt=None): Auth.__init__(self, app) self.config = {