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:
sslipushenko 2016-12-09 18:43:20 +02:00 committed by Sergey Slipushenko
parent d6b5f7f2ab
commit ddcefa47ed
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(
{'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 = {

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