mirror of
https://github.com/laurivosandi/certidude
synced 2024-12-23 00:25:18 +00:00
api: Preliminary API call for listing client leases
This commit is contained in:
parent
3d36b2f92c
commit
887743cc0b
@ -1,4 +1,5 @@
|
||||
import re
|
||||
import datetime
|
||||
import falcon
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
@ -201,9 +202,68 @@ class SignedCertificateDetailResource(CertificateAuthorityBase):
|
||||
def on_delete(self, req, resp, ca, cn, user):
|
||||
ca.revoke(cn)
|
||||
|
||||
class LeaseResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@login_required
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp, ca, user):
|
||||
from ipaddress import ip_address
|
||||
|
||||
OIDS = {
|
||||
(2, 5, 4, 3) : 'CN', # common name
|
||||
(2, 5, 4, 6) : 'C', # country
|
||||
(2, 5, 4, 7) : 'L', # locality
|
||||
(2, 5, 4, 8) : 'ST', # stateOrProvince
|
||||
(2, 5, 4, 10) : 'O', # organization
|
||||
(2, 5, 4, 11) : 'OU', # organizationalUnit
|
||||
}
|
||||
|
||||
def parse_dn(data):
|
||||
chunks, remainder = decoder.decode(data)
|
||||
dn = ""
|
||||
if remainder:
|
||||
raise ValueError()
|
||||
# TODO: Check for duplicate entries?
|
||||
for chunk in chunks:
|
||||
for chunkette in chunk:
|
||||
key, value = chunkette
|
||||
dn += "/" + OIDS[key] + "=" + value
|
||||
return str(dn)
|
||||
|
||||
# BUGBUG
|
||||
SQL_LEASES = """
|
||||
SELECT
|
||||
acquired,
|
||||
released,
|
||||
address,
|
||||
identities.data as dn
|
||||
FROM
|
||||
addresses
|
||||
RIGHT JOIN
|
||||
identities
|
||||
ON
|
||||
identities.id = addresses.identity
|
||||
WHERE
|
||||
addresses.released <> 1
|
||||
"""
|
||||
cnx = ca.database.get_connection()
|
||||
cursor = cnx.cursor(dictionary=True)
|
||||
query = (SQL_LEASES)
|
||||
cursor.execute(query)
|
||||
|
||||
for row in cursor:
|
||||
row["acquired"] = datetime.utcfromtimestamp(row["acquired"])
|
||||
row["released"] = datetime.utcfromtimestamp(row["released"]) if row["released"] else None
|
||||
row["address"] = ip_address(bytes(row["address"]))
|
||||
row["dn"] = parse_dn(bytes(row["dn"]))
|
||||
yield row
|
||||
|
||||
|
||||
class SignedCertificateListResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
@validate_common_name
|
||||
def on_get(self, req, resp, ca):
|
||||
for j in authority.get_signed():
|
||||
@ -258,6 +318,7 @@ class RequestDetailResource(CertificateAuthorityBase):
|
||||
class RequestListResource(CertificateAuthorityBase):
|
||||
@serialize
|
||||
@pop_certificate_authority
|
||||
@authorize_admin
|
||||
def on_get(self, req, resp, ca):
|
||||
for j in ca.get_requests():
|
||||
yield omit(
|
||||
@ -458,6 +519,7 @@ def certidude_app():
|
||||
app.add_route("/api/ca/{ca}/signed/", SignedCertificateListResource(config))
|
||||
app.add_route("/api/ca/{ca}/request/{cn}/", RequestDetailResource(config))
|
||||
app.add_route("/api/ca/{ca}/request/", RequestListResource(config))
|
||||
app.add_route("/api/ca/{ca}/lease/", LeaseResource(config))
|
||||
app.add_route("/api/ca/{ca}/", IndexResource(config))
|
||||
app.add_route("/api/ca/", AuthorityListResource(config))
|
||||
return app
|
||||
|
@ -0,0 +1,72 @@
|
||||
|
||||
import click
|
||||
import falcon
|
||||
import kerberos
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
|
||||
# Vanilla Kerberos provides only username.
|
||||
# AD also embeds PAC (Privilege Attribute Certificate), which
|
||||
# is supposed to be sent via HTTP headers and it contains
|
||||
# the groups user is part of.
|
||||
# Even then we would have to manually look up the e-mail
|
||||
# address eg via LDAP, hence to keep things simple
|
||||
# we simply use Kerberos to authenticate.
|
||||
|
||||
FQDN = socket.getaddrinfo(socket.gethostname(), 0, flags=socket.AI_CANONNAME)[0][3]
|
||||
|
||||
if not os.getenv("KRB5_KTNAME"):
|
||||
click.echo("Kerberos keytab not specified, set environment variable 'KRB5_KTNAME'", err=True)
|
||||
exit(250)
|
||||
|
||||
try:
|
||||
principal = kerberos.getServerPrincipalDetails("HTTP", FQDN)
|
||||
except kerberos.KrbError as exc:
|
||||
click.echo("Failed to initialize Kerberos, reason: %s" % exc, err=True)
|
||||
exit(249)
|
||||
else:
|
||||
click.echo("Kerberos enabled, service principal is HTTP/%s" % FQDN)
|
||||
|
||||
def login_required(func):
|
||||
def wrapped(resource, req, resp, *args, **kwargs):
|
||||
authorization = req.get_header("Authorization")
|
||||
|
||||
if not authorization:
|
||||
resp.append_header("WWW-Authenticate", "Negotiate")
|
||||
raise falcon.HTTPUnauthorized("Unauthorized", "No Kerberos ticket offered?")
|
||||
|
||||
token = ''.join(authorization.split()[1:])
|
||||
|
||||
try:
|
||||
result, context = kerberos.authGSSServerInit("HTTP@" + FQDN)
|
||||
except kerberos.GSSError as ex:
|
||||
raise falcon.HTTPForbidden("Forbidden", "Authentication System Failure: %s(%s)" % (ex[0][0], ex[1][0],))
|
||||
|
||||
try:
|
||||
result = kerberos.authGSSServerStep(context, token)
|
||||
except kerberos.GSSError as ex:
|
||||
kerberos.authGSSServerClean(context)
|
||||
raise falcon.HTTPForbidden("Forbidden", "Bad credentials: %s(%s)" % (ex[0][0], ex[1][0],))
|
||||
except kerberos.KrbError as ex:
|
||||
kerberos.authGSSServerClean(context)
|
||||
raise falcon.HTTPForbidden("Forbidden", "Bad credentials: %s" % (ex[0],))
|
||||
|
||||
kerberos_user = kerberos.authGSSServerUserName(context).split("@")
|
||||
|
||||
try:
|
||||
# BUGBUG: https://github.com/02strich/pykerberos/issues/6
|
||||
#kerberos.authGSSServerClean(context)
|
||||
pass
|
||||
except kerberos.GSSError as ex:
|
||||
raise error.LoginFailed('Authentication System Failure %s(%s)' % (ex[0][0], ex[1][0],))
|
||||
|
||||
if result == kerberos.AUTH_GSS_COMPLETE:
|
||||
kwargs["user"] = kerberos_user
|
||||
return func(resource, req, resp, *args, **kwargs)
|
||||
elif result == kerberos.AUTH_GSS_CONTINUE:
|
||||
raise falcon.HTTPUnauthorized("Unauthorized", "Tried GSSAPI")
|
||||
else:
|
||||
raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI")
|
||||
|
||||
return wrapped
|
@ -21,9 +21,9 @@
|
||||
|
||||
<h1>Signed certificates</h1>
|
||||
|
||||
<ul>
|
||||
<ul id="signed_certificates">
|
||||
{% for j in authority.signed | sort | reverse %}
|
||||
<li id="certificate_{{ j.sha256sum }}">
|
||||
<li id="certificate_{{ j.sha256sum }}" data-dn="{{ j.subject }}">
|
||||
<a class="button" href="/api/{{authority.slug}}/signed/{{j.subject}}/">Fetch</a>
|
||||
<button onClick="javascript:$.ajax({url:'/api/{{authority.slug}}/signed/{{j.subject.CN}}/',type:'delete'});">Revoke</button>
|
||||
|
||||
@ -51,6 +51,9 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div class="status">
|
||||
{% include 'status.html' %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -49,6 +49,29 @@ $(document).ready(function() {
|
||||
});
|
||||
|
||||
$("#container").html(nunjucks.render('authority.html', { authority: authority, session: session }));
|
||||
|
||||
$.ajax({
|
||||
method: "GET",
|
||||
url: "/api/ca/" + authority.slug + "/lease/",
|
||||
dataType: "json",
|
||||
success: function(leases, status, xhr) {
|
||||
console.info("Got leases:", leases);
|
||||
for (var j = 0; j < leases.length; j++) {
|
||||
var $status = $("#signed_certificates [data-dn='" + leases[j].dn + "'] .status");
|
||||
if (!$status.length) {
|
||||
console.info("Detected rogue client:", leases[j]);
|
||||
continue;
|
||||
}
|
||||
$status.html(nunjucks.render('status.html', {
|
||||
lease: {
|
||||
address: leases[j].address,
|
||||
dn: leases[j].dn,
|
||||
acquired: new Date(leases[j].acquired).toLocaleString(),
|
||||
released: leases[j].released ? new Date(leases[j].released).toLocaleString() : null
|
||||
}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
16
certidude/static/status.html
Normal file
16
certidude/static/status.html
Normal file
@ -0,0 +1,16 @@
|
||||
<svg height="32" width="32">
|
||||
<circle cx="16" cy="16" r="13" stroke="black" stroke-width="3" fill="{% if lease %}{% if lease.released %}green{%else %}red{% endif %}{% else %}yellow{% endif %}" />
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
|
||||
{% if lease %}
|
||||
{% if lease.released %}
|
||||
Last seen {{ lease.released }} at {{ lease.address }}
|
||||
{% else %}
|
||||
Online since {{ lease.acquired }} at {{ lease.address }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Not seen
|
||||
{% endif %}
|
||||
</span>
|
After Width: | Height: | Size: 428 B |
@ -88,7 +88,7 @@ class CertificateAuthorityConfig(object):
|
||||
section = "CA_" + slug
|
||||
|
||||
dirs = dict([(key, self.get(section, key))
|
||||
for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "push_server", "inbox", "outbox")])
|
||||
for key in ("dir", "certificate", "crl", "certs", "new_certs_dir", "private_key", "revoked_certs_dir", "request_subnets", "autosign_subnets", "admin_subnets", "admin_users", "push_server", "database", "inbox", "outbox")])
|
||||
|
||||
# Variable expansion, eg $dir
|
||||
for key, value in dirs.items():
|
||||
@ -433,7 +433,7 @@ class Certificate(CertificateBase):
|
||||
return self.signed <= other.signed
|
||||
|
||||
class CertificateAuthority(object):
|
||||
def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, push_server=None):
|
||||
def __init__(self, slug, certificate, crl, certs, new_certs_dir, revoked_certs_dir=None, private_key=None, autosign_subnets=None, request_subnets=None, admin_subnets=None, admin_users=None, email_address=None, inbox=None, outbox=None, basic_constraints="CA:FALSE", key_usage="digitalSignature,keyEncipherment", extended_key_usage="clientAuth", certificate_lifetime=5*365, revocation_list_lifetime=1, push_server=None, database=None):
|
||||
|
||||
import hashlib
|
||||
m = hashlib.sha512()
|
||||
@ -455,6 +455,8 @@ class CertificateAuthority(object):
|
||||
self.certificate = Certificate(open(certificate))
|
||||
self.mailer = Mailer(outbox) if outbox else None
|
||||
self.push_server = push_server
|
||||
self.database_url = database
|
||||
self._database_pool = None
|
||||
|
||||
self.certificate_lifetime = certificate_lifetime
|
||||
self.revocation_list_lifetime = revocation_list_lifetime
|
||||
@ -474,6 +476,24 @@ class CertificateAuthority(object):
|
||||
else:
|
||||
self.admin_users = set([j for j in admin_users.split(" ") if j])
|
||||
|
||||
@property
|
||||
def database(self):
|
||||
from urllib.parse import urlparse
|
||||
if not self._database_pool:
|
||||
o = urlparse(self.database_url)
|
||||
if o.scheme == "mysql":
|
||||
import mysql.connector
|
||||
self._database_pool = mysql.connector.pooling.MySQLConnectionPool(
|
||||
pool_size = 3,
|
||||
user=o.username,
|
||||
password=o.password,
|
||||
host=o.hostname,
|
||||
database=o.path[1:])
|
||||
else:
|
||||
raise NotImplementedError("Unsupported database scheme %s, currently only mysql://user:pass@host/database is supported" % o.scheme)
|
||||
|
||||
return self._database_pool
|
||||
|
||||
def event_publish(self, event_type, event_data):
|
||||
"""
|
||||
Publish event on push server
|
||||
|
Loading…
Reference in New Issue
Block a user