add more pki features, bug fixes, try not to use jquery

This commit is contained in:
TuxCoder 2020-05-25 20:23:27 +02:00
parent 38932aef44
commit 6c388c8129
18 changed files with 675 additions and 1068 deletions

View file

@ -16,7 +16,7 @@ LDAP_BIND_PW = '123456'
PKI_PATH = f'{DATA_FOLDER}/pki'
DOMAIN = 'example.com'
SERVER_NAME = f'account.{ DOMAIN }:9090'
#SERVER_NAME = f'account.{ DOMAIN }:9090'
HYDRA_REQUEST_TIMEOUT_SECONDS = 3
HYDRA_ADMIN_URL = 'http://127.0.0.1:4445'

View file

@ -0,0 +1,45 @@
"use strict";
const $ = document.querySelector.bind(document)
const _ = document.getElementById
export class ConfirmDialog {
constructor(message) {
this._div = document.getElementById('confirm-dialog-template').content.querySelector('div').cloneNode(true);
this._div.querySelector('.modal-body').innerHTML = message;
}
show() {
var self = this;
this._promise = new Promise((resolve, reject) => {
self._resolve = resolve;
self._reject = reject;
});
this._div.querySelectorAll('.close').forEach(function (o){
o.onclick=self.cancel.bind(self);
});
this._div.querySelector('.process').onclick = () => {
self._close();
self._resolve();
};
$('.messages-box').appendChild(this._div);
return this._promise
}
cancel() {
this._close()
this._reject('canceled by user');
}
_close() {
$('.messages-box').removeChild(this._div);
}
}

View file

