mirror of
				https://github.com/laurivosandi/certidude
				synced 2025-10-31 09:29:13 +00:00 
			
		
		
		
	api: Preliminary API call for listing client leases
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user