Merge "Add authentication with Json Web Tokens"

This commit is contained in:
Jenkins 2017-01-04 23:16:14 +00:00 committed by Gerrit Code Review
commit 27215d8776
6 changed files with 172 additions and 18 deletions

View File

@ -28,6 +28,7 @@ import webob
from refstack.api import exceptions as api_exc from refstack.api import exceptions as api_exc
from refstack.api import utils as api_utils from refstack.api import utils as api_utils
from refstack.api import constants as const
from refstack import db from refstack import db
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -188,6 +189,16 @@ class CORSHook(pecan.hooks.PecanHook):
state.response.headers['Access-Control-Allow-Credentials'] = 'true' 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): def setup_app(config):
"""App factory.""" """App factory."""
# By default we expect path to oslo config file in environment variable # 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} template_path = CONF.api.template_path % {'project_root': PROJECT_ROOT}
static_root = CONF.api.static_root % {'project_root': PROJECT_ROOT} static_root = CONF.api.static_root % {'project_root': PROJECT_ROOT}
app_conf = dict(config.app) app_conf = dict(config.app)
app = pecan.make_app( app = pecan.make_app(
app_conf.pop('root'), app_conf.pop('root'),
debug=CONF.api.app_dev_mode, debug=CONF.api.app_dev_mode,
static_root=static_root, static_root=static_root,
template_path=template_path, template_path=template_path,
hooks=[JSONErrorHook(), CORSHook(), pecan.hooks.RequestViewerHook( hooks=[
JWTAuthHook(), JSONErrorHook(), CORSHook(),
pecan.hooks.RequestViewerHook(
{'items': ['status', 'method', 'controller', 'path', 'body']}, {'items': ['status', 'method', 'controller', 'path', 'body']},
headers=False, writer=WritableLogger(LOG, logging.DEBUG) headers=False, writer=WritableLogger(LOG, logging.DEBUG)
)] )
]
) )
beaker_conf = { beaker_conf = {

View File

@ -81,3 +81,7 @@ SOFTWARE = 1
DISTRO = 0 DISTRO = 0
PUBLIC_CLOUD = 1 PUBLIC_CLOUD = 1
HOSTED_PRIVATE_CLOUD = 2 HOSTED_PRIVATE_CLOUD = 2
JWT_TOKEN_HEADER = 'Authorization'
JWT_TOKEN_ENV = 'jwt.token'
JWT_VALIDATION_LEEWAY = 42

View File

@ -14,6 +14,7 @@
# under the License. # under the License.
"""Refstack API's utils.""" """Refstack API's utils."""
import binascii
import copy import copy
import functools import functools
import random import random
@ -21,11 +22,13 @@ import requests
import string import string
import types import types
from Crypto.PublicKey import RSA
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log from oslo_log import log
from oslo_utils import timeutils from oslo_utils import timeutils
import pecan import pecan
import pecan.rest import pecan.rest
import jwt
import six import six
from six.moves.urllib import parse from six.moves.urllib import parse
@ -176,14 +179,26 @@ def get_user_session():
return pecan.request.environ['beaker.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 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 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(): def get_user_public_keys():
@ -191,11 +206,12 @@ def get_user_public_keys():
return db.get_user_pubkeys(get_user_id()) 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.""" """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: try:
if get_user(): if get_user(user_id=user_id):
return True return True
except db.NotFound: except db.NotFound:
pass pass
@ -345,3 +361,52 @@ def check_user_is_product_admin(product_id):
product = db.get_product(product_id) product = db.get_product(product_id)
vendor_id = product['organization_id'] vendor_id = product['organization_id']
return check_user_is_vendor_admin(vendor_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")

View File

@ -14,12 +14,15 @@
# under the License. # under the License.
"""Tests for API's utils""" """Tests for API's utils"""
import time
import mock import mock
from oslo_config import fixture as config_fixture from oslo_config import fixture as config_fixture
from oslo_utils import timeutils from oslo_utils import timeutils
from oslotest import base from oslotest import base
from pecan import rest from pecan import rest
import jwt
import six
from six.moves.urllib import parse from six.moves.urllib import parse
from webob import exc 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.api import utils as api_utils
from refstack import db 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): 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, 'get_user_session')
@mock.patch.object(api_utils, 'db') @mock.patch.object(api_utils, 'db')
def test_is_authenticated(self, mock_db, mock_get_user_session): @mock.patch('pecan.request')
mock_session = mock.MagicMock(**{const.USER_OPENID: 'foo@bar.com'}) 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_session.return_value = mock_session
mock_get_user = mock_db.user_get 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()) 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_db.NotFound = db.NotFound
mock_get_user.side_effect = mock_db.NotFound('User') mock_get_user.side_effect = mock_db.NotFound('User')
self.assertFalse(api_utils.is_authenticated()) self.assertFalse(api_utils.is_authenticated())
@ -509,3 +538,42 @@ class APIUtilsTestCase(base.BaseTestCase):
mock_db.return_value = ['another-user'] mock_db.return_value = ['another-user']
result = api_utils.check_user_is_vendor_admin('some-vendor') result = api_utils.check_user_is_vendor_admin('some-vendor')
self.assertFalse(result) 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])

View File

@ -183,12 +183,13 @@ class SetupAppTestCase(base.BaseTestCase):
@mock.patch('pecan.hooks') @mock.patch('pecan.hooks')
@mock.patch.object(app, 'JSONErrorHook') @mock.patch.object(app, 'JSONErrorHook')
@mock.patch.object(app, 'CORSHook') @mock.patch.object(app, 'CORSHook')
@mock.patch.object(app, 'JWTAuthHook')
@mock.patch('os.path.join') @mock.patch('os.path.join')
@mock.patch('pecan.make_app') @mock.patch('pecan.make_app')
@mock.patch('refstack.api.app.SessionMiddleware') @mock.patch('refstack.api.app.SessionMiddleware')
@mock.patch('refstack.api.utils.get_token', return_value='42') @mock.patch('refstack.api.utils.get_token', return_value='42')
def test_setup_app(self, get_token, session_middleware, make_app, os_join, 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', self.CONF.set_override('app_dev_mode',
True, True,
@ -207,6 +208,7 @@ class SetupAppTestCase(base.BaseTestCase):
json_error_hook.return_value = 'json_error_hook' json_error_hook.return_value = 'json_error_hook'
cors_hook.return_value = 'cors_hook' cors_hook.return_value = 'cors_hook'
auth_hook.return_value = 'jwt_auth_hook'
pecan_hooks.RequestViewerHook.return_value = 'request_viewer_hook' pecan_hooks.RequestViewerHook.return_value = 'request_viewer_hook'
pecan_config = mock.Mock() pecan_config = mock.Mock()
pecan_config.app = {'root': 'fake_pecan_config'} pecan_config.app = {'root': 'fake_pecan_config'}
@ -223,7 +225,8 @@ class SetupAppTestCase(base.BaseTestCase):
debug=True, debug=True,
static_root='fake_static_root', static_root='fake_static_root',
template_path='fake_template_path', 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( session_middleware.assert_called_once_with(
'fake_app', 'fake_app',

View File

@ -13,3 +13,4 @@ requests>=2.2.0,!=2.4.0
requests-cache>=0.4.9 requests-cache>=0.4.9
jsonschema>=2.0.0,<3.0.0 jsonschema>=2.0.0,<3.0.0
PyMySQL>=0.6.2,!=0.6.4 PyMySQL>=0.6.2,!=0.6.4
PyJWT>=1.0.1 # MIT