api: Preliminary API call for listing client leases

This commit is contained in:
Lauri Võsandi 2015-11-13 19:41:19 +01:00
parent 3d36b2f92c
commit 887743cc0b
6 changed files with 200 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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
}}));
}
}
});
}
});
}

View 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

View File

@ -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