diff --git a/lemur/pending_certificates/schemas.py b/lemur/pending_certificates/schemas.py index fbc94f4e..3dd70b16 100644 --- a/lemur/pending_certificates/schemas.py +++ b/lemur/pending_certificates/schemas.py @@ -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() diff --git a/lemur/pending_certificates/service.py b/lemur/pending_certificates/service.py index 405b2c4b..56b6e097 100644 --- a/lemur/pending_certificates/service.py +++ b/lemur/pending_certificates/service.py @@ -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 diff --git a/lemur/pending_certificates/views.py b/lemur/pending_certificates/views.py index 13598040..935f00c1 100644 --- a/lemur/pending_certificates/views.py +++ b/lemur/pending_certificates/views.py @@ -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/', endpoint='pending_certificate') +api.add_resource(PendingCertificatesUpload, '/pending_certificates//upload', endpoint='pendingCertificateUpload') api.add_resource(PendingCertificatePrivateKey, '/pending_certificates//key', endpoint='privateKeyPendingCertificates') diff --git a/lemur/static/app/angular/pending_certificates/pending_certificate/upload.js b/lemur/static/app/angular/pending_certificates/pending_certificate/upload.js new file mode 100644 index 00000000..10e92e0f --- /dev/null +++ b/lemur/static/app/angular/pending_certificates/pending_certificate/upload.js @@ -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'); + }; + + }); diff --git a/lemur/static/app/angular/pending_certificates/pending_certificate/upload.tpl.html b/lemur/static/app/angular/pending_certificates/pending_certificate/upload.tpl.html new file mode 100644 index 00000000..ba3c6a4c --- /dev/null +++ b/lemur/static/app/angular/pending_certificates/pending_certificate/upload.tpl.html @@ -0,0 +1,41 @@ + + + + + diff --git a/lemur/static/app/angular/pending_certificates/services.js b/lemur/static/app/angular/pending_certificates/services.js index 32b335ac..4e1b23e4 100644 --- a/lemur/static/app/angular/pending_certificates/services.js +++ b/lemur/static/app/angular/pending_certificates/services.js @@ -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; }); diff --git a/lemur/static/app/angular/pending_certificates/view/view.js b/lemur/static/app/angular/pending_certificates/view/view.js index 9ada8845..c46d6c74 100644 --- a/lemur/static/app/angular/pending_certificates/view/view.js +++ b/lemur/static/app/angular/pending_certificates/view/view.js @@ -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(); + }); + }; + }); diff --git a/lemur/static/app/angular/pending_certificates/view/view.tpl.html b/lemur/static/app/angular/pending_certificates/view/view.tpl.html index 1f028793..d9c1b461 100644 --- a/lemur/static/app/angular/pending_certificates/view/view.tpl.html +++ b/lemur/static/app/angular/pending_certificates/view/view.tpl.html @@ -51,6 +51,7 @@ diff --git a/lemur/tests/conftest.py b/lemur/tests/conftest.py index e65b9440..809b9a6a 100644 --- a/lemur/tests/conftest.py +++ b/lemur/tests/conftest.py @@ -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() diff --git a/lemur/tests/test_pending_certificates.py b/lemur/tests/test_pending_certificates.py index 7accf7d9..043002d3 100644 --- a/lemur/tests/test_pending_certificates.py +++ b/lemur/tests/test_pending_certificates.py @@ -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')