Use POST not GET for keypair generation

This patch fixes the Cross-Site Request Forgery (CSRF) attack against
the keypair generation pages:
- HORIZON_URL/project/key_pairs/PAIRNAME/generate/
- HORIZON_URL/project/key_pairs/PAIRNAME/download/
These pages exposed creating and/or overwriting a keypair with a given
name via a CSRF attack.

This patch closes these holes by using only POST-based keypair creation,
and exposing the keypair in the contents of a modal dialog instead of a
download, which ultimately requires a GET.  It uses the same client-side
features for both the Launch Instance keypair creation and Compute / Key
Pairs panel.

Closes-Bug: 1575913
Change-Id: Ie5ca28ff2bd806eb1481eba6f419b797b68856b6
This commit is contained in:
Matt Borland 2016-09-08 14:50:23 -06:00 committed by Gary Smith
parent ae764497ec
commit d07fedc45f
23 changed files with 299 additions and 784 deletions

View File

@ -15,8 +15,6 @@
"""
from collections import OrderedDict
from django.http import HttpResponse
from django.template.defaultfilters import slugify
from django.utils import http as utils_http
from django.utils.translation import ugettext_lazy as _
from django.views import generic
@ -87,47 +85,6 @@ class Keypairs(generic.View):
)
@urls.register
class Keypair(generic.View):
url_regex = r'nova/keypairs/(?P<keypair_name>.+)/$'
def get(self, request, keypair_name):
"""Creates a new keypair and associates it to the current project.
* Since the response for this endpoint creates a new keypair and
is not idempotent, it normally would be represented by a POST HTTP
request. However, this solution was adopted as it
would support automatic file download across browsers.
:param keypair_name: the name to associate the keypair to
:param regenerate: (optional) if set to the string 'true',
replaces the existing keypair with a new keypair
This returns the new keypair object on success.
"""
try:
regenerate = request.GET.get('regenerate') == 'true'
if regenerate:
api.nova.keypair_delete(request, keypair_name)
keypair = api.nova.keypair_create(request, keypair_name)
except exceptions.Conflict:
return HttpResponse(status=409)
except Exception:
return HttpResponse(status=500)
else:
response = HttpResponse(content_type='application/binary')
response['Content-Disposition'] = ('attachment; filename=%s.pem'
% slugify(keypair_name))
response.write(keypair.private_key)
response['Content-Length'] = str(len(response.content))
return response
@urls.register
class Services(generic.View):
"""API for nova services.

View File

