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 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(
|
||||
hooks=[
|
||||
JWTAuthHook(), JSONErrorHook(), CORSHook(),
|
||||
pecan.hooks.RequestViewerHook(
|
||||
{'items': ['status', 'method', 'controller', 'path', 'body']},
|
||||
headers=False, writer=WritableLogger(LOG, logging.DEBUG)
|
||||
)]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
beaker_conf = {
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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])
|
||||
|
@ -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',
|
||||
|
@ -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
|
Loading…
Reference in New Issue
Block a user