From ddcefa47ed6bbd0325d0f60a19d5d2d21844774c Mon Sep 17 00:00:00 2001 From: sslipushenko Date: Fri, 9 Dec 2016 18:43:20 +0200 Subject: [PATCH] Add authentication with Json Web Tokens This patch allows to authenticate user with JSON Web Token in the RefStack API. It keeps compatibility with previous method of posting signed results How to generate valid token: > jwt --key="$( cat %path to private key% )" --alg=RS256 user_openid=%openstackid% exp=+100500 How to test auth in API: > curl -k --header "Authorization: Bearer %token%" https://localhost.org/v1/profile Change-Id: I56c88e2fb0ce0e8d6a8b67fba3c2cf25458e1807 --- refstack/api/app.py | 23 ++++++-- refstack/api/constants.py | 4 ++ refstack/api/utils.py | 79 ++++++++++++++++++++++++--- refstack/tests/unit/test_api_utils.py | 76 ++++++++++++++++++++++++-- refstack/tests/unit/test_app.py | 7 ++- requirements.txt | 1 + 6 files changed, 172 insertions(+), 18 deletions(-) diff --git a/refstack/api/app.py b/refstack/api/app.py index 6f08241c..85ce9729 100644 --- a/refstack/api/app.py +++ b/refstack/api/app.py @@ -28,6 +28,7 @@ import webob from refstack.api import exceptions as api_exc from refstack.api import utils as api_utils +from refstack.api import constants as const from refstack import db LOG = log.getLogger(__name__) @@ -188,6 +189,16 @@ class CORSHook(pecan.hooks.PecanHook): state.response.headers['Access-Control-Allow-Credentials'] = 'true' +class JWTAuthHook(pecan.hooks.PecanHook): + """A pecan hook that handles authentication with JSON Web Tokens.""" + + def on_route(self, state): + """Check signature in request headers.""" + token = api_utils.decode_token(state.request) + if token: + state.request.environ[const.JWT_TOKEN_ENV] = token + + def setup_app(config): """App factory.""" # By default we expect path to oslo config file in environment variable @@ -211,17 +222,19 @@ def setup_app(config): template_path = CONF.api.template_path % {'project_root': PROJECT_ROOT} static_root = CONF.api.static_root % {'project_root': PROJECT_ROOT} - app_conf = dict(config.app) app = pecan.make_app( app_conf.pop('root'), debug=CONF.api.app_dev_mode, static_root=static_root, template_path=template_path, - hooks=[JSONErrorHook(), CORSHook(), pecan.hooks.RequestViewerHook( - {'items': ['status', 'method', 'controller', 'path', 'body']}, - headers=False, writer=WritableLogger(LOG, logging.DEBUG) - )] + hooks=[ + JWTAuthHook(), JSONErrorHook(), CORSHook(), + pecan.hooks.RequestViewerHook( + {'items': ['status', 'method', 'controller', 'path', 'body']}, + headers=False, writer=WritableLogger(LOG, logging.DEBUG) + ) + ] ) beaker_conf = { diff --git a/refstack/api/constants.py b/refstack/api/constants.py index 43560b26..1ed32d97 100644 --- a/refstack/api/constants.py +++ b/refstack/api/constants.py @@ -81,3 +81,7 @@ SOFTWARE = 1 DISTRO = 0 PUBLIC_CLOUD = 1 HOSTED_PRIVATE_CLOUD = 2 + +JWT_TOKEN_HEADER = 'Authorization' +JWT_TOKEN_ENV = 'jwt.token' +JWT_VALIDATION_LEEWAY = 42 diff --git a/refstack/api/utils.py b/refstack/api/utils.py index fe8fc5bd..2af24cc7 100644 --- a/refstack/api/utils.py +++ b/refstack/api/utils.py @@ -14,6 +14,7 @@ # under the License. """Refstack API's utils.""" +import binascii import copy import functools import random @@ -21,11 +22,13 @@ import requests import string import types +from Crypto.PublicKey import RSA from oslo_config import cfg from oslo_log import log from oslo_utils import timeutils import pecan import pecan.rest +import jwt import six from six.moves.urllib import parse @@ -176,14 +179,26 @@ def get_user_session(): return pecan.request.environ['beaker.session'] -def get_user_id(): +def get_token_data(): + """Return dict with data encoded from token.""" + return pecan.request.environ.get(const.JWT_TOKEN_ENV) + + +def get_user_id(from_session=True, from_token=True): """Return authenticated user id.""" - return get_user_session().get(const.USER_OPENID) + session = get_user_session() + token = get_token_data() + if from_session and session.get(const.USER_OPENID): + return session.get(const.USER_OPENID) + elif from_token and token: + return token.get(const.USER_OPENID) -def get_user(): +def get_user(user_id=None): """Return db record for authenticated user.""" - return db.user_get(get_user_id()) + if not user_id: + user_id = get_user_id() + return db.user_get(user_id) def get_user_public_keys(): @@ -191,11 +206,12 @@ def get_user_public_keys(): return db.get_user_pubkeys(get_user_id()) -def is_authenticated(): +def is_authenticated(by_session=True, by_token=True): """Return True if user is authenticated.""" - if get_user_id(): + user_id = get_user_id(from_session=by_session, from_token=by_token) + if user_id: try: - if get_user(): + if get_user(user_id=user_id): return True except db.NotFound: pass @@ -345,3 +361,52 @@ def check_user_is_product_admin(product_id): product = db.get_product(product_id) vendor_id = product['organization_id'] return check_user_is_vendor_admin(vendor_id) + + +def decode_token(request): + """Validate request signature. + + ValidationError rises if request is not valid. + """ + if not request.headers.get(const.JWT_TOKEN_HEADER): + return + try: + auth_schema, token = request.headers.get( + const.JWT_TOKEN_HEADER).split(' ', 1) + except ValueError: + raise api_exc.ValidationError("Token is not valid") + if auth_schema != 'Bearer': + raise api_exc.ValidationError( + "Authorization schema 'Bearer' should be used") + try: + token_data = jwt.decode(token, algorithms='RS256', verify=False) + except jwt.InvalidTokenError: + raise api_exc.ValidationError("Token is not valid") + + openid = token_data.get(const.USER_OPENID) + if not openid: + raise api_exc.ValidationError("Token does not contain user's openid") + pubkeys = db.get_user_pubkeys(openid) + for pubkey in pubkeys: + try: + pem_pubkey = RSA.importKey( + '%s %s' % (pubkey['format'], pubkey['pubkey']) + ).exportKey(format='PEM') + except (ValueError, IndexError, TypeError, binascii.Error): + pass + else: + try: + token_data = jwt.decode( + token, key=pem_pubkey, + options={'verify_signature': True, + 'verify_exp': True, + 'require_exp': True}, + leeway=const.JWT_VALIDATION_LEEWAY) + # NOTE(sslipushenko) If at least one key is valid, let + # the validation pass + return token_data + except jwt.InvalidTokenError: + pass + + # NOTE(sslipushenko) If all user's keys are not valid, the validation fails + raise api_exc.ValidationError("Token is not valid") diff --git a/refstack/tests/unit/test_api_utils.py b/refstack/tests/unit/test_api_utils.py index c318b5d6..ce018ee3 100644 --- a/refstack/tests/unit/test_api_utils.py +++ b/refstack/tests/unit/test_api_utils.py @@ -14,12 +14,15 @@ # under the License. """Tests for API's utils""" +import time import mock from oslo_config import fixture as config_fixture from oslo_utils import timeutils from oslotest import base from pecan import rest +import jwt +import six from six.moves.urllib import parse from webob import exc @@ -28,6 +31,20 @@ from refstack.api import exceptions as api_exc from refstack.api import utils as api_utils from refstack import db +PRIV_KEY = '''-----BEGIN PRIVATE KEY----- +MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA2tgf+sqQ/aI7Cytr +cpQYzbpOk1xy9GQP+kFN8ewIJgSLKX9bJf+7YqRuK8vsdtmPWVaLZtKTpPnXL0lM +jMotYwIDAQABAkA1eKtPruEAZ/w/PWuygkcRNV1vmh4oYq6Yug4ed0qCZxPxkBNx +0nnK9LeiWDnSCQ/Fi46y7XS6BLsbZ2wqGarJAiEA+r6oaDqFoScgl7KyQfkIY7ph +bnlIxVm4HWCLwEH4020CIQDfbk76sO8NuUbSaU6tIAoF9jmtaSW7kMr8/7M+SISy +DwIhAKsUaLzsqP4iPyehoeRHcMTyhsWkdNVJ+Mf6dn+Pw6ElAiEAnHFgW6gHulRA +gpO5wv7sBcCiIgm9odeASiXAG5wrTYECIHKU0v03nQlGOL2HUognsEw/nihi/667 +pcPXhEWd4qmC +-----END PRIVATE KEY-----''' + +PUB_KEY = ('AAAAB3NzaC1yc2EAAAADAQABAAAAQQDa2B/6ypD9ojsLK2tylBjNuk6TXH' + 'L0ZA/6QU3x7AgmBIspf1sl/7tipG4ry+x22Y9ZVotm0pOk+dcvSUyMyi1j') + class APIUtilsTestCase(base.BaseTestCase): @@ -322,13 +339,25 @@ class APIUtilsTestCase(base.BaseTestCase): @mock.patch.object(api_utils, 'get_user_session') @mock.patch.object(api_utils, 'db') - def test_is_authenticated(self, mock_db, mock_get_user_session): - mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.com'}) + @mock.patch('pecan.request') + def test_is_authenticated(self, mock_request, + mock_db, mock_get_user_session): + mock_request.headers = {} + mock_session = {const.USER_OPENID: 'foo@bar.com'} mock_get_user_session.return_value = mock_session mock_get_user = mock_db.user_get - mock_get_user.return_value = 'Dobby' + mock_get_user.return_value = 'FAKE_USER' self.assertTrue(api_utils.is_authenticated()) - mock_db.user_get.called_once_with(mock_session) + mock_db.user_get.assert_called_once_with('foo@bar.com') + + mock_request.environ = { + const.JWT_TOKEN_ENV: {const.USER_OPENID: 'foo@bar.com'}} + mock_get_user_session.return_value = {} + mock_get_user.reset_mock() + mock_get_user.return_value = 'FAKE_USER' + self.assertTrue(api_utils.is_authenticated()) + mock_get_user.assert_called_once_with('foo@bar.com') + mock_db.NotFound = db.NotFound mock_get_user.side_effect = mock_db.NotFound('User') self.assertFalse(api_utils.is_authenticated()) @@ -509,3 +538,42 @@ class APIUtilsTestCase(base.BaseTestCase): mock_db.return_value = ['another-user'] result = api_utils.check_user_is_vendor_admin('some-vendor') self.assertFalse(result) + + @mock.patch('refstack.db.get_user_pubkeys') + def test_encode_token(self, mock_pubkey): + mock_request = mock.MagicMock() + mock_request.headers = {} + self.assertIsNone(api_utils.decode_token(mock_request)) + + fake_token = jwt.encode({'foo': 'bar'}, key=PRIV_KEY, + algorithm='RS256') + auth_str = 'Bearer %s' % six.text_type(fake_token, 'utf-8') + mock_request.headers = {const.JWT_TOKEN_HEADER: auth_str} + self.assertRaises(api_exc.ValidationError, api_utils.decode_token, + mock_request) + + fake_token = jwt.encode({const.USER_OPENID: 'oid'}, key=PRIV_KEY, + algorithm='RS256') + auth_str = 'Bearer %s' % six.text_type(fake_token, 'utf-8') + mock_request.headers = {const.JWT_TOKEN_HEADER: auth_str} + mock_pubkey.return_value = [{'format': 'ssh-rsa', + 'pubkey': 'fakepubkey'}] + self.assertRaises(api_exc.ValidationError, api_utils.decode_token, + mock_request) + + mock_pubkey.return_value = [{'format': 'ssh-rsa', + 'pubkey': PUB_KEY}] + self.assertRaises(api_exc.ValidationError, api_utils.decode_token, + mock_request) + + fake_token = jwt.encode({const.USER_OPENID: 'oid', + 'exp': int(time.time()) + 3600}, + key=PRIV_KEY, + algorithm='RS256') + auth_str = 'Bearer %s' % six.text_type(fake_token, 'utf-8') + mock_request.headers = {const.JWT_TOKEN_HEADER: auth_str} + mock_pubkey.return_value = [{'format': 'ssh-rsa', + 'pubkey': PUB_KEY}] + self.assertEqual('oid', + api_utils.decode_token( + mock_request)[const.USER_OPENID]) diff --git a/refstack/tests/unit/test_app.py b/refstack/tests/unit/test_app.py index a661607f..d0b104bc 100644 --- a/refstack/tests/unit/test_app.py +++ b/refstack/tests/unit/test_app.py @@ -183,12 +183,13 @@ class SetupAppTestCase(base.BaseTestCase): @mock.patch('pecan.hooks') @mock.patch.object(app, 'JSONErrorHook') @mock.patch.object(app, 'CORSHook') + @mock.patch.object(app, 'JWTAuthHook') @mock.patch('os.path.join') @mock.patch('pecan.make_app') @mock.patch('refstack.api.app.SessionMiddleware') @mock.patch('refstack.api.utils.get_token', return_value='42') def test_setup_app(self, get_token, session_middleware, make_app, os_join, - json_error_hook, cors_hook, pecan_hooks): + auth_hook, json_error_hook, cors_hook, pecan_hooks): self.CONF.set_override('app_dev_mode', True, @@ -207,6 +208,7 @@ class SetupAppTestCase(base.BaseTestCase): json_error_hook.return_value = 'json_error_hook' cors_hook.return_value = 'cors_hook' + auth_hook.return_value = 'jwt_auth_hook' pecan_hooks.RequestViewerHook.return_value = 'request_viewer_hook' pecan_config = mock.Mock() pecan_config.app = {'root': 'fake_pecan_config'} @@ -223,7 +225,8 @@ class SetupAppTestCase(base.BaseTestCase): debug=True, static_root='fake_static_root', template_path='fake_template_path', - hooks=['cors_hook', 'json_error_hook', 'request_viewer_hook'] + hooks=['jwt_auth_hook', 'cors_hook', 'json_error_hook', + 'request_viewer_hook'] ) session_middleware.assert_called_once_with( 'fake_app', diff --git a/requirements.txt b/requirements.txt index 9fc5f1dc..2da639d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ requests>=2.2.0,!=2.4.0 requests-cache>=0.4.9 jsonschema>=2.0.0,<3.0.0 PyMySQL>=0.6.2,!=0.6.4 +PyJWT>=1.0.1 # MIT \ No newline at end of file