@ -1,14 +1,20 @@
import 'jquery';
import 'bootstrap';
import 'jquery-form'
import {ConfirmDialog} from './confirm-modal.js';
window.$ =window.jQuery = require('jquery');
jQuery = window.$ = window.jQuery = require('jquery');
var forge = require('node-forge');
var QRCode = require("qrcode-svg");
var pki = require('node-forge/lib/pki');
var asn1 = require('node-forge/lib/asn1');
var pkcs12 = require('node-forge/lib/pkcs12');
var util = require('node-forge/lib/util');
import SimpleFormSubmit from "simple-form-submit";
const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);
/*
Convert an ArrayBuffer into a string
@ -32,12 +38,13 @@ function randBase32() {
return result;
}
window.$(document).ready(function () {
$('#sidebarCollapse').on('click', function () {
$('nav.sidebar').toggleClass('d-none');
});
});
window.ConfirmDialog = ConfirmDialog;
window.$(document).ready(function () {
$('#sidebarCollapse').onclick = function () {
$('nav.sidebar').classList.toggle('d-none');
};
});
window.totp = {
init_list: function(){
@ -46,18 +53,18 @@ window.totp = {
//create new TOTP secret, create qrcode and ask for token.
var form = $('form');
var secret = randBase32();
var input_secret = form.find('#secret')
if(input_secret.val() == '') {
input_secret.val(secret);
var input_secret = form.querySelector('#secret')
if(input_secret.value == '') {
input_secret.value = secret;
}
form.find('#name').on('change',window.totp.generate_qrcode);
form.querySelector('#name').on('change',window.totp.generate_qrcode);
window.totp.generate_qrcode();
},
generate_qrcode: function(){
var form = $('form');
var secret = form.find('#secret').val();
var name = form.find('#name').val();
var secret = form.querySelector('#secret').value;
var name = form.querySelector('#name').value;
var issuer = 'Lenticular%20Cloud';
var svg_container = $('#svg-container')
var svg = new QRCode(`otpauth://totp/${issuer}:${name}?secret=${secret}&issuer=${issuer}`).svg();
@ -84,48 +91,52 @@ window.client_cert = {
},
generate_private_key: function() {
var form = $('form#gen-key-form');
var key_size = form.find('#key-size').val();
var valid_time = form.find('input[name=valid_time]').val();
$('button#generate-key')[0].style['display'] = 'none';
var key_size = form.querySelector('#key-size').value;
var valid_time = form.querySelector('input[name=valid_time]').value;
$('button#generate-key').style['display'] = 'none';
pki.rsa.generateKeyPair({bits: key_size, workers: 2}, function(err, keypair) {
console.log(keypair);
form.data('keypair', keypair);
//returns the exported key to a hidden form
var form_sign_key = $('#gen-key-sign form');
form_sign_key.find('textarea[name=publickey]').val(pki.publicKeyToPem(keypair.publicKey));
form_sign_key.find('input[name=valid_time]').val(valid_time);
form_sign_key.querySelector('textarea[name=publickey]').value = pki.publicKeyToPem(keypair.publicKey);
form_sign_key.querySelector('input[name=valid_time]').value = valid_time;
form_sign_key.ajaxForm({
success: function(response) {
// get certificate
var data = response['data'];
var certs = [
pki.certificateFromPem(data.cert),
pki.certificateFromPem(data.ca_cert)
];
var password = form.find('#cert-password').val();
var keypair = form.data('keypair');
var p12Asn1;
if (password == '') {
p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, null, {algorithm: '3des'}); // without password
} else {
p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, password, {algorithm: '3des'}); // without password
}
var p12Der = asn1.toDer(p12Asn1).getBytes();
var p12b64 = util.encode64(p12Der);
SimpleFormSubmit.submitForm(form_sign_key.action, form_sign_key)
.then(response => {
response.json().then( response => {
// get certificate
var data = response.data;
var certs = [
pki.certificateFromPem(data.cert),
pki.certificateFromPem(data.ca_cert)
];
var password = form.querySelector('#cert-password').value;
var p12Asn1;
if (password == '') {
p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, null, {algorithm: '3des'}); // without password
} else {
p12Asn1 = pkcs12.toPkcs12Asn1(keypair.privateKey, certs, password, {algorithm: '3des'}); // without password
}
var p12Der = asn1.toDer(p12Asn1).getBytes();
var p12b64 = util.encode64(p12Der);
var button = $('#save-button')[0];
button.href= "data:application/x-pkcs12;base64," + p12b64
button.style['display'] ='block';
}
});
// submit hidden form
form_sign_key.submit();
var button = $('#save-button');
button.href= "data:application/x-pkcs12;base64," + p12b64
button.style['display'] ='block';
});
});
});
},
revoke_certificate: function(href, id){
var dialog = new ConfirmDialog(`Are you sure to revoke the certificate with the fingerprint ${id}?`);
dialog.show().then(()=>{
fetch(href, {
method: 'DELETE'
});
});
return false;
}
};

View file

@ -3,3 +3,13 @@
@import "~@fortawesome/fontawesome-free/css/all.css";
//@import "~datatables.net-bs4/css/dataTables.bootstrap4.css";
.messages-box {
position: fixed;
width: 100%;
margin-top: 30px;
z-index: 500;
}

View file

@ -54,11 +54,12 @@ def init_app(name=None):
hydra_client = hydra.ApiClient(hydra_config)
app.hydra_api = hydra.AdminApi(hydra_client)
from .views import auth_views, frontend_views, init_login_manager, api_views
from .views import auth_views, frontend_views, init_login_manager, api_views, pki_views
init_login_manager(app)
app.register_blueprint(auth_views)
app.register_blueprint(frontend_views)
app.register_blueprint(api_views)
app.register_blueprint(pki_views)
@app.before_request
def befor_request():

View file

@ -9,6 +9,7 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from collections.abc import MutableSequence
from datetime import datetime
from dateutil import tz
import pyotp
import json
@ -137,10 +138,13 @@ class Service(object):
class Certificate(object):
def __init__(self, cn, ca_name, cert_data):
def __init__(self, cn, ca_name: str, cert_data, revoked=False):
self._cn = cn
self._ca_name = ca_name
self._cert_data = cert_data
self._revoked = revoked
self._cert_data.not_valid_after.replace(tzinfo=tz.tzutc())
self._cert_data.not_valid_before.replace(tzinfo=tz.tzutc())
@property
def cn(self):
@ -151,19 +155,35 @@ class Certificate(object):
return self._ca_name
@property
def not_valid_before(self):
return self._cert_data.not_valid_before
def not_valid_before(self) -> datetime:
return self._cert_data.not_valid_before.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()).replace(tzinfo=None)
@property
def not_valid_after(self):
return self._cert_data.not_valid_after
def not_valid_after(self) -> datetime:
return self._cert_data.not_valid_after.replace(tzinfo=tz.tzutc()).astimezone(tz.tzlocal()).replace(tzinfo=None)
def fingerprint(self, algorithm=hashes.SHA256()):
@property
def serial_number(self) -> int:
return self._cert_data.serial_number
@property
def serial_number_hex(self) -> str:
return f'{self._cert_data.serial_number:X}'
def fingerprint(self, algorithm=hashes.SHA256()) -> bytes:
return self._cert_data.fingerprint(algorithm)
def pem(self):
@property
def is_valid(self) -> bool:
return self.not_valid_after > datetime.now() and not self._revoked
def pem(self) -> str:
return self._cert_data.public_bytes(encoding=serialization.Encoding.PEM).decode()
@property
def raw(self):
return self._cert_data
def __str__(self):
return f'Certificate(cn={self._cn}, ca_name={self._ca_name}, not_valid_before={self.not_valid_before}, not_valid_after={self.not_valid_after})'

View file

@ -11,6 +11,8 @@ import os
import string
import re
import datetime
from dateutil import tz
from operator import attrgetter
import logging
from .model import Service, User, Certificate
@ -52,6 +54,7 @@ class Pki(object):
ca_private_key = self._ensure_private_key(ca_name)
ca_cert = self._ensure_ca_cert(ca_name, ca_private_key)
self.update_crl(ca_name)
pki_path = self._pki_path / ca_name
if not pki_path.exists():
@ -62,15 +65,39 @@ class Pki(object):
def get_client_certs(self, user: User, service: Service):
pki_path = self._pki_path / service.name
certs = []
crl = self._load_ca_crl(service.name)
for cert_path in pki_path.glob(f'{user.username}*.crt.pem'):
print(cert_path)
with cert_path.open('rb') as cert_fd:
cert_data = x509.load_pem_x509_certificate(
cert_fd.read(),
backend=default_backend())
cert = Certificate(user.username, service.name, cert_data)
revoked = crl.get_revoked_certificate_by_serial_number(
cert_data.serial_number) is not None
cert = Certificate(
user.username, service.name, cert_data, revoked)
certs.append(cert)
return certs
return sorted(certs, key=attrgetter('not_valid_before'), reverse=True)
def get_crl(self, service: Service):
return self._load_ca_crl(service.name)
def get_client_cert(self, user: User, service: Service, serial_number: str) -> Certificate:
pki_path = self._pki_path / service.name
cert_path = pki_path / f'{user.username}-{serial_number}.crt.pem'
crl = self._load_ca_crl(service.name)
with cert_path.open('rb') as cert_fd:
cert_data = x509.load_pem_x509_certificate(
cert_fd.read(),
backend=default_backend())
revoked = crl.get_revoked_certificate_by_serial_number(
cert_data.serial_number) is None
cert = Certificate(user.username, service.name, cert_data, revoked)
return cert
def revoke_certificate(self, cert: Certificate):
ca_private_key = self._ensure_private_key(cert.ca_name)
self._revoke_cert(ca_private_key, cert)
def signing_publickey(self, user: User, service: Service, publickey: str, valid_time=DAY*365):
_public_key = serialization.load_pem_public_key(
@ -81,7 +108,7 @@ class Pki(object):
username = str(user.username)
config = service.pki_config #TODO use this config
domain = self._domain
not_valid_before = datetime.datetime.now()
not_valid_before = datetime.datetime.utcnow()
ca_public_key = ca_private_key.public_key()
end_entity_cert_builder = x509.CertificateBuilder().\
@ -124,6 +151,15 @@ class Pki(object):
add_extension(
x509.SubjectKeyIdentifier.from_public_key(_public_key),
critical=False).\
add_extension(
x509.CRLDistributionPoints([
x509.DistributionPoint(
full_name=[x509.UniformResourceIdentifier(f'http://crl.{self._domain}/{ca_name}.crl')],
relative_name=None,
crl_issuer=None,
reasons=None)
]),
critical=False).\
add_extension(
x509.AuthorityInformationAccess([
x509.AccessDescription(
@ -142,9 +178,9 @@ class Pki(object):
backend=default_backend()
)
fingerprint =end_entity_cert.fingerprint(hashes.SHA256()).hex()
serial_number = f'{end_entity_cert.serial_number:X}'
end_entity_cert_filename = self._pki_path / ca_name / \
f'{safe_filename(username)}-{fingerprint}.crt.pem'
f'{safe_filename(username)}-{serial_number}.crt.pem'
# save cert
with end_entity_cert_filename.open("wb") as end_entity_cert_file:
end_entity_cert_file.write(
@ -224,3 +260,65 @@ class Pki(object):
ca_cert.public_bytes(encoding=serialization.Encoding.PEM))
assert isinstance(ca_cert, x509.Certificate)
return ca_cert
def _revoke_cert(self, ca_private_key, cert):
crl = self._load_ca_crl(cert.ca_name)
builder = self._builder_crl(cert.ca_name, crl)
revoked_certificate = x509.RevokedCertificateBuilder().serial_number(
cert.raw.serial_number
).revocation_date(
datetime.datetime.now(tz.tzlocal())
).build(default_backend())
builder = builder.add_revoked_certificate(revoked_certificate)
crl = self._crl_save(cert.ca_name, ca_private_key, builder)
foobar = crl.get_revoked_certificate_by_serial_number(cert.raw.serial_number)
def _get_path_crl(self, ca_name):
return self._pki_path / f'{ca_name}.crl.pem'
def _crl_save(self, ca_name, private_key, builder):
crl = builder.sign(
private_key=private_key,
algorithm=hashes.SHA256(),
backend=default_backend()
)
ca_crl_filename = self._get_path_crl(ca_name)
with open(ca_crl_filename, "wb") as ca_crl_file:
ca_crl_file.write(
crl.public_bytes(encoding=serialization.Encoding.PEM))
return crl
def _builder_crl(self, ca_name, old_crl=None):
builder = x509.CertificateRevocationListBuilder()
iname = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, f'Lenticular Cloud CA - {ca_name}'),
x509.NameAttribute(NameOID.ORGANIZATION_NAME,
'Lenticluar Cloud'),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME,
ca_name),
])
builder = builder.issuer_name(iname)
builder = builder.last_update(datetime.datetime.now(tz.tzlocal()))
builder = builder.next_update(datetime.datetime.now(tz.tzlocal()) + DAY)
if old_crl is not None:
for revoked_certificate in old_crl:
builder = builder.add_revoked_certificate(revoked_certificate)
return builder
def update_crl(self, ca_name):
ca_private_key = self._ensure_private_key(ca_name)
crl = self._load_ca_crl(ca_name)
builder = self._builder_crl(ca_name, crl)
return self._crl_save(ca_name, ca_private_key, builder)
def _load_ca_crl(self, ca_name):
ca_crl_filename = self._get_path_crl(ca_name)
if ca_crl_filename.exists():
with ca_crl_filename.open("rb") as ca_crl_file:
crl = x509.load_pem_x509_crl(
ca_crl_file.read(),
backend=default_backend())
return crl

View file

@ -3,3 +3,4 @@
from .auth import auth_views
from .frontend import frontend_views, init_login_manager
from .api import api_views
from .pki import pki_views

View file

@ -126,12 +126,26 @@ def client_cert():
return render_template('frontend/client_cert.html.j2', services=current_app.lenticular_services, client_certs=client_certs)
@frontend_views.route('/client_cert/<service_name>/<fingerprint>')
@frontend_views.route('/client_cert/<service_name>/<serial_number>')
@login_required
def get_client_cert(service_name, fingerprint):
def get_client_cert(service_name, serial_number):
service = current_app.lenticular_services[service_name]
current_app.pki.get_client_cert(current_user, service, fingerprint)
pass
cert = current_app.pki.get_client_cert(
current_user, service, serial_number)
return jsonify({
'data': {
'pem': cert.pem()}
})
@frontend_views.route('/client_cert/<service_name>/<serial_number>', methods=['DELETE'])
@login_required
def revoke_client_cert(service_name, serial_number):
service = current_app.lenticular_services[service_name]
cert = current_app.pki.get_client_cert(
current_user, service, serial_number)
current_app.pki.revoke_certificate(cert)
return jsonify({})
@frontend_views.route(
@ -160,7 +174,8 @@ def client_cert_new(service_name):
'errors': form.errors
})
return render_template('frontend/client_cert_new.html.j2',
return render_template(
'frontend/client_cert_new.html.j2',
service=service,
form=form)
@ -172,7 +187,7 @@ def totp():
return render_template('frontend/totp.html.j2', delete_form=delete_form)
@frontend_views.route('/totp/new', methods=['GET','POST'])
@frontend_views.route('/totp/new', methods=['GET', 'POST'])
@login_required
def totp_new():
form = TOTPForm()

View file

@ -0,0 +1,38 @@
import flask
from flask import Blueprint, redirect, request
from flask import current_app, session
from flask import jsonify
from flask.helpers import make_response
from flask.templating import render_template
from oic.oic.message import TokenErrorResponse, UserInfoErrorResponse, EndSessionRequest
from pyop.access_token import AccessToken, BearerTokenError
from pyop.exceptions import InvalidAuthenticationRequest, InvalidAccessToken, InvalidClientAuthentication, OAuthError, \
InvalidSubjectIdentifier, InvalidClientRegistrationRequest
from pyop.util import should_fragment_encode
from flask import Blueprint, render_template, request, url_for
from flask_login import login_required, login_user, logout_user
from werkzeug.utils import redirect
import logging
from urllib.parse import urlparse
from base64 import b64decode, b64encode
import ory_hydra_client as hydra
from requests_oauthlib.oauth2_session import OAuth2Session
import requests
from cryptography.hazmat.primitives import serialization
from ..model import User, SecurityUser
from ..model_db import User as DbUser
from ..form.login import LoginForm
from ..auth_providers import LdapAuthProvider
pki_views = Blueprint('pki', __name__, url_prefix='/')
@pki_views.route('/<service_name>.crl')
def crl(service_name: str):
service = current_app.lenticular_services[service_name]
crl = current_app.pki.get_crl(service)
return crl.public_bytes(encoding=serialization.Encoding.DER)

1304
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,9 @@
},
"author": "TuxCoder",
"license": "GPLv3",
"devDependencies": {
"dependencies": {
"@fortawesome/fontawesome-free": "^5.13.0",
"bootstrap": "^4.4.1",
"css-loader": "^3.5.3",
"file-loader": "^6.0.0",
"jquery": "^3.5.0",
@ -28,7 +29,7 @@
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"dependencies": {
"bootstrap": "^4.4.1"
"devDependencies": {
"simple-form-submit": "^1.0.22"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -15,6 +15,28 @@
{% block body %}
<div class='messages-box'>
</div>
<template id='confirm-dialog-template'>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"></h5>
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger close" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-ok process">Process</button>
</div>
</div>
</div>
</template>
<div class="modal fade" id="confirm-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
@ -57,9 +79,9 @@
<div class="sidebar-sticky active">
{#<a href="/"><img alt="logo" class="container-fluid" src="/static/images/dog_main_small.png"></a>#}
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.index') }}">{{ gettext('Account') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.logout') }}">{{ gettext('Logout') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.client_cert') }}">{{ gettext('Client Cert') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.totp') }}">{{ gettext('2FA - TOTP') }}</a></li>
<li class="nav-item"><a class="nav-link" href="{{ url_for('frontend.logout') }}">{{ gettext('Logout') }}</a></li>
</div>
</nav>
{% endif %}
@ -76,10 +98,12 @@
</main>
</div>
</div>
<div class="mt-5 row justify-content-center">
<footer>
<span class="text-muted">Render Time: {{ g.request_time() }}</span> | <span class="text-muted">{{ gettext('All right reserved. &copy;2019') }}</span>
</footer>
<div class='container'>
<div class="mt-5 row justify-content-center">
<footer>
<span class="text-muted">Render Time: {{ g.request_time() }}</span> | <span class="text-muted">{{ gettext('All right reserved. &copy;') + '2020' }}</span>
</footer>
</div>
</div>
{% endblock %}

View file

@ -21,18 +21,27 @@
<tr>
<th>not valid before</th>
<th>not valid after</th>
<th>fingerprint<th>
<th>serial_number<th>
<th> <th>
</tr>
</thead>
<tbody>
{% for cert in client_certs[service.name] %}
<tr>
<tr {{ 'class="table-warning"' if not cert.is_valid else ''}}>
<td>{{ cert.not_valid_before }}</td>
<td>{{ cert.not_valid_after }}</td>
<td>{{ cert.fingerprint().hex() }}</td>
<td>{{ cert.serial_number_hex }}</td>
<td>
<a title="{{ gettext('Download') }}" href="{{ url_for('.get_client_cert', service_name=service.name, serial_number=cert.serial_number_hex) }}"><i class="fas fa-file-download"></i></a>
&nbsp;
{% if cert.is_valid %}
<a title="{{ gettext('Revoke')}}" href="{{ url_for('.revoke_client_cert', service_name=service.name, serial_number=cert.serial_number_hex) }}" onclick="client_cert.revoke_certificate(this.href, '{{ cert.serial_number_hex }}'); return false;"><i class="fas fa-ban"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
<a class="btn btn-default" href="{{ url_for('frontend.client_cert_new', service_name=service.name) }}">
<a class="btn btn-primary" href="{{ url_for('frontend.client_cert_new', service_name=service.name) }}">
New Certificate
</a>
</div>
@ -45,7 +54,7 @@
{% block script_js %}
client_certs.init_list();
client_cert.init_list();
{% endblock %}

View file

@ -9,4 +9,4 @@ logging.basicConfig(level=logging.DEBUG)
if __name__ == "__main__":
#app.run(ssl_context=('https.crt', 'https.key'), debug=True, host='127.0.0.1')
app.run(debug=True, host='127.0.0.1')
app.run(debug=True, host='127.0.0.1', port=9090)