1
0
mirror of https://github.com/laurivosandi/certidude synced 2025-10-31 01:19:11 +00:00

Complete overhaul

* Switch to Python 2.x due to lack of decent LDAP support in Python 3.x
* Add LDAP backend for authentication/authorization
* Add PAM backend for authentication
* Add getent backend for authorization
* Add preliminary CSRF protection
* Update icons
* Update push server documentation, use nchan from now on
* Add P12 bundle generation
* Add thin wrapper around Python's SQL connectors
* Enable mailing subsystem
* Add Kerberos TGT renewal cronjob
* Add HTTPS server setup commands for nginx
This commit is contained in:
2016-03-21 23:42:39 +02:00
parent ffdab4d36d
commit 811e6dbb08
96 changed files with 2140 additions and 10312 deletions

View File

@@ -1,13 +1,19 @@
# encoding: utf-8
import falcon
import mimetypes
import logging
import os
import click
from datetime import datetime
from time import sleep
from certidude import authority
from certidude import authority, mailer
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize, event_source
from certidude.decorators import serialize, event_source, csrf_protection
from certidude.wrappers import Request, Certificate
from certidude import config
from certidude import constants, config
logger = logging.getLogger("api")
class CertificateStatusResource(object):
"""
@@ -24,7 +30,9 @@ class CertificateStatusResource(object):
class CertificateAuthorityResource(object):
def on_get(self, req, resp):
logger.info("Served CA certificate to %s", req.context.get("remote_addr"))
resp.stream = open(config.AUTHORITY_CERTIFICATE_PATH, "rb")
resp.append_header("Content-Type", "application/x-x509-ca-cert")
resp.append_header("Content-Disposition", "attachment; filename=ca.crt")
@@ -34,16 +42,54 @@ class SessionResource(object):
@authorize_admin
@event_source
def on_get(self, req, resp):
if config.ACCOUNTS_BACKEND == "ldap":
import ldap
ft = config.LDAP_MEMBERS_FILTER % (config.ADMINS_GROUP, "*")
r = req.context.get("ldap_conn").search_s(config.LDAP_BASE,
ldap.SCOPE_SUBTREE, ft.encode("utf-8"), ["cn", "member"])
for dn,entry in r:
cn, = entry.get("cn")
break
else:
raise ValueError("Failed to look up group %s in LDAP" % repr(group_name))
admins = dict([(j, j.split(",")[0].split("=")[1]) for j in entry.get("member")])
elif config.ACCOUNTS_BACKEND == "posix":
import grp
_, _, gid, members = grp.getgrnam(config.ADMINS_GROUP)
admins = dict([(j, j) for j in members])
else:
raise NotImplementedError("Authorization backend %s not supported" % config.AUTHORIZATION_BACKEND)
return dict(
username=req.context.get("user"),
event_channel = config.PUSH_EVENT_SOURCE % config.PUSH_TOKEN,
user = dict(
name=req.context.get("user").name,
gn=req.context.get("user").given_name,
sn=req.context.get("user").surname,
mail=req.context.get("user").mail
),
request_submission_allowed = sum( # Dirty hack!
[req.context.get("remote_addr") in j
for j in config.REQUEST_SUBNETS]),
user_subnets = config.USER_SUBNETS,
autosign_subnets = config.AUTOSIGN_SUBNETS,
request_subnets = config.REQUEST_SUBNETS,
admin_subnets=config.ADMIN_SUBNETS,
admin_users=config.ADMIN_USERS,
requests=authority.list_requests(),
signed=authority.list_signed(),
revoked=authority.list_revoked())
admin_users = admins,
#admin_users=config.ADMIN_USERS,
authority = dict(
outbox = config.OUTBOX,
certificate = authority.certificate,
events = config.PUSH_EVENT_SOURCE % config.PUSH_TOKEN,
requests=authority.list_requests(),
signed=authority.list_signed(),
revoked=authority.list_revoked(),
) if config.ADMINS_GROUP in req.context.get("groups") else None,
features=dict(
tagging=config.TAGGING_BACKEND,
leases=False, #config.LEASES_BACKEND,
logging=config.LOGGING_BACKEND))
class StaticResource(object):
@@ -58,7 +104,7 @@ class StaticResource(object):
if os.path.isdir(path):
path = os.path.join(path, "index.html")
print("Serving:", path)
click.echo("Serving: %s" % path)
if os.path.exists(path):
content_type, content_encoding = mimetypes.guess_type(path)
@@ -72,7 +118,33 @@ class StaticResource(object):
resp.body = "File '%s' not found" % req.path
class BundleResource(object):
@login_required
def on_get(self, req, resp):
common_name = req.context["user"].mail
logger.info("Signing bundle %s for %s", common_name, req.context.get("user"))
resp.set_header("Content-Type", "application/x-pkcs12")
resp.set_header("Content-Disposition", "attachment; filename=%s.p12" % common_name)
resp.body, cert = authority.generate_pkcs12_bundle(common_name,
owner=req.context.get("user"))
import ipaddress
class NormalizeMiddleware(object):
@csrf_protection
def process_request(self, req, resp, *args):
assert not req.get_param("unicode") or req.get_param("unicode") == u"", "Unicode sanity check failed"
req.context["remote_addr"] = ipaddress.ip_address(req.env["REMOTE_ADDR"].decode("utf-8"))
def process_response(self, req, resp, resource):
# wtf falcon?!
if isinstance(resp.location, unicode):
resp.location = resp.location.encode("ascii")
def certidude_app():
from certidude import config
from .revoked import RevocationListResource
from .signed import SignedCertificateListResource, SignedCertificateDetailResource
from .request import RequestListResource, RequestDetailResource
@@ -82,60 +154,56 @@ def certidude_app():
from .tag import TagResource, TagDetailResource
from .cfg import ConfigResource, ScriptResource
app = falcon.API()
app = falcon.API(middleware=NormalizeMiddleware())
# Certificate authority API calls
app.add_route("/api/ocsp/", CertificateStatusResource())
app.add_route("/api/bundle/", BundleResource())
app.add_route("/api/certificate/", CertificateAuthorityResource())
app.add_route("/api/revoked/", RevocationListResource())
app.add_route("/api/signed/{cn}/", SignedCertificateDetailResource())
app.add_route("/api/signed/", SignedCertificateListResource())
app.add_route("/api/request/{cn}/", RequestDetailResource())
app.add_route("/api/request/", RequestListResource())
app.add_route("/api/log/", LogResource())
app.add_route("/api/tag/", TagResource())
app.add_route("/api/tag/{identifier}/", TagDetailResource())
app.add_route("/api/config/", ConfigResource())
app.add_route("/api/script/", ScriptResource())
app.add_route("/api/", SessionResource())
# Gateway API calls, should this be moved to separate project?
app.add_route("/api/lease/", LeaseResource())
app.add_route("/api/whois/", WhoisResource())
"""
Set up logging
"""
log_handlers = []
if config.LOGGING_BACKEND == "sql":
from certidude.mysqllog import LogHandler
uri = config.cp.get("logging", "database")
log_handlers.append(LogHandler(uri))
app.add_route("/api/log/", LogResource(uri))
elif config.LOGGING_BACKEND == "syslog":
from logging.handlers import SyslogHandler
log_handlers.append(SysLogHandler())
# Browsing syslog via HTTP is obviously not possible out of the box
elif config.LOGGING_BACKEND:
raise ValueError("Invalid logging.backend = %s" % config.LOGGING_BACKEND)
from certidude import config
from certidude.mysqllog import MySQLLogHandler
from datetime import datetime
import logging
import socket
import json
if config.TAGGING_BACKEND == "sql":
uri = config.cp.get("tagging", "database")
app.add_route("/api/tag/", TagResource(uri))
app.add_route("/api/tag/{identifier}/", TagDetailResource(uri))
app.add_route("/api/config/", ConfigResource(uri))
app.add_route("/api/script/", ScriptResource(uri))
elif config.TAGGING_BACKEND:
raise ValueError("Invalid tagging.backend = %s" % config.TAGGING_BACKEND)
class PushLogHandler(logging.Handler):
def emit(self, record):
from certidude.push import publish
publish("log-entry", dict(
created = datetime.fromtimestamp(record.created),
message = record.msg % record.args,
severity = record.levelname.lower()))
if config.DATABASE_POOL:
sql_handler = MySQLLogHandler(config.DATABASE_POOL)
push_handler = PushLogHandler()
if config.PUSH_PUBLISH:
from certidude.push import PushLogHandler
log_handlers.append(PushLogHandler())
for facility in "api", "cli":
logger = logging.getLogger(facility)
logger.setLevel(logging.DEBUG)
if config.DATABASE_POOL:
logger.addHandler(sql_handler)
logger.addHandler(push_handler)
for handler in log_handlers:
logger.addHandler(handler)
logging.getLogger("cli").debug("Started Certidude at %s", config.FQDN)
logging.getLogger("cli").debug("Started Certidude at %s", constants.FQDN)
import atexit

View File

@@ -6,6 +6,7 @@ from random import choice
from certidude import config
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize
from certidude.relational import RelationalMixin
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger("api")
@@ -39,43 +40,42 @@ where
device.cn = %s
"""
SQL_SELECT_INHERITANCE = """
SQL_SELECT_RULES = """
select
tag_inheritance.`id` as `id`,
tag.id as `tag_id`,
tag.`key` as `match_key`,
tag.`value` as `match_value`,
tag_inheritance.`key` as `key`,
tag_inheritance.`value` as `value`
from tag_inheritance
join tag on tag.id = tag_inheritance.tag_id
tag.cn as `cn`,
tag.key as `tag_key`,
tag.value as `tag_value`,
tag_properties.property_key as `property_key`,
tag_properties.property_value as `property_value`
from
tag_properties
join
tag
on
tag.key = tag_properties.tag_key and
tag.value = tag_properties.tag_value
"""
class ConfigResource(object):
class ConfigResource(RelationalMixin):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(SQL_SELECT_INHERITANCE)
def g():
for row in cursor:
yield row
cursor.close()
conn.close()
return g()
return self.iterfetch(SQL_SELECT_RULES)
class ScriptResource(object):
class ScriptResource(RelationalMixin):
def on_get(self, req, resp):
from certidude.api.whois import address_to_identity
node = address_to_identity(
config.DATABASE_POOL.get_connection(),
ipaddress.ip_address(req.env["REMOTE_ADDR"])
self.connect(),
req.context.get("remote_addr")
)
if not node:
resp.body = "Could not map IP address: %s" % req.env["REMOTE_ADDR"]
resp.body = "Could not map IP address: %s" % req.context.get("remote_addr")
resp.status = falcon.HTTP_404
return
@@ -84,7 +84,7 @@ class ScriptResource(object):
key, common_name = identity.split("=")
assert "=" not in common_name
conn = config.DATABASE_POOL.get_connection()
conn = self.connect()
cursor = conn.cursor()
resp.set_header("Content-Type", "text/x-shellscript")

View File

@@ -2,38 +2,14 @@
from certidude import config
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize
from certidude.relational import RelationalMixin
class LogResource(RelationalMixin):
SQL_CREATE_TABLES = "log_tables.sql"
class LogResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
"""
Translate currently online client's IP-address to distinguished name
"""
SQL_LOG_ENTRIES = """
SELECT
*
FROM
log
ORDER BY created DESC
"""
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(SQL_LOG_ENTRIES)
def g():
for row in cursor:
yield row
cursor.close()
conn.close()
return tuple(g())
# for acquired, released, identity in cursor:
# return {
# "acquired": datetime.utcfromtimestamp(acquired),
# "identity": parse_dn(bytes(identity))
# }
# return None
# TODO: Add last id parameter
return self.iterfetch("select * from log order by created desc")

View File

@@ -5,45 +5,42 @@ import logging
import ipaddress
import os
from certidude import config, authority, helpers, push, errors
from certidude.auth import login_required, authorize_admin
from certidude.auth import login_required, login_optional, authorize_admin
from certidude.decorators import serialize
from certidude.wrappers import Request, Certificate
from certidude.firewall import whitelist_subnets, whitelist_content_types
logger = logging.getLogger("api")
class RequestListResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
return helpers.list_requests()
return authority.list_requests()
@login_optional
@whitelist_subnets(config.REQUEST_SUBNETS)
@whitelist_content_types("application/pkcs10")
def on_post(self, req, resp):
"""
Submit certificate signing request (CSR) in PEM format
"""
# Parse remote IPv4/IPv6 address
remote_addr = ipaddress.ip_network(req.env["REMOTE_ADDR"].decode("utf-8"))
# Check for CSR submission whitelist
if config.REQUEST_SUBNETS:
for subnet in config.REQUEST_SUBNETS:
if subnet.overlaps(remote_addr):
break
else:
logger.warning("Attempted to submit signing request from non-whitelisted address %s", remote_addr)
raise falcon.HTTPForbidden("Forbidden", "IP address %s not whitelisted" % remote_addr)
if req.get_header("Content-Type") != "application/pkcs10":
raise falcon.HTTPUnsupportedMediaType(
"This API call accepts only application/pkcs10 content type")
body = req.stream.read(req.content_length).decode("ascii")
body = req.stream.read(req.content_length)
csr = Request(body)
if not csr.common_name:
logger.warning("Rejected signing request without common name from %s",
req.context.get("remote_addr"))
raise falcon.HTTPBadRequest(
"Bad request",
"No common name specified!")
# Check if this request has been already signed and return corresponding certificte if it has been signed
try:
cert = authority.get_signed(csr.common_name)
except FileNotFoundError:
except EnvironmentError:
pass
else:
if cert.pubkey == csr.pubkey:
@@ -56,12 +53,12 @@ class RequestListResource(object):
# Process automatic signing if the IP address is whitelisted and autosigning was requested
if req.get_param_as_bool("autosign"):
for subnet in config.AUTOSIGN_SUBNETS:
if subnet.overlaps(remote_addr):
if subnet.overlaps(req.context.get("remote_addr")):
try:
resp.set_header("Content-Type", "application/x-x509-user-cert")
resp.body = authority.sign(csr).dump()
return
except FileExistsError: # Certificate already exists, try to save the request
except EnvironmentError: # Certificate already exists, try to save the request
pass
break
@@ -73,7 +70,8 @@ class RequestListResource(object):
pass
except errors.DuplicateCommonNameError:
# TODO: Certificate renewal
logger.warning("Rejected signing request with overlapping common name from %s", req.env["REMOTE_ADDR"])
logger.warning("Rejected signing request with overlapping common name from %s",
req.context.get("remote_addr"))
raise falcon.HTTPConflict(
"CSR with such CN already exists",
"Will not overwrite existing certificate signing request, explicitly delete CSR and try again")
@@ -86,12 +84,12 @@ class RequestListResource(object):
url = config.PUSH_LONG_POLL % csr.fingerprint()
click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url)
logger.warning("Redirecting signing request from %s to %s", req.env["REMOTE_ADDR"], url)
resp.set_header("Location", url.encode("ascii"))
logger.debug("Redirecting signing request from %s to %s", req.context.get("remote_addr"), url)
else:
# Request was accepted, but not processed
resp.status = falcon.HTTP_202
logger.info("Signing request from %s stored", req.env["REMOTE_ADDR"])
logger.info("Signing request from %s stored", req.context.get("remote_addr"))
class RequestDetailResource(object):
@@ -101,11 +99,8 @@ class RequestDetailResource(object):
Fetch certificate signing request as PEM
"""
csr = authority.get_request(cn)
# if not os.path.exists(path):
# raise falcon.HTTPNotFound()
resp.set_header("Content-Type", "application/pkcs10")
resp.set_header("Content-Disposition", "attachment; filename=%s.csr" % csr.common_name)
logger.debug("Signing request %s was downloaded by %s",
csr.common_name, req.context.get("remote_addr"))
return csr
@login_required
@@ -120,14 +115,17 @@ class RequestDetailResource(object):
resp.body = "Certificate successfully signed"
resp.status = falcon.HTTP_201
resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn)
logger.info("Signing request %s signed by %s from %s", csr.common_name, req.context["user"], req.env["REMOTE_ADDR"])
logger.info("Signing request %s signed by %s from %s", csr.common_name,
req.context.get("user"), req.context.get("remote_addr"))
@login_required
@authorize_admin
def on_delete(self, req, resp, cn):
try:
authority.delete_request(cn)
except FileNotFoundError:
# Logging implemented in the function above
except EnvironmentError as e:
resp.body = "No certificate CN=%s found" % cn
logger.warning("User %s attempted to delete non-existant signing request %s from %s", req.context["user"], cn, req.env["REMOTE_ADDR"])
logger.warning("User %s failed to delete signing request %s from %s, reason: %s",
req.context["user"], cn, req.context.get("remote_addr"), e)
raise falcon.HTTPNotFound()

