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
This commit is contained in:
parent
d6b5f7f2ab
commit
ddcefa47ed
@ -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 = {
|
||||
|
@ -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…
x
Reference in New Issue
Block a user