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