Merge "Add authentication with Json Web Tokens"
This commit is contained in:
commit
27215d8776
@ -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 = {
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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])
|
||||||
|
@ -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',
|
||||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user