@ -36,29 +36,6 @@ KEYPAIR_ERROR_MESSAGES = {
'and may not be white space.')}
class CreateKeypair(forms.SelfHandlingForm):
name = forms.RegexField(max_length=255,
label=_("Key Pair Name"),
regex=KEYPAIR_NAME_REGEX,
error_messages=KEYPAIR_ERROR_MESSAGES)
def handle(self, request, data):
return True # We just redirect to the download view.
def clean(self):
cleaned_data = super(CreateKeypair, self).clean()
name = cleaned_data.get('name')
try:
keypairs = api.nova.keypair_list(self.request)
except Exception:
exceptions.handle(self.request, ignore=True)
keypairs = []
if name in [keypair.name for keypair in keypairs]:
error_msg = _("The name is already in use.")
self._errors['name'] = self.error_class([error_msg])
return cleaned_data
class ImportKeypair(forms.SelfHandlingForm):
name = forms.RegexField(max_length=255,
label=_("Key Pair Name"),

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from django.core import urlresolvers
from django.utils.translation import string_concat
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy
@ -78,16 +79,28 @@ class ImportKeyPair(QuotaKeypairMixin, tables.LinkAction):
return True
class CreateKeyPair(QuotaKeypairMixin, tables.LinkAction):
name = "create"
class CreateLinkNG(QuotaKeypairMixin, tables.LinkAction):
name = "create-keypair-ng"
verbose_name = _("Create Key Pair")
url = "horizon:project:key_pairs:create"
classes = ("ajax-modal",)
url = "horizon:project:key_pairs:index"
classes = ("btn-launch",)
icon = "plus"
policy_rules = (("compute", "os_compute_api:os-keypairs:create"),)
def get_default_attrs(self):
url = urlresolvers.reverse(self.url)
ngclick = "modal.createKeyPair({ successUrl: '%s' })" % url
self.attrs.update({
'ng-controller': 'KeypairController as modal',
'ng-click': ngclick
})
return super(CreateLinkNG, self).get_default_attrs()
def get_link_url(self, datum=None):
return "javascript:void(0);"
def allowed(self, request, keypair=None):
if super(CreateKeyPair, self).allowed(request, keypair):
if super(CreateLinkNG, self).allowed(request, keypair):
self.verbose_name = _("Create Key Pair")
return True
@ -113,6 +126,6 @@ class KeyPairsTable(tables.DataTable):
class Meta(object):
name = "keypairs"
verbose_name = _("Key Pairs")
table_actions = (CreateKeyPair, ImportKeyPair, DeleteKeyPairs,
table_actions = (CreateLinkNG, ImportKeyPair, DeleteKeyPairs,
KeypairsFilterAction,)
row_actions = (DeleteKeyPairs,)

View File

@ -1,7 +0,0 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<p>{% trans "Key pairs are SSH credentials which are injected into images when they are launched. Creating a new key pair registers the public key and downloads the private key (a .pem file)." %}</p>
<p>{% trans "Protect and use the key as you would any normal SSH private key." %}</p>
{% endblock %}

View File

@ -1,8 +0,0 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{{ page_title }}{% endblock %}
{% block main %}
{% include 'project/key_pairs/_create.html' %}
{% endblock %}

View File

@ -22,8 +22,6 @@ from mox3.mox import IsA
import six
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.key_pairs.forms \
import CreateKeypair
from openstack_dashboard.dashboards.project.key_pairs.forms \
import KEYPAIR_ERROR_MESSAGES
from openstack_dashboard.test import helpers as test
@ -80,37 +78,6 @@ class KeyPairTests(test.TestCase):
res = self.client.post(INDEX_URL, formData)
self.assertRedirectsNoFollow(res, INDEX_URL)
def test_create_keypair_get(self):
res = self.client.get(
reverse('horizon:project:key_pairs:create'))
self.assertTemplateUsed(
res, 'project/key_pairs/create.html')
def test_download_keypair_get(self):
keypair_name = "keypair"
context = {'keypair_name': keypair_name}
url = reverse('horizon:project:key_pairs:download',
kwargs={'keypair_name': keypair_name})
res = self.client.get(url, context)
self.assertTemplateUsed(
res, 'project/key_pairs/download.html')
@test.create_stubs({api.nova: ('keypair_create',)})
def test_generate_keypair_get(self):
keypair = self.keypairs.first()
keypair.private_key = "secret"
api.nova.keypair_create(IsA(http.HttpRequest),
keypair.name).AndReturn(keypair)
self.mox.ReplayAll()
context = {'keypair_name': keypair.name}
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name})
res = self.client.get(url, context)
self.assertTrue(res.has_header('content-disposition'))
@test.create_stubs({api.nova: ('keypair_get',)})
def test_keypair_detail_get(self):
keypair = self.keypairs.first()
@ -126,22 +93,6 @@ class KeyPairTests(test.TestCase):
res = self.client.get(url, context)
self.assertContains(res, "<dd>%s</dd>" % keypair.name, 1, 200)
@test.create_stubs({api.nova: ("keypair_create", "keypair_delete",)})
def test_regenerate_keypair_get(self):
keypair = self.keypairs.first()
keypair.private_key = "secret"
optional_param = "regenerate"
api.nova.keypair_delete(IsA(http.HttpRequest), keypair.name)
api.nova.keypair_create(IsA(http.HttpRequest),
keypair.name).AndReturn(keypair)
self.mox.ReplayAll()
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name,
'optional': optional_param})
res = self.client.get(url)
self.assertTrue(res.has_header('content-disposition'))
@test.create_stubs({api.nova: ("keypair_import",)})
def test_import_keypair(self):
key1_name = "new_key_pair"
@ -203,22 +154,6 @@ class KeyPairTests(test.TestCase):
msg = six.text_type(KEYPAIR_ERROR_MESSAGES['invalid'])
self.assertFormErrors(res, count=1, message=msg)
@test.create_stubs({api.nova: ("keypair_create",)})
def test_generate_keypair_exception(self):
keypair = self.keypairs.first()
api.nova.keypair_create(IsA(http.HttpRequest), keypair.name) \
.AndRaise(self.exceptions.nova)
self.mox.ReplayAll()
context = {'keypair_name': keypair.name}
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name})
res = self.client.get(url, context)
self.assertRedirectsNoFollow(
res, reverse('horizon:project:key_pairs:index'))
@test.create_stubs({api.nova: ("keypair_import",)})
def test_import_keypair_with_regex_defined_name(self):
key1_name = "new-key-pair with_regex"
@ -235,42 +170,3 @@ class KeyPairTests(test.TestCase):
url = reverse('horizon:project:key_pairs:import')
res = self.client.post(url, formData)
self.assertMessageCount(res, success=1)
@test.create_stubs({api.nova: ("keypair_create",)})
def test_create_keypair_with_regex_name_get(self):
keypair = self.keypairs.first()
keypair.name = "key-space pair-regex_name-0123456789"
keypair.private_key = "secret"
api.nova.keypair_create(IsA(http.HttpRequest),
keypair.name).AndReturn(keypair)
self.mox.ReplayAll()
context = {'keypair_name': keypair.name}
url = reverse('horizon:project:key_pairs:generate',
kwargs={'keypair_name': keypair.name})
res = self.client.get(url, context)
self.assertTrue(res.has_header('content-disposition'))
def test_download_with_regex_name_get(self):
keypair_name = "key pair-regex_name-0123456789"
context = {'keypair_name': keypair_name}
url = reverse('horizon:project:key_pairs:download',
kwargs={'keypair_name': keypair_name})
res = self.client.get(url, context)
self.assertTemplateUsed(
res, 'project/key_pairs/download.html')
@test.create_stubs({api.nova: ('keypair_list',)})
def test_create_duplicate_keypair(self):
keypair_name = self.keypairs.first().name
api.nova.keypair_list(IsA(http.HttpRequest)) \
.AndReturn(self.keypairs.list())
self.mox.ReplayAll()
form = CreateKeypair(self.request, data={'name': keypair_name})
self.assertFalse(form.is_valid())
self.assertIn('The name is already in use.',
form.errors['name'][0])

