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 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 = {

View File

@ -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

View File

@ -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")

View File

@ -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])

View File

@ -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',

View File

@ -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