mistercrunch closed pull request #6157: authentication via JWT providers
URL: https://github.com/apache/incubator-superset/pull/6157
This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:
As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):
diff --git a/requirements.txt b/requirements.txt
index a21cda97f1..7e99d96b55 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -64,6 +64,7 @@ polyline==1.3.2
pycparser==2.19 # via cffi
pydruid==0.4.4
pyhive==0.5.1
+PyJWT==1.6.4
python-dateutil==2.6.1
python-editor==1.0.3 # via alembic
python-geohash==0.8.5
diff --git a/setup.py b/setup.py
index 76f31f6f4c..9a192a1042 100644
--- a/setup.py
+++ b/setup.py
@@ -80,6 +80,7 @@ def get_git_sha():
'polyline',
'pydruid>=0.4.3',
'pyhive>=0.4.0',
+ 'PyJWT>=1.6.4',
'python-dateutil',
'python-geohash',
'pyyaml>=3.11',
diff --git a/superset/config.py b/superset/config.py
index c94373969f..a6c6516b20 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -137,6 +137,15 @@
# { 'name': 'Flickr', 'url': 'http://www.flickr.com/<username>' },
# { 'name': 'MyOpenID', 'url': 'https://www.myopenid.com' }]
+# Setup use JWT token from JWT provider for authentication
+# JWT_LOGIN_ENABLED = False
+# JWT_AUTH_ALGORITHMS = ['HS256', 'RS256', 'ES256']
+# JWT_AUTH_ISSUER = 'https://issuer'
+# JWT_AUTH_PUBLIC_CERTS_URL = 'https://certs'
+# JWT_AUTH_AUDIENCE = 'aud'
+# JWT_AUTH_COOKIE_NAME = 'CF_Authorization'
+# AUTH_USER_REGISTRATION = True
+
# ---------------------------------------------------
# Roles config
# ---------------------------------------------------
diff --git a/superset/jwt_auth.py b/superset/jwt_auth.py
new file mode 100644
index 0000000000..81c0335526
--- /dev/null
+++ b/superset/jwt_auth.py
@@ -0,0 +1,67 @@
+# pylint: disable=C,R,W
+import json
+import logging
+
+import jwt
+import requests
+
+
+def get_public_keys(url):
+
+ """
+ Returns:
+ List of RSA public keys usable by PyJWT.
+ """
+ key_cache = get_public_keys.key_cache
+ if url in key_cache:
+ return key_cache[url]
+ else:
+ r = requests.get(url)
+ r.raise_for_status()
+ data = r.json()
+ if 'keys' in data:
+ public_keys = []
+ for key_dict in data['keys']:
+ public_key =
jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key_dict))
+ public_keys.append(public_key)
+
+ get_public_keys.key_cache[url] = public_keys
+ return public_keys
+ else:
+ get_public_keys.key_cache[url] = data
+ return data
+
+
+get_public_keys.key_cache = {}
+
+
+def verify_jwt_token(jwt_token, expected_issuer, expected_audience, algorithms,
+ public_certs_url):
+ #
https://developers.cloudflare.com/access/setting-up-access/validate-jwt-tokens/
+ # https://cloud.google.com/iap/docs/signed-headers-howto
+ # Loop through the keys since we can't pass the key set to the decoder
+ keys = get_public_keys(public_certs_url)
+
+ key_id = jwt.get_unverified_header(jwt_token).get('kid', '')
+ if key_id and isinstance(keys, dict):
+ keys = [keys.get(key_id)]
+
+ valid_token = False
+ payload = None
+ for key in keys:
+ try:
+ # decode returns the claims which has the email if you need it
+ payload = jwt.decode(
+ jwt_token,
+ key=key,
+ audience=expected_audience,
+ algorithms=algorithms,
+ )
+ issuer = payload['iss']
+ if issuer != expected_issuer:
+ raise Exception('Wrong issuer: {}'.format(issuer))
+ valid_token = True
+ break
+ except Exception as e:
+ logging.exception(e)
+ return payload, valid_token
diff --git a/superset/security.py b/superset/security.py
index c0f6f37cea..33f14b290a 100644
--- a/superset/security.py
+++ b/superset/security.py
@@ -3,6 +3,7 @@
import logging
from flask import g
+from flask_appbuilder.const import LOGMSG_WAR_SEC_LOGIN_FAILED
from flask_appbuilder.security.sqla import models as ab_models
from flask_appbuilder.security.sqla.manager import SecurityManager
from sqlalchemy import or_
@@ -72,9 +73,31 @@
'metric_access',
])
+log = logging.getLogger(__name__)
+
class SupersetSecurityManager(SecurityManager):
+ def __init__(self, appbuilder):
+ super().__init__(appbuilder)
+ app = appbuilder.get_app
+ app.config.setdefault('JWT_LOGIN_ENABLED', False)
+
+ if app.config['JWT_LOGIN_ENABLED']:
+
+ if not any([app.config.get('JWT_AUTH_COOKIE_NAME'),
+ app.config.get('JWT_AUTH_HEADER_NAME')]):
+ raise Exception('Missing JWT_AUTH_COOKIE_NAME or
JWT_AUTH_HEADER_NAME')
+ empty_jwt_values = [
+ k for k in ['JWT_AUTH_PUBLIC_CERTS_URL', 'JWT_AUTH_AUDIENCE',
+ 'JWT_AUTH_ISSUER', 'JWT_AUTH_ALGORITHMS']
+ if not app.config.get(k)
+ ]
+ if empty_jwt_values:
+ raise Exception('Missing JWT config
{0}'.format(empty_jwt_values))
+
+ self.lm.request_loader(self.load_user_from_jwt_token)
+
def get_schema_perm(self, database, schema):
if schema:
return '[{}].[{}]'.format(database, schema)
@@ -425,3 +448,68 @@ def set_perm(self, mapper, connection, target): # noqa
view_menu_id=view_menu.id,
),
)
+
+ def auth_user_jwt(self, payload):
+ if 'email' in payload:
+ user = self.find_user(email=payload['email'])
+ else:
+ log.error('User info does not have email {0}'.format(payload))
+ return None
+
+ # User is disabled
+ if user and not user.is_active:
+ log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(payload))
+ return None
+ # If user does not exist on the DB and not self user registration, go
away
+ if not user and not self.auth_user_registration:
+ return None
+ # User does not exist, create one if self registration.
+ if not user:
+ user = self.add_user(
+ username=payload['email'].replace('@', '_'),
+ first_name=payload.get('first_name', ''),
+ last_name=payload.get('last_name', ''),
+ email=payload['email'],
+ role=self.find_role(self.auth_user_registration_role),
+ )
+
+ if not user:
+ log.error('Error creating a new JWT user {0}'.format(payload))
+ return None
+ return user
+
+ def jwt_token_load_user_from_request(self, request):
+ from werkzeug.exceptions import Unauthorized
+ from superset.jwt_auth import verify_jwt_token
+ app = self.appbuilder.get_app
+
+ payload = None
+
+ if app.config['JWT_AUTH_COOKIE_NAME']:
+ jwt_token =
request.cookies.get(app.config['JWT_AUTH_COOKIE_NAME'], None)
+ elif app.config['JWT_AUTH_HEADER_NAME']:
+ jwt_token =
request.headers.get(app.config['JWT_AUTH_HEADER_NAME'], None)
+ else:
+ return None
+
+ if jwt_token:
+ payload, token_is_valid = verify_jwt_token(
+ jwt_token,
+ expected_issuer=app.config['JWT_AUTH_ISSUER'],
+ expected_audience=app.config['JWT_AUTH_AUDIENCE'],
+ algorithms=app.config['JWT_AUTH_ALGORITHMS'],
+ public_certs_url=app.config['JWT_AUTH_PUBLIC_CERTS_URL'],
+ )
+ if not token_is_valid:
+ raise Unauthorized('Invalid JWT token')
+
+ if payload:
+ return self.auth_user_jwt(payload)
+
+ def load_user_from_jwt_token(self, request):
+ if self.appbuilder.app.config['JWT_LOGIN_ENABLED'] and \
+ not getattr(request, '_load_user_from_jwt_token_lock', False):
+ request._load_user_from_jwt_token_lock = True
+ user = self.jwt_token_load_user_from_request(request)
+ request._load_user_from_jwt_token_lock = False
+ return user
----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
For queries about this service, please contact Infrastructure at:
[email protected]
With regards,
Apache Git Services
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]