Merge pull request #2757 from jplana/add-pending-certificate-upload

Allow uploading a signed cert for a pending certificate.
This commit is contained in:
Curtis 2019-04-24 09:53:50 -07:00 committed by GitHub
commit ccd0bde0bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 326 additions and 4 deletions

View File

@ -1,5 +1,7 @@
from marshmallow import fields, post_load
from marshmallow import fields, validates_schema, post_load
from marshmallow.exceptions import ValidationError
from lemur.common import utils, validators
from lemur.authorities.schemas import AuthorityNestedOutputSchema
from lemur.certificates.schemas import CertificateNestedOutputSchema
from lemur.common.schema import LemurInputSchema, LemurOutputSchema
@ -98,6 +100,31 @@ class PendingCertificateCancelSchema(LemurInputSchema):
note = fields.String()
class PendingCertificateUploadInputSchema(LemurInputSchema):
external_id = fields.String(missing=None, allow_none=True)
body = fields.String(required=True)
chain = fields.String(missing=None, allow_none=True)
@validates_schema
def validate_cert_chain(self, data):
cert = None
if data.get('body'):
try:
cert = utils.parse_certificate(data['body'])
except ValueError:
raise ValidationError("Public certificate presented is not valid.", field_names=['body'])
if data.get('chain'):
try:
chain = utils.parse_cert_chain(data['chain'])
except ValueError:
raise ValidationError("Invalid certificate in certificate chain.", field_names=['chain'])
# Throws ValidationError
validators.verify_cert_chain([cert] + chain)
pending_certificate_output_schema = PendingCertificateOutputSchema()
pending_certificate_edit_input_schema = PendingCertificateEditInputSchema()
pending_certificate_cancel_schema = PendingCertificateCancelSchema()
pending_certificate_upload_input_schema = PendingCertificateUploadInputSchema()

View File

@ -8,9 +8,11 @@ from sqlalchemy import or_, cast, Integer
from lemur import database
from lemur.authorities.models import Authority
from lemur.authorities import service as authorities_service
from lemur.certificates import service as certificate_service
from lemur.certificates.schemas import CertificateUploadInputSchema
from lemur.common.utils import truthiness
from lemur.common.utils import truthiness, parse_cert_chain, parse_certificate
from lemur.common import validators
from lemur.destinations.models import Destination
from lemur.domains.models import Domain
from lemur.notifications.models import Notification
@ -230,3 +232,40 @@ def render(args):
# Only show unresolved certificates in the UI
query = query.filter(PendingCertificate.resolved.is_(False))
return database.sort_and_page(query, PendingCertificate, args)
def upload(pending_certificate_id, **kwargs):
"""
Uploads a (signed) pending certificate. The allowed fields are validated by
PendingCertificateUploadInputSchema. The certificate is also validated to be
signed by the correct authoritity.
"""
pending_cert = get(pending_certificate_id)
partial_cert = kwargs
uploaded_chain = partial_cert['chain']
authority = authorities_service.get(pending_cert.authority.id)
# Construct the chain for cert validation
if uploaded_chain:
chain = uploaded_chain + '\n' + authority.authority_certificate.body
else:
chain = authority.authority_certificate.body
parsed_chain = parse_cert_chain(chain)
# Check that the certificate is actually signed by the CA to avoid incorrect cert pasting
validators.verify_cert_chain([parse_certificate(partial_cert['body'])] + parsed_chain)
final_cert = create_certificate(pending_cert, partial_cert, pending_cert.user)
update(
pending_cert.id,
resolved=True
)
pending_cert_final_result = update(
pending_cert.id,
resolved_cert_id=final_cert.id
)
return pending_cert_final_result

View File