View File

@@ -1,9 +1,12 @@
import logging
from certidude.authority import export_crl
logger = logging.getLogger("api")
class RevocationListResource(object):
def on_get(self, req, resp):
logger.debug("Revocation list requested by %s", req.context.get("remote_addr"))
resp.set_header("Content-Type", "application/x-pkcs7-crl")
resp.append_header("Content-Disposition", "attachment; filename=ca.crl")
resp.body = export_crl()

View File

@@ -9,40 +9,35 @@ logger = logging.getLogger("api")
class SignedCertificateListResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
for j in authority.list_signed():
yield omit(
key_type=j.key_type,
key_length=j.key_length,
identity=j.identity,
cn=j.common_name,
c=j.country_code,
st=j.state_or_county,
l=j.city,
o=j.organization,
ou=j.organizational_unit,
fingerprint=j.fingerprint())
return {"signed":authority.list_signed()}
class SignedCertificateDetailResource(object):
@serialize
def on_get(self, req, resp, cn):
# Compensate for NTP lag
from time import sleep
sleep(5)
# from time import sleep
# sleep(5)
try:
logger.info("Served certificate %s to %s", cn, req.env["REMOTE_ADDR"])
resp.set_header("Content-Disposition", "attachment; filename=%s.crt" % cn)
return authority.get_signed(cn)
except FileNotFoundError:
logger.warning("Failed to serve non-existant certificate %s to %s", cn, req.env["REMOTE_ADDR"])
cert = authority.get_signed(cn)
except EnvironmentError:
logger.warning("Failed to serve non-existant certificate %s to %s",
cn, req.context.get("remote_addr"))
resp.body = "No certificate CN=%s found" % cn
raise falcon.HTTPNotFound()
else:
logger.debug("Served certificate %s to %s",
cn, req.context.get("remote_addr"))
return cert
@login_required
@authorize_admin
def on_delete(self, req, resp, cn):
logger.info("Revoked certificate %s by %s from %s", cn, req.context["user"], req.env["REMOTE_ADDR"])
logger.info("Revoked certificate %s by %s from %s",
cn, req.context.get("user"), req.context.get("remote_addr"))
authority.revoke_certificate(cn)