View File

@ -21,14 +21,7 @@ from openstack_dashboard.dashboards.project.key_pairs import views
urlpatterns = [
url(r'^$', views.IndexView.as_view(), name='index'),
url(r'^create/$', views.CreateView.as_view(), name='create'),
url(r'^import/$', views.ImportView.as_view(), name='import'),
url(r'^(?P<keypair_name>[^/]+)/download/$', views.DownloadView.as_view(),
name='download'),
url(r'^(?P<keypair_name>[^/]+)/generate/$', views.GenerateView.as_view(),
name='generate'),
url(r'^(?P<keypair_name>[^/]+)/(?P<optional>[^/]+)/generate/$',
views.GenerateView.as_view(), name='generate'),
url(r'^(?P<keypair_name>[^/]+)/$', views.DetailView.as_view(),
name='detail'),
]

View File

@ -14,12 +14,7 @@
from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy
from django import http
from django.template.defaultfilters import slugify
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import cache_control
from django.views.decorators.cache import never_cache
from horizon import exceptions
from horizon import forms
@ -55,20 +50,6 @@ class IndexView(tables.DataTableView):
return keypairs
class CreateView(forms.ModalFormView):
form_class = key_pairs_forms.CreateKeypair
template_name = 'project/key_pairs/create.html'
submit_url = reverse_lazy(
"horizon:project:key_pairs:create")
success_url = 'horizon:project:key_pairs:download'
submit_label = page_title = _("Create Key Pair")
cancel_url = reverse_lazy("horizon:project:key_pairs:index")
def get_success_url(self):
return reverse(self.success_url,
kwargs={"keypair_name": self.request.POST['name']})
class ImportView(forms.ModalFormView):
form_class = key_pairs_forms.ImportKeypair
template_name = 'project/key_pairs/import.html'
@ -103,37 +84,3 @@ class DetailView(views.HorizonTemplateView):
context = super(DetailView, self).get_context_data(**kwargs)
context['keypair'] = self._get_data()
return context
class DownloadView(views.HorizonTemplateView):
template_name = 'project/key_pairs/download.html'
page_title = _("Download Key Pair")
def get_context_data(self, keypair_name=None):
return {'keypair_name': keypair_name}
class GenerateView(views.HorizonTemplateView):
# TODO(Itxaka): Remove cache_control in django >= 1.9
# https://code.djangoproject.com/ticket/13008
@method_decorator(cache_control(max_age=0, no_cache=True,
no_store=True, must_revalidate=True))
@method_decorator(never_cache)
def get(self, request, keypair_name=None, optional=None):
try:
if optional == "regenerate":
nova.keypair_delete(request, keypair_name)
keypair = nova.keypair_create(request, keypair_name)
except Exception:
redirect = reverse('horizon:project:key_pairs:index')
exceptions.handle(self.request,
_('Unable to create key pair: %(exc)s'),
redirect=redirect)
response = http.HttpResponse(content_type='application/binary')
response['Content-Disposition'] = ('attachment; filename=%s.pem'
% slugify(keypair.name))
response.write(keypair.private_key)
response['Content-Length'] = str(len(response.content))
return response

View File

