diff --git a/nova/exception.py b/nova/exception.py index bf8a8e48526b..799cd07b0988 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -1896,6 +1896,10 @@ class InvalidToken(Invalid): msg_fmt = _("The token '%(token)s' is invalid or has expired") +class TokenInUse(Invalid): + msg_fmt = _("The generated token is invalid") + + class InvalidConnectionInfo(Invalid): msg_fmt = _("Invalid Connection Info") diff --git a/nova/objects/__init__.py b/nova/objects/__init__.py index d7b97f2da6f0..380b1025611b 100644 --- a/nova/objects/__init__.py +++ b/nova/objects/__init__.py @@ -32,6 +32,7 @@ def register_all(): __import__('nova.objects.cell_mapping') __import__('nova.objects.compute_node') __import__('nova.objects.diagnostics') + __import__('nova.objects.console_auth_token') __import__('nova.objects.dns_domain') __import__('nova.objects.ec2') __import__('nova.objects.external_event') diff --git a/nova/objects/console_auth_token.py b/nova/objects/console_auth_token.py new file mode 100644 index 000000000000..606cd09b3c6b --- /dev/null +++ b/nova/objects/console_auth_token.py @@ -0,0 +1,170 @@ +# Copyright 2015 Intel Corp +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_db.exception import DBDuplicateEntry +from oslo_log import log as logging +from oslo_utils import strutils +from oslo_utils import timeutils +from oslo_utils import uuidutils + +import nova.conf +from nova import db +from nova import exception +from nova.i18n import _ +from nova.objects import base +from nova.objects import fields +from nova import utils + +CONF = nova.conf.CONF +LOG = logging.getLogger(__name__) + + +@base.NovaObjectRegistry.register +class ConsoleAuthToken(base.NovaTimestampObject, base.NovaObject): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'id': fields.IntegerField(), + 'console_type': fields.StringField(nullable=False), + 'host': fields.StringField(nullable=False), + 'port': fields.IntegerField(nullable=False), + 'internal_access_path': fields.StringField(nullable=True), + 'instance_uuid': fields.UUIDField(nullable=False), + 'access_url_base': fields.StringField(nullable=True), + # NOTE(PaulMurray): The unhashed token field is not stored in the + # database. A hash of the token is stored instead and is not a + # field on the object. + 'token': fields.StringField(nullable=False), + } + + @property + def access_url(self): + """The access url with token parameter. + + :returns: the access url with credential parameters + + access_url_base is the base url used to access a console. + Adding the unhashed token as a parameter in a query string makes it + specific to this authorization. + """ + if self.obj_attr_is_set('id'): + return '%s?token=%s' % (self.access_url_base, self.token) + + @staticmethod + def _from_db_object(context, obj, db_obj): + # NOTE(PaulMurray): token is not stored in the database but + # this function assumes it is in db_obj. The unhashed token + # field is populated in the authorize method after the token + # authorization is created in the database. + for field in obj.fields: + setattr(obj, field, db_obj[field]) + obj._context = context + obj.obj_reset_changes() + return obj + + @base.remotable + def authorize(self, ttl): + """Authorise the console token and store in the database. + + :param ttl: time to live in seconds + :returns: an authorized token + + The expires value is set for ttl seconds in the future and the token + hash is stored in the database. This function can only succeed if the + token is unique and the object has not already been stored. + """ + if self.obj_attr_is_set('id'): + raise exception.ObjectActionError( + action='authorize', + reason=_('must be a new object to authorize')) + + token = uuidutils.generate_uuid() + token_hash = utils.get_sha256_str(token) + expires = timeutils.utcnow_ts() + ttl + + updates = self.obj_get_changes() + # NOTE(melwitt): token could be in the updates if authorize() has been + # called twice on the same object. 'token' is not a database column and + # should not be included in the call to create the database record. + if 'token' in updates: + del updates['token'] + updates['token_hash'] = token_hash + updates['expires'] = expires + + try: + db_obj = db.console_auth_token_create(self._context, updates) + db_obj['token'] = token + self._from_db_object(self._context, self, db_obj) + except DBDuplicateEntry: + # NOTE(PaulMurray) we are generating the token above so this + # should almost never happen - but technically its possible + raise exception.TokenInUse() + + LOG.debug("Authorized token with expiry %(expires)s for console " + "connection %(console)s", + {'expires': expires, + 'console': strutils.mask_password(self)}) + return token + + @base.remotable_classmethod + def validate(cls, context, token): + """Validate the token. + + :param context: the context + :param token: the token for the authorization + :returns: The ConsoleAuthToken object if valid + + The token is valid if the token is in the database and the expires + time has not passed. + """ + token_hash = utils.get_sha256_str(token) + db_obj = db.console_auth_token_get_valid(context, token_hash) + + if db_obj is not None: + db_obj['token'] = token + obj = cls._from_db_object(context, cls(), db_obj) + LOG.debug("Validated token - console connection is " + "%(console)s", + {'console': strutils.mask_password(obj)}) + return obj + else: + LOG.debug("Token validation failed") + raise exception.InvalidToken(token='***') + + @base.remotable_classmethod + def clean_console_auths_for_instance(cls, context, instance_uuid): + """Remove all console authorizations for the instance. + + :param context: the context + :param instance_uuid: the instance to be cleaned + + All authorizations related to the specified instance will be + removed from the database. + """ + db.console_auth_token_destroy_all_by_instance(context, instance_uuid) + + @base.remotable_classmethod + def clean_expired_console_auths_for_host(cls, context, host): + """Remove all expired console authorizations for the host. + + :param context: the context + :param host: the host name + + All expired authorizations related to the specified host + will be removed. Tokens that have not expired will + remain. + """ + db.console_auth_token_destroy_expired_by_host(context, host) diff --git a/nova/tests/functional/db/test_console_auth_token.py b/nova/tests/functional/db/test_console_auth_token.py new file mode 100644 index 000000000000..79099c28c046 --- /dev/null +++ b/nova/tests/functional/db/test_console_auth_token.py @@ -0,0 +1,57 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from oslo_versionedobjects import fixture as ovo_fixture + +from nova import context +from nova import exception +from nova import objects +from nova import test +from nova.tests import uuidsentinel + + +class ConsoleAuthTokenTestCase(test.TestCase): + def setUp(self): + super(ConsoleAuthTokenTestCase, self).setUp() + self.context = context.RequestContext('fake-user', 'fake-project') + instance = objects.Instance( + context=self.context, + project_id=self.context.project_id, + uuid=uuidsentinel.fake_instance) + instance.create() + self.console = objects.ConsoleAuthToken( + context=self.context, + instance_uuid=uuidsentinel.fake_instance, + console_type='fake-type', + host='fake-host', + port=1000, + internal_access_path='fake-internal_access_path', + access_url_base='fake-external_access_path' + ) + self.token = self.console.authorize(100) + + def test_validate(self): + connection_info = objects.ConsoleAuthToken.validate( + self.context, self.token) + expected = self.console.obj_to_primitive()['nova_object.data'] + del expected['created_at'] + ovo_fixture.compare_obj(self, connection_info, expected, + allow_missing=['created_at']) + + def test_validate_invalid(self): + unauthorized_token = uuidsentinel.token + self.assertRaises( + exception.InvalidToken, + objects.ConsoleAuthToken.validate, + self.context, unauthorized_token) diff --git a/nova/tests/unit/objects/test_console_auth_token.py b/nova/tests/unit/objects/test_console_auth_token.py new file mode 100644 index 000000000000..b92399a5f432 --- /dev/null +++ b/nova/tests/unit/objects/test_console_auth_token.py @@ -0,0 +1,165 @@ +# Copyright 2016 Intel Corp. +# Copyright 2016 Hewlett Packard Enterprise Development Company LP +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import mock + +from oslo_db.exception import DBDuplicateEntry +from oslo_utils import timeutils + +from nova import exception +from nova.objects import console_auth_token as token_obj +from nova.tests.unit import fake_console_auth_token as fakes +from nova.tests.unit.objects import test_objects +from nova.tests import uuidsentinel + + +class _TestConsoleAuthToken(object): + + @mock.patch('nova.db.console_auth_token_create') + def test_authorize(self, mock_create): + # the expires time is calculated from the current time and + # a ttl value in the object. Fix the current time so we can + # test expires is calculated correctly as expected + self.addCleanup(timeutils.clear_time_override) + timeutils.set_time_override() + ttl = 10 + expires = timeutils.utcnow_ts() + ttl + + db_dict = copy.deepcopy(fakes.fake_token_dict) + db_dict['expires'] = expires + mock_create.return_value = db_dict + + create_dict = copy.deepcopy(fakes.fake_token_dict) + create_dict['expires'] = expires + del create_dict['id'] + del create_dict['created_at'] + del create_dict['updated_at'] + + expected = copy.deepcopy(fakes.fake_token_dict) + del expected['token_hash'] + del expected['expires'] + expected['token'] = fakes.fake_token + + obj = token_obj.ConsoleAuthToken( + context=self.context, + console_type=fakes.fake_token_dict['console_type'], + host=fakes.fake_token_dict['host'], + port=fakes.fake_token_dict['port'], + internal_access_path=fakes.fake_token_dict['internal_access_path'], + instance_uuid=fakes.fake_token_dict['instance_uuid'], + access_url_base=fakes.fake_token_dict['access_url_base'], + ) + with mock.patch('uuid.uuid4', return_value=fakes.fake_token): + token = obj.authorize(ttl) + + mock_create.assert_called_once_with(self.context, create_dict) + self.assertEqual(token, fakes.fake_token) + self.compare_obj(obj, expected) + + url = obj.access_url + expected_url = '%s?token=%s' % ( + fakes.fake_token_dict['access_url_base'], + fakes.fake_token) + self.assertEqual(expected_url, url) + + @mock.patch('nova.db.console_auth_token_create') + def test_authorize_duplicate_token(self, mock_create): + mock_create.side_effect = DBDuplicateEntry() + + obj = token_obj.ConsoleAuthToken( + context=self.context, + console_type=fakes.fake_token_dict['console_type'], + host=fakes.fake_token_dict['host'], + port=fakes.fake_token_dict['port'], + internal_access_path=fakes.fake_token_dict['internal_access_path'], + instance_uuid=fakes.fake_token_dict['instance_uuid'], + access_url_base=fakes.fake_token_dict['access_url_base'], + ) + + self.assertRaises(exception.TokenInUse, + obj.authorize, + 100) + + @mock.patch('nova.db.console_auth_token_create') + def test_authorize_instance_not_found(self, mock_create): + mock_create.side_effect = exception.InstanceNotFound( + instance_id=fakes.fake_token_dict['instance_uuid']) + + obj = token_obj.ConsoleAuthToken( + context=self.context, + console_type=fakes.fake_token_dict['console_type'], + host=fakes.fake_token_dict['host'], + port=fakes.fake_token_dict['port'], + internal_access_path=fakes.fake_token_dict['internal_access_path'], + instance_uuid=fakes.fake_token_dict['instance_uuid'], + access_url_base=fakes.fake_token_dict['access_url_base'], + ) + + self.assertRaises(exception.InstanceNotFound, + obj.authorize, + 100) + + @mock.patch('nova.db.console_auth_token_create') + def test_authorize_object_already_created(self, mock_create): + # the expires time is calculated from the current time and + # a ttl value in the object. Fix the current time so we can + # test expires is calculated correctly as expected + self.addCleanup(timeutils.clear_time_override) + timeutils.set_time_override() + ttl = 10 + expires = timeutils.utcnow_ts() + ttl + + db_dict = copy.deepcopy(fakes.fake_token_dict) + db_dict['expires'] = expires + mock_create.return_value = db_dict + + obj = token_obj.ConsoleAuthToken( + context=self.context, + console_type=fakes.fake_token_dict['console_type'], + host=fakes.fake_token_dict['host'], + port=fakes.fake_token_dict['port'], + internal_access_path=fakes.fake_token_dict['internal_access_path'], + instance_uuid=fakes.fake_token_dict['instance_uuid'], + access_url_base=fakes.fake_token_dict['access_url_base'], + ) + obj.authorize(100) + self.assertRaises(exception.ObjectActionError, + obj.authorize, + 100) + + @mock.patch('nova.db.console_auth_token_destroy_all_by_instance') + def test_clean_console_auths_for_instance(self, mock_destroy): + token_obj.ConsoleAuthToken.clean_console_auths_for_instance( + self.context, uuidsentinel.instance) + mock_destroy.assert_called_once_with( + self.context, uuidsentinel.instance) + + @mock.patch('nova.db.console_auth_token_destroy_expired_by_host') + def test_clean_expired_console_auths_for_host(self, mock_destroy): + token_obj.ConsoleAuthToken.clean_expired_console_auths_for_host( + self.context, 'fake-host') + mock_destroy.assert_called_once_with( + self.context, 'fake-host') + + +class TestConsoleAuthToken(test_objects._LocalTest, + _TestConsoleAuthToken): + pass + + +class TestRemoteConsoleAuthToken(test_objects._RemoteTest, + _TestConsoleAuthToken): + pass diff --git a/nova/tests/unit/objects/test_objects.py b/nova/tests/unit/objects/test_objects.py index 94fb1d93aae9..2cea737bc39e 100644 --- a/nova/tests/unit/objects/test_objects.py +++ b/nova/tests/unit/objects/test_objects.py @@ -1073,6 +1073,7 @@ object_data = { 'ComputeNode': '1.18-431fafd8ac4a5f3559bd9b1f1332cc22', 'ComputeNodeList': '1.17-52f3b0962b1c86b98590144463ebb192', 'CpuDiagnostics': '1.0-d256f2e442d1b837735fd17dfe8e3d47', + 'ConsoleAuthToken': '1.0-a61bf7b54517c4013a12289c5a5268ea', 'DNSDomain': '1.0-7b0b2dab778454b6a7b6c66afe163a1a', 'DNSDomainList': '1.0-4ee0d9efdfd681fed822da88376e04d2', 'Destination': '1.1-fff0853f3acec6b04ddc03158ded11ba',