@ -20,6 +20,7 @@ from lemur.pending_certificates.schemas import (
pending_certificate_output_schema,
pending_certificate_edit_input_schema,
pending_certificate_cancel_schema,
pending_certificate_upload_input_schema,
)
mod = Blueprint('pending_certificates', __name__)
@ -419,6 +420,101 @@ class PendingCertificatePrivateKey(AuthenticatedResource):
return response
class PendingCertificatesUpload(AuthenticatedResource):
""" Defines the 'pending_certificates' upload endpoint """
def __init__(self):
self.reqparse = reqparse.RequestParser()
super(PendingCertificatesUpload, self).__init__()
@validate_schema(pending_certificate_upload_input_schema, pending_certificate_output_schema)
def post(self, pending_certificate_id, data=None):
"""
.. http:post:: /pending_certificates/1/upload
Upload the body for a (signed) pending_certificate
**Example request**:
.. sourcecode:: http
POST /certificates/1/upload HTTP/1.1
Host: example.com
Accept: application/json, text/javascript
{
"body": "-----BEGIN CERTIFICATE-----...",
"chain": "-----BEGIN CERTIFICATE-----...",
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: text/javascript
{
"status": null,
"cn": "*.test.example.net",
"chain": "",
"authority": {
"active": true,
"owner": "secure@example.com",
"id": 1,
"description": "verisign test authority",
"name": "verisign"
},
"owner": "joe@example.com",
"serial": "82311058732025924142789179368889309156",
"id": 2288,
"issuer": "SymantecCorporation",
"dateCreated": "2016-06-03T06:09:42.133769+00:00",
"notBefore": "2016-06-03T00:00:00+00:00",
"notAfter": "2018-01-12T23:59:59+00:00",
"destinations": [],
"bits": 2048,
"body": "-----BEGIN CERTIFICATE-----...",
"description": null,
"deleted": null,
"notifications": [{
"id": 1
}],
"signingAlgorithm": "sha256",
"user": {
"username": "jane",
"active": true,
"email": "jane@example.com",
"id": 2
},
"active": true,
"domains": [{
"sensitive": false,
"id": 1090,
"name": "*.test.example.net"
}],
"replaces": [],
"rotation": true,
"rotationPolicy": {"name": "default"},
"name": "WILDCARD.test.example.net-SymantecCorporation-20160603-20180112",
"roles": [{
"id": 464,
"description": "This is a google group based role created by Lemur",
"name": "joe@example.com"
}],
"san": null
}
:reqheader Authorization: OAuth token to authenticate
:statuscode 403: unauthenticated
:statuscode 200: no error
"""
return service.upload(pending_certificate_id, **data)
api.add_resource(PendingCertificatesList, '/pending_certificates', endpoint='pending_certificates')
api.add_resource(PendingCertificates, '/pending_certificates/<int:pending_certificate_id>', endpoint='pending_certificate')
api.add_resource(PendingCertificatesUpload, '/pending_certificates/<int:pending_certificate_id>/upload', endpoint='pendingCertificateUpload')
api.add_resource(PendingCertificatePrivateKey, '/pending_certificates/<int:pending_certificate_id>/key', endpoint='privateKeyPendingCertificates')

View File

@ -0,0 +1,34 @@
'use strict';
angular.module('lemur')
.controller('PendingCertificateUploadController', function ($scope, $uibModalInstance, PendingCertificateApi, PendingCertificateService, toaster, uploadId) {
PendingCertificateApi.get(uploadId).then(function (pendingCertificate) {
$scope.pendingCertificate = pendingCertificate;
});
$scope.upload = PendingCertificateService.upload;
$scope.save = function (pendingCertificate) {
PendingCertificateService.upload(pendingCertificate).then(
function () {
toaster.pop({
type: 'success',
title: pendingCertificate.name,
body: 'Successfully uploaded!'
});
$uibModalInstance.close();
},
function (response) {
toaster.pop({
type: 'error',
title: pendingCertificate.name,
body: 'Failed to upload ' + response.data.message,
timeout: 100000
});
});
};
$scope.cancel = function () {
$uibModalInstance.dismiss('cancel');
};
});

View File

@ -0,0 +1,41 @@
<div class="modal-header">
<button type="button" class="close" ng-click="cancel()" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h3 class="modal-title">Import certificate <span class="text-muted"><small>{{ pendingCertificate.name }}</small></span></h3>
</div>
<div class="modal-body">
<form name="uploadForm" class="form-horizontal" role="form" novalidate>
<div class="form-group"
ng-class="{'has-error': uploadForm.publicCert.$invalid, 'has-success': !uploadForm.publicCert.$invalid&&uploadForm.publicCert.$dirty}">
<label class="control-label col-sm-2">
Public Certificate
</label>
<div class="col-sm-10">
<textarea name="publicCert" ng-model="pendingCertificate.body" placeholder="PEM encoded string..."
class="form-control" ng-pattern="/^-----BEGIN CERTIFICATE-----/" required></textarea>
<p ng-show="uploadForm.publicCert.$invalid && !uploadForm.publicCert.$pristine" class="help-block">Enter
a valid certificate.</p>
</div>
</div>
<div class="form-group"
ng-class="{'has-error': uploadForm.owner.$invalid&&uploadform.intermediateCert.$dirty, 'has-success': !uploadForm.intermediateCert.$invalid&&uploadForm.intermediateCert.$dirty}">
<label class="control-label col-sm-2">
Intermediate Certificate
</label>
<div class="col-sm-10">
<textarea name="intermediateCert" ng-model="pendingCertificate.chain"
placeholder="PEM encoded string..." class="form-control"
ng-pattern="/^-----BEGIN CERTIFICATE-----/"></textarea>
<p ng-show="uploadForm.intermediateCert.$invalid && !uploadForm.intemediateCert.$pristine"
class="help-block">Enter a valid certificate.</p>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="submit" ng-click="save(pendingCertificate)" ng-disabled="uploadForm.$invalid" class="btn btn-success">
Import
</button>
<button ng-click="cancel()" class="btn btn-danger">Cancel</button>
</div>
</div>

View File