View File

@@ -1,117 +1,63 @@
import falcon
import logging
from certidude import config
from certidude.relational import RelationalMixin
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize
logger = logging.getLogger("api")
SQL_TAG_LIST = """
select
device_tag.id as `id`,
tag.key as `key`,
tag.value as `value`,
device.cn as `cn`
from
device_tag
join
tag
on
device_tag.tag_id = tag.id
join
device
on
device_tag.device_id = device.id
"""
class TagResource(RelationalMixin):
SQL_CREATE_TABLES = "tag_tables.sql"
SQL_TAG_DETAIL = SQL_TAG_LIST + " where device_tag.id = %s"
class TagResource(object):
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(SQL_TAG_LIST)
return self.iterfetch("select * from tag")
def g():
for row in cursor:
yield row
cursor.close()
conn.close()
return tuple(g())
@serialize
@login_required
@authorize_admin
def on_post(self, req, resp):
from certidude import push
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor()
args = req.get_param("cn"),
cursor.execute(
"insert ignore device (`cn`) values (%s) on duplicate key update used = NOW();", args)
device_id = cursor.lastrowid
args = req.get_param("key"), req.get_param("value")
cursor.execute(
"insert into tag (`key`, `value`) values (%s, %s) on duplicate key update used = NOW();", args)
tag_id = cursor.lastrowid
args = device_id, tag_id
cursor.execute(
"insert into device_tag (`device_id`, `tag_id`) values (%s, %s);", args)
push.publish("tag-added", str(cursor.lastrowid))
args = req.get_param("cn"), req.get_param("key"), req.get_param("value")
rowid = self.sql_execute("tag_insert.sql", *args)
push.publish("tag-added", str(rowid))
logger.debug("Tag cn=%s, key=%s, value=%s added" % args)
conn.commit()
cursor.close()
conn.close()
class TagDetailResource(object):
class TagDetailResource(RelationalMixin):
SQL_CREATE_TABLES = "tag_tables.sql"
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp, identifier):
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(SQL_TAG_DETAIL, (identifier,))
conn = self.sql_connect()
cursor = conn.cursor()
if self.uri.scheme == "mysql":
cursor.execute("select `cn`, `key`, `value` from tag where id = %s", (identifier,))
else:
cursor.execute("select `cn`, `key`, `value` from tag where id = ?", (identifier,))
cols = [j[0] for j in cursor.description]
for row in cursor:
cursor.close()
conn.close()
return row
return dict(zip(cols, row))
cursor.close()
conn.close()
raise falcon.HTTPNotFound()
@serialize
@login_required
@authorize_admin
def on_put(self, req, resp, identifier):
from certidude import push
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor()
# Create tag if necessary
args = req.get_param("key"), req.get_param("value")
cursor.execute(
"insert into tag (`key`, `value`) values (%s, %s) on duplicate key update used = NOW();", args)
tag_id = cursor.lastrowid
# Attach tag to device
cursor.execute("update device_tag set tag_id = %s where `id` = %s limit 1",
(tag_id, identifier))
conn.commit()
cursor.close()
conn.close()
args = req.get_param("value"), identifier
self.sql_execute("tag_update.sql", *args)
logger.debug("Tag %s updated, value set to %s",
identifier, req.get_param("value"))
push.publish("tag-updated", identifier)
@@ -122,13 +68,6 @@ class TagDetailResource(object):
@authorize_admin
def on_delete(self, req, resp, identifier):
from certidude import push
conn = config.DATABASE_POOL.get_connection()
cursor = conn.cursor()
cursor.execute("delete from device_tag where id = %s", (identifier,))
conn.commit()
cursor.close()
conn.close()
self.sql_execute("tag_delete.sql", identifier)
push.publish("tag-removed", identifier)
logger.debug("Tag %s removed" % identifier)

View File

@@ -46,7 +46,7 @@ class WhoisResource(object):
identity = address_to_identity(
conn,
ipaddress.ip_address(req.get_param("address") or req.env["REMOTE_ADDR"])
req.context.get("remote_addr")
)
conn.close()
@@ -55,4 +55,4 @@ class WhoisResource(object):
return dict(address=identity[0], acquired=identity[1], identity=identity[2])
else:
resp.status = falcon.HTTP_403
resp.body = "Failed to look up node %s" % req.env["REMOTE_ADDR"]
resp.body = "Failed to look up node %s" % req.context.get("remote_addr")