From df1170eb9bd6c056a82ade7a86a95afa6287150a Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 10 Mar 2015 17:51:17 +0200 Subject: [PATCH 1/3] Feature: optional api key only authentication --- redash/authentication.py | 66 ++++++++++++++++++++++++++---------- redash/models.py | 3 ++ redash/settings.py | 2 ++ tests/test_authentication.py | 39 ++++++++++++++++++++- 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/redash/authentication.py b/redash/authentication.py index 8f330aef3d..83568a8433 100644 --- a/redash/authentication.py +++ b/redash/authentication.py @@ -5,7 +5,7 @@ import logging from flask import request, make_response, redirect, url_for -from flask.ext.login import LoginManager, login_user, current_user +from flask.ext.login import LoginManager, login_user, current_user, logout_user from redash import models, settings, google_oauth @@ -23,9 +23,38 @@ def sign(key, path, expires): return h.hexdigest() -class HMACAuthentication(object): - @staticmethod - def api_key_authentication(): +class Authentication(object): + def verify_authentication(self): + return False + + def required(self, fn): + @functools.wraps(fn) + def decorated(*args, **kwargs): + if current_user.is_authenticated() or self.verify_authentication(): + return fn(*args, **kwargs) + + return make_response(redirect(url_for("login", next=request.url))) + + return decorated + + +class ApiKeyAuthentication(Authentication): + def verify_authentication(self): + api_key = request.args.get('api_key') + query_id = request.view_args.get('query_id', None) + + if query_id and api_key: + query = models.Query.get(models.Query.id == query_id) + + if query.api_key and api_key == query.api_key: + login_user(models.ApiUser(query.api_key), remember=False) + return True + + return False + + +class HMACAuthentication(Authentication): + def verify_authentication(self): signature = request.args.get('signature') expires = float(request.args.get('expires') or 0) query_id = request.view_args.get('query_id', None) @@ -41,22 +70,14 @@ def api_key_authentication(): return False - def required(self, fn): - @functools.wraps(fn) - def decorated(*args, **kwargs): - if current_user.is_authenticated(): - return fn(*args, **kwargs) - - if self.api_key_authentication(): - return fn(*args, **kwargs) - - return make_response(redirect(url_for("login", next=request.url))) - - return decorated - @login_manager.user_loader def load_user(user_id): + # If the user was previously logged in as api user, the user_id will be the api key and will raise an exception as + # it can't be casted to int. + if isinstance(user_id, basestring) and not user_id.isdigit(): + return None + return models.User.select().where(models.User.id == user_id).first() @@ -66,4 +87,13 @@ def setup_authentication(app): app.secret_key = settings.COOKIE_SECRET app.register_blueprint(google_oauth.blueprint) - return HMACAuthentication() + if settings.AUTH_TYPE == 'hmac': + auth = HMACAuthentication() + elif settings.AUTH_TYPE == 'api_key': + auth = ApiKeyAuthentication() + else: + logger.warning("Unknown authentication type ({}). Use default (HMAC).".format(settings.AUTH_TYPE)) + auth = HMACAuthentication() + + return auth + diff --git a/redash/models.py b/redash/models.py index e3085d2d07..d864cd2614 100644 --- a/redash/models.py +++ b/redash/models.py @@ -84,6 +84,9 @@ class ApiUser(UserMixin, PermissionsCheckMixin): def __init__(self, api_key): self.id = api_key + def __repr__(self): + return u"".format(self.id) + @property def permissions(self): return ['view_query'] diff --git a/redash/settings.py b/redash/settings.py index 42f17b0dcd..035315b932 100644 --- a/redash/settings.py +++ b/redash/settings.py @@ -60,6 +60,8 @@ def parse_boolean(str): # proved to be "safe". QUERY_RESULTS_CLEANUP_ENABLED = parse_boolean(os.environ.get("REDASH_QUERY_RESULTS_CLEANUP_ENABLED", "false")) +AUTH_TYPE = os.environ.get("REDASH_AUTH_TYPE", "hmac") + # Google Apps domain to allow access from; any user with email in this Google Apps will be allowed # access GOOGLE_APPS_DOMAIN = os.environ.get("REDASH_GOOGLE_APPS_DOMAIN", "") diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 4e5ca33bd9..615b9b2341 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,8 +1,45 @@ +from flask.ext.login import current_user from mock import patch from tests import BaseTestCase from redash import models from redash.google_oauth import create_and_login_user -from tests.factories import user_factory +from redash.authentication import ApiKeyAuthentication +from tests.factories import user_factory, query_factory +from redash.wsgi import app + + +class TestApiKeyAuthentication(BaseTestCase): + # + # This is a bad way to write these tests, but the way Flask works doesn't make it easy to write them properly... + # + def setUp(self): + super(TestApiKeyAuthentication, self).setUp() + self.api_key = 10 + self.query = query_factory.create(api_key=self.api_key) + + def test_no_api_key(self): + auth = ApiKeyAuthentication() + with app.test_client() as c: + rv = c.get('/api/queries/{0}'.format(self.query.id))#, query_string={'api_key': 'whatever'}) + self.assertFalse(auth.verify_authentication()) + + def test_wrong_api_key(self): + auth = ApiKeyAuthentication() + with app.test_client() as c: + rv = c.get('/api/queries/{0}'.format(self.query.id), query_string={'api_key': 'whatever'}) + self.assertFalse(auth.verify_authentication()) + + def test_correct_api_key(self): + auth = ApiKeyAuthentication() + with app.test_client() as c: + rv = c.get('/api/queries/{0}'.format(self.query.id), query_string={'api_key': self.api_key}) + self.assertTrue(auth.verify_authentication()) + + def test_no_query_id(self): + auth = ApiKeyAuthentication() + with app.test_client() as c: + rv = c.get('/api/queries', query_string={'api_key': self.api_key}) + self.assertFalse(auth.verify_authentication()) class TestCreateAndLoginUser(BaseTestCase): From 335c136ec23eb89cd88b9ffa7e07b40722f4bff0 Mon Sep 17 00:00:00 2001 From: Arik Fraimovich Date: Tue, 10 Mar 2015 18:08:02 +0200 Subject: [PATCH 2/3] Show API Key button in query view --- rd_ui/app/scripts/controllers/query_view.js | 4 ++++ rd_ui/app/views/query.html | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/rd_ui/app/scripts/controllers/query_view.js b/rd_ui/app/scripts/controllers/query_view.js index 45ff4ced5d..cd39776bb2 100644 --- a/rd_ui/app/scripts/controllers/query_view.js +++ b/rd_ui/app/scripts/controllers/query_view.js @@ -33,6 +33,10 @@ $scope.queryExecuting = lock; }; + $scope.showApiKey = function() { + alert("API Key for this query:\n" + $scope.query.api_key); + }; + $scope.saveQuery = function(options, data) { if (data) { data.id = $scope.query.id; diff --git a/rd_ui/app/views/query.html b/rd_ui/app/views/query.html index 2c599c1d54..795d5df94b 100644 --- a/rd_ui/app/views/query.html +++ b/rd_ui/app/views/query.html @@ -139,7 +139,11 @@

ng-show="!query.is_archived && query.id != undefined && (isQueryOwner || currentUser.hasPermission('admin'))"> - + + +