@ -24,9 +24,7 @@
LaunchInstanceCreateKeyPairController.$inject = [
'$uibModalInstance',
'existingKeypairs',
'horizon.app.core.openstack-service-api.nova',
'horizon.framework.widgets.toast.service',
'horizon.app.core.openstack-service-api.keypair-download-service'
'horizon.app.core.openstack-service-api.nova'
];
/**
@ -35,22 +33,21 @@
* @param {Object} $uibModalInstance
* @param {Object} existingKeypairs
* @param {Object} nova
* @param {Object} toastService
* @param {Object} keypairDownloadService
* @description
* Provide a dialog for creation of a new key pair.
* @returns {undefined} Returns nothing
*/
function LaunchInstanceCreateKeyPairController($uibModalInstance, existingKeypairs, nova,
toastService, keypairDownloadService) {
function LaunchInstanceCreateKeyPairController($uibModalInstance, existingKeypairs, nova) {
var ctrl = this;
ctrl.submit = submit;
ctrl.cancel = cancel;
ctrl.doesKeypairExist = doesKeypairExist;
ctrl.generate = generate;
ctrl.keypair = '';
ctrl.keypairExistsError = gettext('Keypair already exists or name contains bad characters.');
ctrl.copyPrivateKey = copyPrivateKey;
/*
* @ngdoc function
@ -62,6 +59,21 @@
return exists(ctrl.keypair);
}
function generate() {
nova.createKeypair({name: ctrl.keypair}).then(onKeypairCreated);
function onKeypairCreated(data) {
ctrl.createdKeypair = data.data;
ctrl.privateKey = ctrl.createdKeypair.private_key;
ctrl.publicKey = ctrl.createdKeypair.public_key;
}
}
function copyPrivateKey() {
angular.element('textarea').select();
document.execCommand('copy');
}
/*
* @ngdoc function
* @name exists
@ -84,17 +96,7 @@
* notified of the problem and given the opportunity to try again.
*/
function submit() {
keypairDownloadService.createAndDownloadKeypair(ctrl.keypair).then(
function success(createdKeypair) {
createdKeypair.regenerateUrl = nova.getRegenerateKeypairUrl(createdKeypair.name);
$uibModalInstance.close(createdKeypair);
},
function error() {
var errorMessage = interpolate(gettext('Unable to generate "%s". Please try again.'),
[ctrl.keypair]);
toastService.add('error', errorMessage);
}
);
$uibModalInstance.close(ctrl.createdKeypair);
}
/*

View File

@ -77,36 +77,16 @@
});
it('should close the modal and return the created keypair', function() {
mockCreationSuccess = true;
mockKeypair = {
name: "newKeypair"
};
spyOn(createKeypairServiceMock, 'getRegenerateKeypairUrl').and.returnValue(
"a url"
);
spyOn(modalInstanceMock, 'close');
ctrl.createdKeypair = {name: 'newKeypair'};
ctrl.submit();
expect(modalInstanceMock.close).toHaveBeenCalledWith({
name: "newKeypair",
regenerateUrl: "a url"
name: "newKeypair"
});
});
it('should raise a toast error message when create is unsuccessful', function() {
mockCreationSuccess = false;
spyOn(toastServiceMock, 'add');
ctrl.keypair = "aKeypair";
ctrl.submit();
expect(toastServiceMock.add).toHaveBeenCalledWith(
'error',
'Unable to generate "aKeypair". Please try again.'
);
});
it('defines a submit function', function() {
expect(ctrl.submit).toBeDefined();
});

View File

@ -29,17 +29,30 @@
{$ ctrl.keypairExistsError $}
</span>
</div>
<div class="form-group" ng-if="ctrl.privateKey">
<label for="private-key" translate>
Private Key
<span class="hz-icon-required fa"></span>
</label>
<!-- Note: textarea is used here (instead of pre) due to the fact that ctrl.copyPrivateKey() uses
the HTMLInputElement.select() function which is only present on input elements -->
<textarea class="form-control" id="private-key" rows="15"
ng-model="ctrl.privateKey" readonly></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-default pull-left" ng-click="ctrl.cancel()">
<span class="fa fa-close"></span>
<translate>Cancel</translate>
</button>
<button class="btn btn-primary"
ng-click="ctrl.submit()" ng-disabled="wizardForm.$invalid || ctrl.doesKeypairExist()">
ng-click="ctrl.generate()" ng-disabled="wizardForm.$invalid || ctrl.doesKeypairExist() || ctrl.privateKey">
<translate>Create Keypair</translate>
</button>
<button class="btn btn-primary"
ng-click="ctrl.copyPrivateKey()" ng-disabled="!ctrl.privateKey">
<translate>Copy Private Key to Clipboard</translate>
</button>
<button class="btn btn-primary"
ng-click="ctrl.submit()" ng-disabled="!ctrl.privateKey">
<translate>Done</translate>
</button>
</div>
</div>

View File

@ -36,6 +36,7 @@
* @param {Object} launchInstanceModel
* @param {Object} $uibModal
* @param {Object} toastService
* @param {Object} settingsService
* @description
* Allows selection of key pairs.
* @returns {undefined} No return value

View File

@ -4,18 +4,6 @@
You may select an existing key pair, import a key pair, or generate a new key pair.
</p>
<div ng-if="ctrl.isKeypairCreated" class="alert alert-info" role="alert">
<p translate>A key pair named '{$ctrl.createdKeypair.name$}' was successfully created. This key pair should automatically download.</p>
<p translate>If not, you can manually download this keypair at the following link:</p>
<a class="btn btn-default" role="button" href="{$ ctrl.createdKeypair.regenerateUrl $}">
<span class="fa fa-download"></span>
{$ctrl.createdKeypair.name$}
</a>
<p translate>
Note: you will not be able to download this later.
</p>
</div>
<button type="button" class="btn btn-default"
ng-click="ctrl.createKeyPair()">
<span class="fa fa-plus"></span>

View File

@ -36,6 +36,7 @@
'horizon.app.core.cloud-services',
'horizon.app.core.flavors',
'horizon.app.core.images',
'horizon.app.core.keypairs',
'horizon.app.core.metadata',
'horizon.app.core.openstack-service-api',
'horizon.app.core.trunks',

View File

@ -0,0 +1,97 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* 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.
*/
(function () {
'use strict';
angular
.module('horizon.app.core.keypairs')
.controller('KeypairController', KeypairController);
KeypairController.$inject = [
'horizon.dashboard.project.workflow.launch-instance.basePath',
'horizon.app.core.openstack-service-api.nova',
'horizon.framework.widgets.modal-wait-spinner.service',
'$window',
'$uibModal'
];
/**
* @ngdoc controller
* @name KeypairController
* @param {string} basePath
* @param {Object} $uibModal
* @description
* Allows creation of key pairs.
* @returns {undefined} No return value
*/
function KeypairController(
basePath,
nova,
spinnerService,
$window,
$uibModal
) {
var ctrl = this;
ctrl.createKeyPair = createKeyPair;
//////////
function setKeyPairs(config) {
return function(response) {
var keyPairs = response.data.items.map(getName);
$uibModal.open({
templateUrl: basePath + 'keypair/create-keypair.html',
controller: 'LaunchInstanceCreateKeyPairController as ctrl',
windowClass: 'modal-dialog-wizard',
resolve: {
existingKeypairs: getKeypairs
}
}).result.then(go(config.successUrl));
function getName(item) {
return item.keypair.name;
}
function getKeypairs() {
return keyPairs;
}
};
}
/**
* @ngdoc function
* @name createKeyPair
* @description
* Launches the modal to create a key pair.
* @returns {undefined} No return value
*/
function createKeyPair(config) {
nova.getKeypairs().then(setKeyPairs(config));
}
function go(url) {
return function changeUrl() {
spinnerService.showModalSpinner(gettext('Please Wait'));
$window.location.href = url;
};
}
}
})();

View File

@ -0,0 +1,80 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* 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.
*/
(function() {
'use strict';
describe('KeypairController', function() {
var ctrl, keyPairCall, $timeout;
var nova = {
getKeypairs: function() {
var kps = {data: {items: [{keypair: {name: 'one'}},{keypair: {name: 'two'}} ]}};
keyPairCall.resolve(kps);
return keyPairCall.promise;
}
};
var spinner = {
showModalSpinner: angular.noop
};
var config = {successUrl: '/some/where'};
var $uibModal = {
open: angular.noop
};
var $window = {location: {}};
beforeEach(module('horizon.app.core.keypairs'));
beforeEach(
inject(
function($controller, $rootScope, $q, _$timeout_) {
$timeout = _$timeout_;
ctrl = $controller('KeypairController', {
'horizon.dashboard.project.workflow.launch-instance.basePath': '/here/',
'horizon.app.core.openstack-service-api.nova': nova,
'horizon.framework.widgets.modal-wait-spinner.service': spinner,
'$uibModal': $uibModal,
'$window': $window
});
keyPairCall = $q.defer();
spyOn($uibModal, 'open').and.returnValue({result: $q.resolve({})});
}
)
);
describe('createKeyPair', function() {
it('opens the modal', function() {
ctrl.createKeyPair(config);
$timeout.flush();
expect($uibModal.open).toHaveBeenCalled();
});
it('provides a function to existingKeypairs that returns keypair names', function() {
ctrl.createKeyPair(config);
$timeout.flush();
var func = $uibModal.open.calls.argsFor(0)[0].resolve.existingKeypairs;
expect(func()).toEqual(['one','two']);
});
it('relocates to the config successUrl', function() {
ctrl.createKeyPair(config);
$timeout.flush();
expect($window.location.href).toBe('/some/where');
});
});
});
})();

View File

@ -0,0 +1,33 @@
/**
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* 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.
*/
(function() {
'use strict';
/**
* @ngdoc overview
* @ngname horizon.app.core.keypairs
*
* @description
* Provides all of the services and widgets required
* to support and display keypairs related content.
*/
angular
.module('horizon.app.core.keypairs', [
])
;
})();

View File

@ -0,0 +1,25 @@
/*
* Copyright 2017 SUSE LLC
*
* 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.
*/
(function () {
'use strict';
describe('horizon.app.core.keypairs', function () {
it('should be defined', function () {
expect(angular.module('horizon.app.core.keypairs')).toBeDefined();
});
});
})();

View File

@ -1,153 +0,0 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* 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.
*/
(function () {
'use strict';
/**
* @ngdoc overview
* @name horizon.dashboard.project.workflow.keypair.create-keypair-service
*
* @description
* Service to handle creating keypairs and downloading their private keys.
* Please note, the implementation has quirks due to what features are
* available in which browsers. As a result, the implementation involves
* using iframes to specify downloads. Since the API does not allow the
* full retrieval of the key pair after creation, and the URL retrieved in
* an iframe must be a GET method, and the fact that we shouldn't pass
* data as part of the URL (because it is potentially logged and contains
* private key data), we have to make the actual API call when performing
* the GET. This also means that if the user misses the download for some
* reason, we need to provide the ability to regenerate the key pair,
* although that is not a feature of this service.
*/
angular
.module('horizon.app.core.openstack-service-api')
.factory('horizon.app.core.openstack-service-api.keypair-download-service',
keypairDownloadService);
keypairDownloadService.$inject = [
'$document',
'horizon.app.core.openstack-service-api.nova',
'$q',
'$timeout'
];
function keypairDownloadService($document, novaAPI, $q, $timeout) {
var service = {
createAndDownloadKeypair: createAndDownloadKeypair
};
return service;
/**
* @ngdoc function
* @name createAndDownloadKeypair
*
* @description
* This function performs the actions necessary to begin a download
* of a newly created key pair. The given name will be used as the
* logical name of the key pair and will be used to make a file-system-
* friendly filename.
* In this implementation, for browser compatibility reasons, the
* download is achieved by creating an iframe with the given path for
* the create API call given, so the results are streamed directly to the
* client. This is not ideal but is due to lack of support in IE for
* features like the data: protocol. The iframes require that an element
* with the class of 'download-iframes' is present.
*
* @param {string} name The desired name for the key pair
* @returns {promise} A promise resolving if true, rejecting with error
*/
function createAndDownloadKeypair(name) {
addDOMResource(name);
return verifyCreatedPromise(name);
}
/**
* @ngdoc function
* @name addDOMResource
*
* @description
* This adds an iframe to the body of the current document, using
* the appropriate URL for the API to create/download the new key pair.
*
* @param {string} keypairName The desired name for the key pair
* @returns {undefined} Returns nothing
*/
function addDOMResource(keypairName) {
var url = novaAPI.getCreateKeypairUrl(keypairName);
var iframe = angular.element("<iframe></iframe>");
iframe.attr('id', keypairName);
iframe.attr('src', url);
iframe.attr('style', 'display: none;');
if ($document.find('.download-iframes').size() === 0) {
var iframeContainer = angular.element('<div class="download-iframes"></div>');
$document.find('body').append(iframeContainer);
}
$document.find('.download-iframes').append(iframe);
}
/**
* @ngdoc function
* @name verifyCreatedPromise
*
* @description
* This function returns a promise that tries ten times to see if a
* key pair of the given name exists in the key pair listing. These
* tries are one second apart. Once it has been found, the promise
* is resolved with the key pair data. If it is not found within the
* period, the promise is rejected.
*
* @param {string} name The name for the key pair
* @returns {promise} A promise resolving if true, rejecting with error
*/
function verifyCreatedPromise(name) {
return $q(function doesKeypairExistPromise(resolve, reject) {
doesKeypairExist(10);
function doesKeypairExist(timesToCheck) {
$timeout(function doesKeypairExistTimeout() {
novaAPI.getKeypairs().then(function isKeypairInResponse(response) {
var foundKeypairs = response.data.items.filter(function sameName(item) {
return item.keypair.name === name;
});
if (foundKeypairs.length === 1) {
resolve(foundKeypairs[0].keypair);
angular.element('.download-iframes #' + name).remove();
} else if (timesToCheck > 1) {
doesKeypairExist(timesToCheck - 1);
} else {
reject();
angular.element('.download-iframes #' + name).remove();
}
});
},
1000);
}
});
}
}
})();

View File

@ -1,166 +0,0 @@
/*
* (c) Copyright 2015 Hewlett-Packard Development Company, L.P.
*
* 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.
*/
(function() {
'use strict';
describe('Download Keypair Service', function() {
var service, $scope, existingKeypairs, $timeout;
var documentMock = {
find: function() {
return documentFindMock;
}
};
var documentFindMock = {
append : angular.noop,
load: function (callback) {
callback();
},
size: function() {
return 1;
}
};
var novaAPIMock = {
getKeypairs: function() {
return {
then: function(callback) {
callback(existingKeypairs);
}
};
},
getCreateKeypairUrl: function() {
return "/some/given/path";
}
};
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module(function ($provide) {
$provide.value('horizon.app.core.openstack-service-api.nova', novaAPIMock);
$provide.value('$document', documentMock);
}));
beforeEach(inject(function (_$injector_, _$rootScope_, _$timeout_) {
service = _$injector_.get(
'horizon.app.core.openstack-service-api.keypair-download-service'
);
$scope = _$rootScope_.$new();
$timeout = _$timeout_;
}));
it('adds the download key pair endpoint as a resource to the DOM', function() {
spyOn(documentFindMock, 'append');
spyOn(documentFindMock, 'load').and.returnValue({});
service.createAndDownloadKeypair("newKeypair");
var passedObj = documentFindMock.append.calls.argsFor(0)[0];
expect(passedObj).toBeDefined();
expect(passedObj.attr('id')).toBe("newKeypair");
});
it('encodes the URI component given slashes, etc.', function() {
spyOn(documentFindMock, 'append');
spyOn(documentFindMock, 'load').and.returnValue({});
service.createAndDownloadKeypair("new/Keypair");
var passedObj = documentFindMock.append.calls.argsFor(0)[0];
expect(passedObj).toBeDefined();
expect(passedObj.attr('id')).toBe("new/Keypair");
});
it('creates a div with download-iframes if not present', function() {
spyOn(documentFindMock, 'append');
spyOn(documentFindMock, 'load').and.returnValue({});
spyOn(documentFindMock, 'size').and.returnValue(0);
service.createAndDownloadKeypair("new/Keypair");
expect(documentFindMock.append.calls.count()).toBe(2);
expect(documentFindMock.append.calls.allArgs().some(function(x) {
return x[0].attr('class') === 'download-iframes';
})).toBe(true);
});
it('checks that the keypair was added and returns a success promise result', function() {
existingKeypairs = {
data: {
items:[{
keypair: {
name: "newKeypair"
}
}]
}
};
var promiseSuccessful, keypair;
service.createAndDownloadKeypair("newKeypair").then(
function success(createdKeypair) {
promiseSuccessful = true;
keypair = createdKeypair;
}
);
$timeout.flush();
$scope.$apply();
expect(promiseSuccessful).toEqual(true);
expect(keypair).toEqual({name: "newKeypair"});
});
it('checks that the keypair was not added and returns a error promise result', function() {
existingKeypairs = {
data: {
items:[]
}
};
var promiseErrored;
service.createAndDownloadKeypair("newKeypair").then(
function success() {},
function error() {
promiseErrored = true;
}
);
// checks 10 times after one second
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$timeout.flush();
$scope.$apply();
expect(promiseErrored).toEqual(true);
});
});
})();

View File

@ -23,20 +23,18 @@
novaAPI.$inject = [
'horizon.framework.util.http.service',
'horizon.framework.widgets.toast.service',
'$window'
'horizon.framework.widgets.toast.service'
];
/**
* @ngdoc service
* @param {Object} apiService
* @param {Object} toastService
* @param {Object} $window
* @name novaApi
* @description Provides access to Nova APIs.
* @returns {Object} The service
*/
function novaAPI(apiService, toastService, $window) {
function novaAPI(apiService, toastService) {
var service = {
getActionList: getActionList,
@ -71,8 +69,6 @@
getServices: getServices,
getInstanceMetadata: getInstanceMetadata,
editInstanceMetadata: editInstanceMetadata,
getCreateKeypairUrl: getCreateKeypairUrl,
getRegenerateKeypairUrl: getRegenerateKeypairUrl,
createFlavor: createFlavor,
updateFlavor: updateFlavor,
deleteFlavor: deleteFlavor,
@ -747,44 +743,6 @@
});
}
/**
* @ngdoc function
* @name getCreateKeypairUrl
*
* @description
* Returns a URL, respecting WEBROOT, that if called as a REST call
* would create and return a new key pair with the given name. This
* function is provided because to perform a download of the key pair,
* an iframe must be given a URL to use (which is further explained in
* the key pair download service).
*
* @param {string} keyPairName
* @returns {Object} The result of the API call
*/
function getCreateKeypairUrl(keyPairName) {
// NOTE: WEBROOT by definition must end with a slash (local_settings.py).
return $window.WEBROOT + "api/nova/keypairs/" +
encodeURIComponent(keyPairName) + "/";
}
/**
* @ngdoc function
* @name getRegenerateKeypairUrl
*
* @description
* Returns a URL, respecting WEBROOT, that if called as a REST call
* would regenereate an existing key pair with the given name and return
* the new key pair data. This function is provided because to perform
* a download of the key pair, an iframe must be given a URL to use
* (which is further explained in the key pair download service).
*
* @param {string} keyPairName
* @returns {Object} The result of the API call
*/
function getRegenerateKeypairUrl(keyPairName) {
return getCreateKeypairUrl(keyPairName) + "?regenerate=true";
}
/**
* @name createServerSnapshot
* @param {Object} newSnapshot - The new server snapshot

View File

@ -560,53 +560,4 @@
});
//// This is separated due to differences in what is being tested.
describe('Keypair functions', function() {
var service, $window;
beforeEach(module('horizon.app.core.openstack-service-api'));
beforeEach(module(function ($provide) {
$provide.value('horizon.framework.util.http.service', {});
$provide.value('horizon.framework.widgets.toast.service', {});
}));
beforeEach(inject(function (_$injector_, _$rootScope_, _$timeout_, _$window_) {
service = _$injector_.get(
'horizon.app.core.openstack-service-api.nova'
);
$window = _$window_;
$window.WEBROOT = '/';
}));
afterEach(inject(function (_$window_) {
$window = _$window_;
$window.WEBROOT = '/';
}));
it('returns a link to download the private key for an existing keypair', function() {
var link = service.getCreateKeypairUrl("keypairName");
expect(link).toEqual('/api/nova/keypairs/keypairName/');
});
it('returns a WEBROOT link to download the private key for an existing keypair', function() {
$window.WEBROOT = '/myroot/';
var link = service.getCreateKeypairUrl("keypairName");
expect(link).toEqual('/myroot/api/nova/keypairs/keypairName/');
});
it('returns a link to redownload the private key for an existing keypair', function() {
var link = service.getRegenerateKeypairUrl("keypairName");
expect(link).toEqual('/api/nova/keypairs/keypairName/?regenerate=true');
});
it('returns a WEBROOT link to redownload the private key for an existing keypair', function() {
$window.WEBROOT = '/myroot/';
var link = service.getRegenerateKeypairUrl("keypairName");
expect(link).toEqual('/myroot/api/nova/keypairs/keypairName/?regenerate=true');
});
});
})();

View File

@ -22,8 +22,6 @@ from openstack_dashboard.api.rest import nova
from openstack_dashboard.test import helpers as test
from openstack_dashboard.usage import quotas
from novaclient import exceptions
class NovaRestTestCase(test.TestCase):
#
@ -216,67 +214,6 @@ class NovaRestTestCase(test.TestCase):
self.assertEqual('/api/nova/keypairs/Ni%21', response['location'])
nc.keypair_import.assert_called_once_with(request, 'Ni!', 'hi')
def test_keypair_create_and_download(self):
self._test_keypair_create_and_download(False)
def test_keypair_recreate_and_download(self):
self._test_keypair_create_and_download(True)
@mock.patch.object(nova.api, 'nova')
def _test_keypair_create_and_download(self, recreate_keypair, nc):
params = {}
if recreate_keypair:
params = {'regenerate': 'true'}
request = self.mock_rest_request(GET=params)
keypair_create_response = mock.Mock()
keypair_create_response.private_key = "private key content"
nc.keypair_create.return_value = keypair_create_response
with mock.patch.object(settings, 'DEBUG', True):
response = nova.Keypair().get(request, "Ni!")
if recreate_keypair:
nc.keypair_delete.assert_called_once_with(request, 'Ni!')
else:
nc.keypair_delete.assert_not_called()
nc.keypair_create.assert_called_once_with(request, 'Ni!')
self.assertStatusCode(response, 200)
self.assertEqual(
'attachment; filename=ni.pem',
response['Content-Disposition'])
self.assertEqual(
"private key content",
response.content.decode('utf-8'))
self.assertEqual('19', response['Content-Length'])
@mock.patch.object(nova.api, 'nova')
def test_keypair_fail_to_create_because_already_exists(self, nc):
request = self.mock_rest_request(GET={})
conflict_exception = exceptions.Conflict(409, 'keypair exists!')
nc.keypair_create.side_effect = conflict_exception
with mock.patch.object(settings, 'DEBUG', True):
response = nova.Keypair().get(request, "Ni!")
self.assertEqual(409, response.status_code)
@mock.patch.object(nova.api, 'nova')
def test_keypair_fail_to_create(self, nc):
request = self.mock_rest_request(GET={})
surprise_exception = exceptions.ClientException(501, 'Boom!')
nc.keypair_create.side_effect = surprise_exception
with mock.patch.object(settings, 'DEBUG', True):
response = nova.Keypair().get(request, "Ni!")
self.assertEqual(500, response.status_code)
#
# Availability Zones
#