Compare commits

...

7 Commits

Author SHA1 Message Date
Arik Fraimovich
8c5e2dffc4 Bump version 2018-10-18 11:27:34 +03:00
YOSHIDA Katsuhiko
6f5a69f170 Preventing open redirection (#2906)
* Prevent open redirection attack

* Add redirection url after logging in test

* Sanitize url just before redirecting it

* Consider when next parameter is None
2018-10-18 11:26:34 +03:00
Alexey Korobkov
de0089ceb9 Add auth via JWT providers (#2768)
* authentication via JWT providers
* add support for IAP JWT auth
* remove jwt_auth Blueprint and /headers endpoint
* fix pep8: imports
2018-09-27 21:50:55 +03:00
Arik Fraimovich
fa92fec012 Update version 2018-09-27 21:49:08 +03:00
Jannis Leidel
2d86305852 Update to 3.1.26 to improve compatibility with Celery 4.x. (#2865) 2018-09-27 21:07:40 +03:00
Arik Fraimovich
d6ba42613b Add the release branch regex to the second pipeline 2018-09-24 12:39:28 +03:00
Arik Fraimovich
c91b73d9e3 Fix: CircleCI release branch regex 2018-09-24 12:37:32 +03:00
13 changed files with 197 additions and 23 deletions

View File

@@ -115,11 +115,11 @@ workflows:
branches: branches:
only: only:
- master - master
- release/.* - /release\/.*/
- build-docker-image: - build-docker-image:
requires: requires:
- unit-tests - unit-tests
filters: filters:
branches: branches:
only: only:
- release/.* - /release\/.*/

View File

@@ -1,6 +1,6 @@
{ {
"name": "redash-client", "name": "redash-client",
"version": "5.0.0", "version": "5.0.2",
"description": "The frontend part of Redash.", "description": "The frontend part of Redash.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -18,7 +18,7 @@ from redash.query_runner import import_query_runners
from redash.destinations import import_destinations from redash.destinations import import_destinations
__version__ = '5.0.0' __version__ = '5.0.2'
def setup_logging(): def setup_logging():

View File

@@ -6,8 +6,12 @@ import time
import logging import logging
from flask import redirect, request, jsonify, url_for from flask import redirect, request, jsonify, url_for
from urlparse import urlsplit, urlunsplit
from werkzeug.exceptions import Unauthorized
from redash import models, settings from redash import models, settings
from redash.settings.organization import settings as org_settings
from redash.authentication import jwt_auth
from redash.authentication.org_resolving import current_org from redash.authentication.org_resolving import current_org
from redash.tasks import record_event from redash.tasks import record_event
@@ -48,6 +52,21 @@ def load_user(user_id):
return None return None
def request_loader(request):
user = None
if settings.AUTH_TYPE == 'hmac':
user = hmac_load_user_from_request(request)
elif settings.AUTH_TYPE == 'api_key':
user = api_key_load_user_from_request(request)
else:
logger.warning("Unknown authentication type ({}). Using default (HMAC).".format(settings.AUTH_TYPE))
user = hmac_load_user_from_request(request)
if org_settings['auth_jwt_login_enabled'] and user is None:
user = jwt_token_load_user_from_request(request)
return user
def hmac_load_user_from_request(request): def hmac_load_user_from_request(request):
signature = request.args.get('signature') signature = request.args.get('signature')
expires = float(request.args.get('expires') or 0) expires = float(request.args.get('expires') or 0)
@@ -116,6 +135,40 @@ def api_key_load_user_from_request(request):
return user return user
def jwt_token_load_user_from_request(request):
org = current_org._get_current_object()
payload = None
if org_settings['auth_jwt_auth_cookie_name']:
jwt_token = request.cookies.get(org_settings['auth_jwt_auth_cookie_name'], None)
elif org_settings['auth_jwt_auth_header_name']:
jwt_token = request.headers.get(org_settings['auth_jwt_auth_header_name'], None)
else:
return None
if jwt_token:
payload, token_is_valid = jwt_auth.verify_jwt_token(
jwt_token,
expected_issuer=org_settings['auth_jwt_auth_issuer'],
expected_audience=org_settings['auth_jwt_auth_audience'],
algorithms=org_settings['auth_jwt_auth_algorithms'],
public_certs_url=org_settings['auth_jwt_auth_public_certs_url'],
)
if not token_is_valid:
raise Unauthorized('Invalid JWT token')
if not payload:
return
try:
user = models.User.get_by_email_and_org(payload['email'], org)
except models.NoResultFound:
user = create_and_login_user(current_org, payload['email'], payload['email'])
return user
def log_user_logged_in(app, user): def log_user_logged_in(app, user):
event = { event = {
'org_id': current_org.id, 'org_id': current_org.id,
@@ -168,14 +221,7 @@ def setup_authentication(app):
app.register_blueprint(ldap_auth.blueprint) app.register_blueprint(ldap_auth.blueprint)
user_logged_in.connect(log_user_logged_in) user_logged_in.connect(log_user_logged_in)
login_manager.request_loader(request_loader)
if settings.AUTH_TYPE == 'hmac':
login_manager.request_loader(hmac_load_user_from_request)
elif settings.AUTH_TYPE == 'api_key':
login_manager.request_loader(api_key_load_user_from_request)
else:
logger.warning("Unknown authentication type ({}). Using default (HMAC).".format(settings.AUTH_TYPE))
login_manager.request_loader(hmac_load_user_from_request)
def create_and_login_user(org, name, email, picture=None): def create_and_login_user(org, name, email, picture=None):
@@ -197,3 +243,16 @@ def create_and_login_user(org, name, email, picture=None):
login_user(user_object, remember=True) login_user(user_object, remember=True)
return user_object return user_object
def get_next_path(unsafe_next_path):
if not unsafe_next_path:
return ''
# Preventing open redirection attacks
parts = list(urlsplit(unsafe_next_path))
parts[0] = '' # clear scheme
parts[1] = '' # clear netloc
safe_next_path = urlunsplit(parts)
return safe_next_path

View File

@@ -4,7 +4,7 @@ from flask import redirect, url_for, Blueprint, flash, request, session
from flask_oauthlib.client import OAuth from flask_oauthlib.client import OAuth
from redash import models, settings from redash import models, settings
from redash.authentication import create_and_login_user, logout_and_redirect_to_index from redash.authentication import create_and_login_user, logout_and_redirect_to_index, get_next_path
from redash.authentication.org_resolving import current_org from redash.authentication.org_resolving import current_org
logger = logging.getLogger('google_oauth') logger = logging.getLogger('google_oauth')
@@ -102,6 +102,7 @@ def authorized():
if user is None: if user is None:
return logout_and_redirect_to_index() return logout_and_redirect_to_index()
next_path = request.args.get('state') or url_for("redash.index", org_slug=org.slug) unsafe_next_path = request.args.get('state') or url_for("redash.index", org_slug=org.slug)
next_path = get_next_path(unsafe_next_path)
return redirect(next_path) return redirect(next_path)

View File

@@ -0,0 +1,65 @@
import logging
import json
import jwt
import requests
logger = logging.getLogger('jwt_auth')
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

View File

@@ -13,7 +13,7 @@ except ImportError:
logger.error("The ldap3 library was not found. This is required to use LDAP authentication (see requirements.txt).") logger.error("The ldap3 library was not found. This is required to use LDAP authentication (see requirements.txt).")
exit() exit()
from redash.authentication import create_and_login_user, logout_and_redirect_to_index from redash.authentication import create_and_login_user, logout_and_redirect_to_index, get_next_path
from redash.authentication.org_resolving import current_org from redash.authentication.org_resolving import current_org
@@ -23,7 +23,8 @@ blueprint = Blueprint('ldap_auth', __name__)
@blueprint.route("/ldap/login", methods=['GET', 'POST']) @blueprint.route("/ldap/login", methods=['GET', 'POST'])
def login(org_slug=None): def login(org_slug=None):
index_url = url_for("redash.index", org_slug=org_slug) index_url = url_for("redash.index", org_slug=org_slug)
next_path = request.args.get('next', index_url) unsafe_next_path = request.args.get('next', index_url)
next_path = get_next_path(unsafe_next_path)
if not settings.LDAP_LOGIN_ENABLED: if not settings.LDAP_LOGIN_ENABLED:
logger.error("Cannot use LDAP for login without being enabled in settings") logger.error("Cannot use LDAP for login without being enabled in settings")

View File

@@ -1,6 +1,6 @@
import logging import logging
from flask import redirect, url_for, Blueprint, request from flask import redirect, url_for, Blueprint, request
from redash.authentication import create_and_login_user, logout_and_redirect_to_index from redash.authentication import create_and_login_user, logout_and_redirect_to_index, get_next_path
from redash.authentication.org_resolving import current_org from redash.authentication.org_resolving import current_org
from redash.handlers.base import org_scoped_rule from redash.handlers.base import org_scoped_rule
from redash import settings from redash import settings
@@ -11,7 +11,8 @@ blueprint = Blueprint('remote_user_auth', __name__)
@blueprint.route(org_scoped_rule("/remote_user/login")) @blueprint.route(org_scoped_rule("/remote_user/login"))
def login(org_slug=None): def login(org_slug=None):
next_path = request.args.get('next') unsafe_next_path = request.args.get('next')
next_path = get_next_path(unsafe_next_path)
if not settings.REMOTE_USER_LOGIN_ENABLED: if not settings.REMOTE_USER_LOGIN_ENABLED:
logger.error("Cannot use remote user for login without being enabled in settings") logger.error("Cannot use remote user for login without being enabled in settings")

View File

@@ -4,7 +4,7 @@ from flask import abort, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user from flask_login import current_user, login_required, login_user, logout_user
from redash import __version__, limiter, models, settings from redash import __version__, limiter, models, settings
from redash.authentication import current_org, get_login_url from redash.authentication import current_org, get_login_url, get_next_path
from redash.authentication.account import (BadSignature, SignatureExpired, from redash.authentication.account import (BadSignature, SignatureExpired,
send_password_reset_email, send_password_reset_email,
validate_token) validate_token)
@@ -106,8 +106,9 @@ def login(org_slug=None):
elif current_org == None: elif current_org == None:
return redirect('/') return redirect('/')
index_url = url_for("redash.index", org_slug=org_slug) index_url = url_for('redash.index', org_slug=org_slug)
next_path = request.args.get('next', index_url) unsafe_next_path = request.args.get('next', index_url)
next_path = get_next_path(unsafe_next_path)
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(next_path) return redirect(next_path)

View File

@@ -17,11 +17,26 @@ SAML_LOGIN_ENABLED = SAML_METADATA_URL != ""
DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY") DATE_FORMAT = os.environ.get("REDASH_DATE_FORMAT", "DD/MM/YY")
JWT_LOGIN_ENABLED = parse_boolean(os.environ.get("REDASH_JWT_LOGIN_ENABLED", "false"))
JWT_AUTH_ISSUER = os.environ.get("REDASH_JWT_AUTH_ISSUER", "")
JWT_AUTH_PUBLIC_CERTS_URL = os.environ.get("REDASH_JWT_AUTH_PUBLIC_CERTS_URL", "")
JWT_AUTH_AUDIENCE = os.environ.get("REDASH_JWT_AUTH_AUDIENCE", "")
JWT_AUTH_ALGORITHMS = os.environ.get("REDASH_JWT_AUTH_ALGORITHMS", "HS256,RS256,ES256").split(',')
JWT_AUTH_COOKIE_NAME = os.environ.get("REDASH_JWT_AUTH_COOKIE_NAME", "")
JWT_AUTH_HEADER_NAME = os.environ.get("REDASH_JWT_AUTH_HEADER_NAME", "")
settings = { settings = {
"auth_password_login_enabled": PASSWORD_LOGIN_ENABLED, "auth_password_login_enabled": PASSWORD_LOGIN_ENABLED,
"auth_saml_enabled": SAML_LOGIN_ENABLED, "auth_saml_enabled": SAML_LOGIN_ENABLED,
"auth_saml_entity_id": SAML_ENTITY_ID, "auth_saml_entity_id": SAML_ENTITY_ID,
"auth_saml_metadata_url": SAML_METADATA_URL, "auth_saml_metadata_url": SAML_METADATA_URL,
"auth_saml_nameid_format": SAML_NAMEID_FORMAT, "auth_saml_nameid_format": SAML_NAMEID_FORMAT,
"date_format": DATE_FORMAT "date_format": DATE_FORMAT,
"auth_jwt_login_enabled": JWT_LOGIN_ENABLED,
"auth_jwt_auth_issuer": JWT_AUTH_ISSUER,
"auth_jwt_auth_public_certs_url": JWT_AUTH_PUBLIC_CERTS_URL,
"auth_jwt_auth_audience": JWT_AUTH_AUDIENCE,
"auth_jwt_auth_algorithms": JWT_AUTH_ALGORITHMS,
"auth_jwt_auth_cookie_name": JWT_AUTH_COOKIE_NAME,
"auth_jwt_auth_header_name": JWT_AUTH_HEADER_NAME,
} }

View File

@@ -33,7 +33,7 @@ wsgiref==0.1.2
honcho==0.5.0 honcho==0.5.0
statsd==2.1.2 statsd==2.1.2
gunicorn==19.7.1 gunicorn==19.7.1
celery==3.1.25 celery==3.1.26.post2
jsonschema==2.4.0 jsonschema==2.4.0
RestrictedPython==3.6.0 RestrictedPython==3.6.0
pysaml2==4.5.0 pysaml2==4.5.0
@@ -44,6 +44,7 @@ semver==2.2.1
xlsxwriter==0.9.3 xlsxwriter==0.9.3
pystache==0.5.4 pystache==0.5.4
parsedatetime==2.1 parsedatetime==2.1
PyJWT==1.6.4
cryptography==2.0.2 cryptography==2.0.2
simplejson==3.10.0 simplejson==3.10.0
ua-parser==0.7.3 ua-parser==0.7.3

View File

@@ -1,3 +1,4 @@
from passlib.apps import custom_app_context as pwd_context
import redash.models import redash.models
from redash.models import db from redash.models import db
from redash.permissions import ACCESS_TYPE_MODIFY from redash.permissions import ACCESS_TYPE_MODIFY
@@ -41,6 +42,7 @@ class Sequence(object):
user_factory = ModelFactory(redash.models.User, user_factory = ModelFactory(redash.models.User,
name='John Doe', email=Sequence(u'test{}@example.com'), name='John Doe', email=Sequence(u'test{}@example.com'),
password_hash=pwd_context.encrypt('test1234'),
group_ids=[2], group_ids=[2],
org_id=1) org_id=1)

View File

@@ -184,6 +184,34 @@ class TestGetLoginUrl(BaseTestCase):
with self.app.test_request_context('/{}_notexists/'.format(self.factory.org.slug)): with self.app.test_request_context('/{}_notexists/'.format(self.factory.org.slug)):
self.assertEqual(get_login_url(next=None), '/') self.assertEqual(get_login_url(next=None), '/')
class TestRedirectToUrlAfterLoggingIn(BaseTestCase):
def setUp(self):
super(TestRedirectToUrlAfterLoggingIn, self).setUp()
self.user = self.factory.user
self.password = 'test1234'
def test_no_next_param(self):
response = self.post_request('/login', data={'email': self.user.email, 'password': self.password}, org=self.factory.org)
self.assertEqual(response.location, 'http://localhost/{}/'.format(self.user.org.slug))
def test_simple_path_in_next_param(self):
response = self.post_request('/login?next=queries', data={'email': self.user.email, 'password': self.password}, org=self.factory.org)
self.assertEqual(response.location, 'http://localhost/queries')
def test_starts_scheme_url_in_next_param(self):
response = self.post_request('/login?next=https://redash.io', data={'email': self.user.email, 'password': self.password}, org=self.factory.org)
self.assertEqual(response.location, 'http://localhost/')
def test_without_scheme_url_in_next_param(self):
response = self.post_request('/login?next=//redash.io', data={'email': self.user.email, 'password': self.password}, org=self.factory.org)
self.assertEqual(response.location, 'http://localhost/')
def test_without_scheme_with_path_url_in_next_param(self):
response = self.post_request('/login?next=//localhost/queries', data={'email': self.user.email, 'password': self.password}, org=self.factory.org)
self.assertEqual(response.location, 'http://localhost/queries')
class TestRemoteUserAuth(BaseTestCase): class TestRemoteUserAuth(BaseTestCase):
DEFAULT_SETTING_OVERRIDES = { DEFAULT_SETTING_OVERRIDES = {
'REDASH_REMOTE_USER_LOGIN_ENABLED': 'true' 'REDASH_REMOTE_USER_LOGIN_ENABLED': 'true'