@ -245,5 +245,9 @@ angular.module('lemur')
return pending_certificate.customOperation('remove', null, {}, {'Content-Type': 'application/json'}, options);
};
PendingCertificateService.upload = function (pending_certificate) {
return pending_certificate.customPOST({'body': pending_certificate.body, 'chain': pending_certificate.chain}, 'upload');
};
return PendingCertificateService;
});

View File

@ -99,4 +99,23 @@ angular.module('lemur')
$scope.pendingCertificateTable.reload();
});
};
$scope.upload = function (pendingCertificateId) {
var uibModalInstance = $uibModal.open({
animation: true,
controller: 'PendingCertificateUploadController',
templateUrl: '/angular/pending_certificates/pending_certificate/upload.tpl.html',
size: 'lg',
backdrop: 'static',
resolve: {
uploadId: function () {
return pendingCertificateId;
}
}
});
uibModalInstance.result.then(function () {
$scope.pendingCertificateTable.reload();
});
};
});

View File

@ -51,6 +51,7 @@
<ul class="dropdown-menu">
<li><a href ng-click="edit(pendingCertificate.id)">Edit</a></li>
<li><a href ng-click="cancel(pendingCertificate.id)">Cancel</a></li>
<li><a href ng-click="upload(pendingCertificate.id)">Upload</a></li>
</ul>
</div>
</div>

View File

@ -13,12 +13,12 @@ from lemur import create_app
from lemur.common.utils import parse_private_key
from lemur.database import db as _db
from lemur.auth.service import create_token
from lemur.tests.vectors import SAN_CERT_KEY, INTERMEDIATE_KEY
from lemur.tests.vectors import SAN_CERT_KEY, INTERMEDIATE_KEY, ROOTCA_CERT_STR, ROOTCA_KEY
from .factories import ApiKeyFactory, AuthorityFactory, NotificationFactory, DestinationFactory, \
CertificateFactory, UserFactory, RoleFactory, SourceFactory, EndpointFactory, \
RotationPolicyFactory, PendingCertificateFactory, AsyncAuthorityFactory, InvalidCertificateFactory, \
CryptoAuthorityFactory
CryptoAuthorityFactory, CACertificateFactory
def pytest_runtest_setup(item):
@ -172,6 +172,25 @@ def pending_certificate(session):
return p
@pytest.fixture
def pending_certificate_from_full_chain_ca(session):
u = UserFactory()
a = AuthorityFactory()
p = PendingCertificateFactory(user=u, authority=a)
session.commit()
return p
@pytest.fixture
def pending_certificate_from_partial_chain_ca(session):
u = UserFactory()
c = CACertificateFactory(body=ROOTCA_CERT_STR, private_key=ROOTCA_KEY, chain=None)
a = AuthorityFactory(authority_certificate=c)
p = PendingCertificateFactory(user=u, authority=a)
session.commit()
return p
@pytest.fixture
def invalid_certificate(session):
u = UserFactory()

View File

@ -2,6 +2,7 @@ import json
import pytest
from marshmallow import ValidationError
from lemur.pending_certificates.views import * # noqa
from .vectors import CSR_STR, INTERMEDIATE_CERT_STR, VALID_ADMIN_API_TOKEN, VALID_ADMIN_HEADER_TOKEN, \
VALID_USER_HEADER_TOKEN, WILDCARD_CERT_STR
@ -50,3 +51,44 @@ def test_pending_cancel(client, pending_certificate, token, status):
assert client.delete(api.url_for(PendingCertificates, pending_certificate_id=pending_certificate.id),
data=json.dumps({'note': "unit test", 'send_email': False}),
headers=token).status_code == status
def test_pending_upload(pending_certificate_from_full_chain_ca):
from lemur.pending_certificates.service import upload
from lemur.certificates.service import get
cert = {'body': WILDCARD_CERT_STR,
'chain': None,
'external_id': None
}
pending_cert = upload(pending_certificate_from_full_chain_ca.id, **cert)
assert pending_cert.resolved
assert get(pending_cert.resolved_cert_id)
def test_pending_upload_with_chain(pending_certificate_from_partial_chain_ca):
from lemur.pending_certificates.service import upload
from lemur.certificates.service import get
cert = {'body': WILDCARD_CERT_STR,
'chain': INTERMEDIATE_CERT_STR,
'external_id': None
}
pending_cert = upload(pending_certificate_from_partial_chain_ca.id, **cert)
assert pending_cert.resolved
assert get(pending_cert.resolved_cert_id)
def test_invalid_pending_upload_with_chain(pending_certificate_from_partial_chain_ca):
from lemur.pending_certificates.service import upload
cert = {'body': WILDCARD_CERT_STR,
'chain': None,
'external_id': None
}
with pytest.raises(ValidationError) as err:
upload(pending_certificate_from_partial_chain_ca.id, **cert)
assert str(err.value).startswith(
'Incorrect chain certificate(s) provided: \'*.wild.example.org\' is not signed by \'LemurTrust Unittests Root CA 2018')