1
0
mirror of https://github.com/laurivosandi/certidude synced 2024-12-22 08:15:18 +00:00

Several updates #3

* Move SessionResource and CertificateAuthorityResource to api/session.py
* Log browser user agent for logins
* Remove static sink from backend, nginx always serves static now
* Don't emit 'attribute-update' event if no attributes were changed
* Better CN extraction from DN during lease update
* Log user who deleted request
* Remove long polling CRL fetch API call and relevant test
* Merge auth decorators ldap_authenticate, kerberos_authenticate, pam_authenticate
* Add 'kerberos subnets' to distinguish authentication method
* Add 'admin subnets' to filter traffic to administrative API calls
* Highlight recent log events
* Links to switch between 2, 3 and 4 column layouts in the dashboard
* Restored certidude client snippets in request dialog
* Various bugfixes, improved log messages
This commit is contained in:
Lauri Võsandi 2018-05-04 08:54:55 +00:00
parent 4348458d30
commit bfdd8c4887
22 changed files with 450 additions and 440 deletions

View File

@ -7,7 +7,8 @@ after_success:
- codecov
script:
- echo registry=http://registry.npmjs.org/ | sudo tee /root/.npmrc
- sudo apt install software-properties-common python3-setuptools python3-mysql.connector python3-pyxattr
- sudo apt install software-properties-common python3-setuptools build-essential python3-dev libsasl2-dev libkrb5-dev
- sudo apt remove python3-mimeparse
- sudo mkdir -p /etc/systemd/system # Until Travis is stuck with 14.04
- sudo easy_install3 pip
- sudo -H pip3 install -r requirements.txt

View File

@ -1,218 +1,19 @@
# encoding: utf-8
import falcon
import mimetypes
import logging
import os
import hashlib
from datetime import datetime
from xattr import listxattr, getxattr
from certidude.common import cert_to_dn
from certidude.user import User
from certidude.decorators import serialize, csrf_protection
from certidude import const, config, authority
from .utils import AuthorityHandler
from .utils.firewall import login_required, authorize_admin
logger = logging.getLogger(__name__)
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=%s.crt" %
const.HOSTNAME.encode("ascii"))
class SessionResource(AuthorityHandler):
@csrf_protection
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
def serialize_requests(g):
for common_name, path, buf, req, submitted, server in g():
try:
submission_address = getxattr(path, "user.request.address").decode("ascii") # TODO: move to authority.py
except IOError:
submission_address = None
try:
submission_hostname = getxattr(path, "user.request.hostname").decode("ascii") # TODO: move to authority.py
except IOError:
submission_hostname = None
yield dict(
submitted = submitted,
common_name = common_name,
address = submission_address,
hostname = submission_hostname if submission_hostname != submission_address else None,
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),
sha256sum = hashlib.sha256(buf).hexdigest(),
sha512sum = hashlib.sha512(buf).hexdigest()
)
def serialize_revoked(g):
for common_name, path, buf, cert, signed, expired, revoked, reason in g(limit=5):
yield dict(
serial = "%x" % cert.serial_number,
common_name = common_name,
# TODO: key type, key length, key exponent, key modulo
signed = signed,
expired = expired,
revoked = revoked,
reason = reason,
sha256sum = hashlib.sha256(buf).hexdigest())
def serialize_certificates(g):
for common_name, path, buf, cert, signed, expires in g():
# Extract certificate tags from filesystem
try:
tags = []
for tag in getxattr(path, "user.xdg.tags").decode("utf-8").split(","):
if "=" in tag:
k, v = tag.split("=", 1)
else:
k, v = "other", tag
tags.append(dict(id=tag, key=k, value=v))
except IOError: # No such attribute(s)
tags = None
attributes = {}
for key in listxattr(path):
if key.startswith(b"user.machine."):
attributes[key[13:].decode("ascii")] = getxattr(path, key).decode("ascii")
# Extract lease information from filesystem
try:
last_seen = datetime.strptime(getxattr(path, "user.lease.last_seen").decode("ascii"), "%Y-%m-%dT%H:%M:%S.%fZ")
lease = dict(
inner_address = getxattr(path, "user.lease.inner_address").decode("ascii"),
outer_address = getxattr(path, "user.lease.outer_address").decode("ascii"),
last_seen = last_seen,
age = datetime.utcnow() - last_seen
)
except IOError: # No such attribute(s)
lease = None
try:
signer_username = getxattr(path, "user.signature.username").decode("ascii")
except IOError:
signer_username = None
# TODO: dedup
yield dict(
serial = "%x" % cert.serial_number,
organizational_unit = cert.subject.native.get("organizational_unit_name"),
common_name = common_name,
# TODO: key type, key length, key exponent, key modulo
signed = signed,
expires = expires,
sha256sum = hashlib.sha256(buf).hexdigest(),
signer = signer_username,
lease = lease,
tags = tags,
attributes = attributes or None,
extensions = dict([
(e["extn_id"].native, e["extn_value"].native)
for e in cert["tbs_certificate"]["extensions"]
if e["extn_id"].native in ("extended_key_usage",)])
)
logger.info("Logged in authority administrator %s from %s" % (req.context.get("user"), req.context.get("remote_addr")))
return dict(
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 = config.REQUEST_SUBMISSION_ALLOWED,
service = dict(
protocols = config.SERVICE_PROTOCOLS,
routers = [j[0] for j in authority.list_signed(
common_name=config.SERVICE_ROUTERS)]
),
authority = dict(
builder = dict(
profiles = config.IMAGE_BUILDER_PROFILES
),
tagging = [dict(name=t[0], type=t[1], title=t[2]) for t in config.TAG_TYPES],
lease = dict(
offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option
dead = 604800 # Seconds from last activity to consider lease dead, X509 chain broken or machine discarded
),
certificate = dict(
algorithm = authority.public_key.algorithm,
common_name = self.authority.certificate.subject.native["common_name"],
distinguished_name = cert_to_dn(self.authority.certificate),
md5sum = hashlib.md5(self.authority.certificate_buf).hexdigest(),
blob = self.authority.certificate_buf.decode("ascii"),
),
mailer = dict(
name = config.MAILER_NAME,
address = config.MAILER_ADDRESS
) if config.MAILER_ADDRESS else None,
machine_enrollment_subnets=config.MACHINE_ENROLLMENT_SUBNETS,
user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED,
user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES,
events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN,
requests=serialize_requests(self.authority.list_requests),
signed=serialize_certificates(self.authority.list_signed),
revoked=serialize_revoked(self.authority.list_revoked),
admin_users = User.objects.filter_admins(),
user_subnets = config.USER_SUBNETS or None,
autosign_subnets = config.AUTOSIGN_SUBNETS or None,
request_subnets = config.REQUEST_SUBNETS or None,
admin_subnets=config.ADMIN_SUBNETS or None,
signature = dict(
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME,
profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")),
)
),
features=dict(
ocsp=bool(config.OCSP_SUBNETS),
crl=bool(config.CRL_SUBNETS),
token=bool(config.TOKEN_URL),
tagging=True,
leases=True,
logging=config.LOGGING_BACKEND)
)
class StaticResource(object):
def __init__(self, root):
self.root = os.path.realpath(root)
def __call__(self, req, resp):
path = os.path.realpath(os.path.join(self.root, req.path[1:]))
if not path.startswith(self.root):
raise falcon.HTTPBadRequest()
if os.path.isdir(path):
path = os.path.join(path, "index.html")
if os.path.exists(path):
content_type, content_encoding = mimetypes.guess_type(path)
if content_type:
resp.append_header("Content-Type", content_type)
if content_encoding:
resp.append_header("Content-Encoding", content_encoding)
resp.stream = open(path, "rb")
logger.debug("Serving '%s' from '%s'", req.path, path)
else:
resp.status = falcon.HTTP_404
resp.body = "File '%s' not found" % req.path
logger.info("File '%s' not found, path resolved to '%s'", req.path, path)
import ipaddress
import os
from certidude import config
from user_agents import parse
class NormalizeMiddleware(object):
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.access_route[0])
if req.user_agent:
req.context["user_agent"] = parse(req.user_agent)
else:
req.context["user_agent"] = "Unknown user agent"
def certidude_app(log_handlers=[]):
from certidude import authority, config
@ -225,10 +26,10 @@ def certidude_app(log_handlers=[]):
from .bootstrap import BootstrapResource
from .token import TokenResource
from .builder import ImageBuilderResource
from .session import SessionResource, CertificateAuthorityResource
app = falcon.API(middleware=NormalizeMiddleware())
app.req_options.auto_parse_form_urlencoded = True
#app.req_options.strip_url_path_trailing_slash = False
# Certificate authority API calls
app.add_route("/api/certificate/", CertificateAuthorityResource())
@ -270,9 +71,6 @@ def certidude_app(log_handlers=[]):
from .scep import SCEPResource
app.add_route("/api/scep/", SCEPResource(authority))
# Add sink for serving static files
app.add_sink(StaticResource(os.path.join(__file__, "..", "..", "static")))
if config.OCSP_SUBNETS:
from .ocsp import OCSPResource
app.add_sink(OCSPResource(authority), prefix="/api/ocsp")

View File

@ -1,7 +1,7 @@
import falcon
import logging
import re
from xattr import setxattr, listxattr, removexattr
from xattr import setxattr, listxattr, removexattr, getxattr
from certidude import push
from certidude.decorators import serialize, csrf_protection
from .utils.firewall import login_required, authorize_admin, whitelist_subject
@ -21,7 +21,6 @@ class AttributeResource(object):
Return extended attributes stored on the server.
This not only contains tags and lease information,
but might also contain some other sensitive information.
Results made available only to lease IP address.
"""
try:
path, buf, cert, attribs = self.authority.get_attributes(cn,
@ -44,14 +43,22 @@ class AttributeResource(object):
if not re.match("[a-z0-9_\.]+$", key):
raise falcon.HTTPBadRequest("Invalid key %s" % key)
valid = set()
modified = False
for key, value in req.params.items():
identifier = ("user.%s.%s" % (self.namespace, key)).encode("ascii")
try:
if getxattr(path, identifier).decode("utf-8") != value:
modified = True
except OSError: # no such attribute
pass
setxattr(path, identifier, value.encode("utf-8"))
valid.add(identifier)
for key in listxattr(path):
if not key.startswith(namespace):
continue
if key not in valid:
modified = True
removexattr(path, key)
if modified:
push.publish("attribute-update", cn)

View File

@ -33,9 +33,9 @@ class LeaseResource(AuthorityHandler):
@authorize_server
def on_post(self, req, resp):
client_common_name = req.get_param("client", required=True)
m = re.match("CN=(.+?),", client_common_name) # It's actually DN, resolve it to CN
m = re.match("^(.*, )*CN=(.+?)(, .*)*$", client_common_name) # It's actually DN, resolve it to CN
if m:
client_common_name, = m.groups()
_, client_common_name, _ = m.groups()
path, buf, cert, signed, expires = self.authority.get_signed(client_common_name) # TODO: catch exceptions
if req.get_param("serial") and cert.serial_number != req.get_param_as_int("serial"): # OCSP-ish solution for OpenVPN, not exposed for StrongSwan

View File

@ -140,7 +140,7 @@ class RequestListResource(AuthorityHandler):
resp.set_header("Content-Type", "application/x-pem-file")
_, resp.body = self.authority._sign(csr, body,
overwrite=overwrite_allowed, profile=config.PROFILES["rw"])
logger.info("Autosigned %s as %s is whitelisted", common_name, req.context.get("remote_addr"))
logger.info("Signed %s as %s is whitelisted for autosign", common_name, req.context.get("remote_addr"))
return
except EnvironmentError:
logger.info("Autosign for %s from %s failed, signed certificate already exists",
@ -148,7 +148,7 @@ class RequestListResource(AuthorityHandler):
reasons.append("autosign failed, signed certificate already exists")
break
else:
reasons.append("autosign failed, IP address not whitelisted")
reasons.append("IP address not whitelisted for autosign")
else:
reasons.append("autosign not requested")
@ -170,7 +170,7 @@ class RequestListResource(AuthorityHandler):
push.publish("request-submitted", common_name)
# Wait the certificate to be signed if waiting is requested
logger.info("Stored signing request %s from %s, reasons: %s", common_name, req.context.get("remote_addr"), reasons)
logger.info("Signing request %s from %s put on hold, %s", common_name, req.context.get("remote_addr"), ", ".join(reasons))
if req.get_param("wait"):
# Redirect to nginx pub/sub
@ -178,7 +178,6 @@ class RequestListResource(AuthorityHandler):
click.echo("Redirecting to: %s" % url)
resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url)
logger.debug("Redirecting signing request from %s to %s, reasons: %s", req.context.get("remote_addr"), url, ", ".join(reasons))
else:
# Request was accepted, but not processed
resp.status = falcon.HTTP_202
@ -256,7 +255,7 @@ class RequestDetailResource(AuthorityHandler):
@authorize_admin
def on_delete(self, req, resp, cn):
try:
self.authority.delete_request(cn)
self.authority.delete_request(cn, user=req.context.get("user"))
# Logging implemented in the function above
except errors.RequestDoesNotExist as e:
resp.body = "No certificate signing request for %s found" % cn

View File

@ -20,13 +20,6 @@ class RevocationListResource(AuthorityHandler):
logger.debug("Serving revocation list (DER) to %s", req.context.get("remote_addr"))
resp.body = self.authority.export_crl(pem=False)
elif req.client_accepts("application/x-pem-file"):
if req.get_param_as_bool("wait"):
url = config.LONG_POLL_SUBSCRIBE % "crl"
resp.status = falcon.HTTP_SEE_OTHER
resp.set_header("Location", url)
logger.debug("Redirecting to CRL request to %s", url)
resp.body = "Redirecting to %s" % url
else:
resp.set_header("Content-Type", "application/x-pem-file")
resp.append_header(
"Content-Disposition",

178
certidude/api/session.py Normal file
View File

@ -0,0 +1,178 @@
from datetime import datetime
from xattr import listxattr, getxattr
import falcon
import hashlib
import logging
from certidude import const, config
from certidude.common import cert_to_dn
from certidude.decorators import serialize, csrf_protection
from certidude.user import User
from .utils import AuthorityHandler
from .utils.firewall import login_required, authorize_admin
logger = logging.getLogger(__name__)
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=%s.crt" %
const.HOSTNAME.encode("ascii"))
class SessionResource(AuthorityHandler):
@csrf_protection
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
def serialize_requests(g):
for common_name, path, buf, req, submitted, server in g():
try:
submission_address = getxattr(path, "user.request.address").decode("ascii") # TODO: move to authority.py
except IOError:
submission_address = None
try:
submission_hostname = getxattr(path, "user.request.hostname").decode("ascii") # TODO: move to authority.py
except IOError:
submission_hostname = None
yield dict(
submitted = submitted,
common_name = common_name,
address = submission_address,
hostname = submission_hostname if submission_hostname != submission_address else None,
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),
sha256sum = hashlib.sha256(buf).hexdigest(),
sha512sum = hashlib.sha512(buf).hexdigest()
)
def serialize_revoked(g):
for common_name, path, buf, cert, signed, expired, revoked, reason in g(limit=5):
yield dict(
serial = "%x" % cert.serial_number,
common_name = common_name,
# TODO: key type, key length, key exponent, key modulo
signed = signed,
expired = expired,
revoked = revoked,
reason = reason,
sha256sum = hashlib.sha256(buf).hexdigest())
def serialize_certificates(g):
for common_name, path, buf, cert, signed, expires in g():
# Extract certificate tags from filesystem
try:
tags = []
for tag in getxattr(path, "user.xdg.tags").decode("utf-8").split(","):
if "=" in tag:
k, v = tag.split("=", 1)
else:
k, v = "other", tag
tags.append(dict(id=tag, key=k, value=v))
except IOError: # No such attribute(s)
tags = None
attributes = {}
for key in listxattr(path):
if key.startswith(b"user.machine."):
attributes[key[13:].decode("ascii")] = getxattr(path, key).decode("ascii")
# Extract lease information from filesystem
try:
last_seen = datetime.strptime(getxattr(path, "user.lease.last_seen").decode("ascii"), "%Y-%m-%dT%H:%M:%S.%fZ")
lease = dict(
inner_address = getxattr(path, "user.lease.inner_address").decode("ascii"),
outer_address = getxattr(path, "user.lease.outer_address").decode("ascii"),
last_seen = last_seen,
age = datetime.utcnow() - last_seen
)
except IOError: # No such attribute(s)
lease = None
try:
signer_username = getxattr(path, "user.signature.username").decode("ascii")
except IOError:
signer_username = None
# TODO: dedup
yield dict(
serial = "%x" % cert.serial_number,
organizational_unit = cert.subject.native.get("organizational_unit_name"),
common_name = common_name,
# TODO: key type, key length, key exponent, key modulo
signed = signed,
expires = expires,
sha256sum = hashlib.sha256(buf).hexdigest(),
signer = signer_username,
lease = lease,
tags = tags,
attributes = attributes or None,
extensions = dict([
(e["extn_id"].native, e["extn_value"].native)
for e in cert["tbs_certificate"]["extensions"]
if e["extn_id"].native in ("extended_key_usage",)])
)
logger.info("Logged in authority administrator %s from %s with %s" % (
req.context.get("user"), req.context.get("remote_addr"), req.context.get("user_agent")))
return dict(
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 = config.REQUEST_SUBMISSION_ALLOWED,
service = dict(
protocols = config.SERVICE_PROTOCOLS,
routers = [j[0] for j in self.authority.list_signed(
common_name=config.SERVICE_ROUTERS)]
),
authority = dict(
builder = dict(
profiles = config.IMAGE_BUILDER_PROFILES
),
tagging = [dict(name=t[0], type=t[1], title=t[2]) for t in config.TAG_TYPES],
lease = dict(
offline = 600, # Seconds from last seen activity to consider lease offline, OpenVPN reneg-sec option
dead = 604800 # Seconds from last activity to consider lease dead, X509 chain broken or machine discarded
),
certificate = dict(
algorithm = self.authority.public_key.algorithm,
common_name = self.authority.certificate.subject.native["common_name"],
distinguished_name = cert_to_dn(self.authority.certificate),
md5sum = hashlib.md5(self.authority.certificate_buf).hexdigest(),
blob = self.authority.certificate_buf.decode("ascii"),
),
mailer = dict(
name = config.MAILER_NAME,
address = config.MAILER_ADDRESS
) if config.MAILER_ADDRESS else None,
machine_enrollment_subnets=config.MACHINE_ENROLLMENT_SUBNETS,
user_enrollment_allowed=config.USER_ENROLLMENT_ALLOWED,
user_multiple_certificates=config.USER_MULTIPLE_CERTIFICATES,
events = config.EVENT_SOURCE_SUBSCRIBE % config.EVENT_SOURCE_TOKEN,
requests=serialize_requests(self.authority.list_requests),
signed=serialize_certificates(self.authority.list_signed),
revoked=serialize_revoked(self.authority.list_revoked),
admin_users = User.objects.filter_admins(),
user_subnets = config.USER_SUBNETS or None,
autosign_subnets = config.AUTOSIGN_SUBNETS or None,
request_subnets = config.REQUEST_SUBNETS or None,
admin_subnets=config.ADMIN_SUBNETS or None,
signature = dict(
revocation_list_lifetime=config.REVOCATION_LIST_LIFETIME,
profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")),
)
),
features=dict(
ocsp=bool(config.OCSP_SUBNETS),
crl=bool(config.CRL_SUBNETS),
token=bool(config.TOKEN_URL),
tagging=True,
leases=True,
logging=config.LOGGING_BACKEND)
)

View File

@ -68,8 +68,8 @@ class SignedCertificateDetailResource(AuthorityHandler):
@login_required
@authorize_admin
def on_delete(self, req, resp, cn):
logger.info("Revoked certificate %s by %s from %s",
cn, req.context.get("user"), req.context.get("remote_addr"))
self.authority.revoke(cn,
reason=req.get_param("reason", default="key_compromise"))
reason=req.get_param("reason", default="key_compromise"),
user=req.context.get("user")
)

View File

@ -41,7 +41,7 @@ class TagResource(AuthorityHandler):
else:
tags.add("%s=%s" % (key,value))
setxattr(path, "user.xdg.tags", ",".join(tags).encode("utf-8"))
logger.debug("Tag %s=%s set for %s" % (key, value, cn))
logger.info("Tag %s=%s set for %s by %s" % (key, value, cn, req.context.get("user")))
push.publish("tag-update", cn)
@ -68,7 +68,7 @@ class TagDetailResource(object):
else:
tags.add(value)
setxattr(path, "user.xdg.tags", ",".join(tags).encode("utf-8"))
logger.debug("Tag %s set to %s for %s" % (tag, value, cn))
logger.info("Tag %s set to %s for %s by %s" % (tag, value, cn, req.context.get("user")))
push.publish("tag-update", cn)
@csrf_protection
@ -82,5 +82,5 @@ class TagDetailResource(object):
removexattr(path, "user.xdg.tags")
else:
setxattr(path, "user.xdg.tags", ",".join(tags))
logger.debug("Tag %s removed for %s" % (tag, cn))
logger.info("Tag %s removed for %s by %s" % (tag, cn, req.context.get("user")))
push.publish("tag-update", cn)

View File

@ -4,15 +4,17 @@ import logging
import binascii
import click
import gssapi
import ldap
import os
import re
import simplepam
import socket
from asn1crypto import pem, x509
from base64 import b64decode
from certidude.user import User
from certidude import config, const
logger = logging.getLogger("api")
logger = logging.getLogger(__name__)
def whitelist_subnets(subnets):
"""
@ -81,18 +83,34 @@ def whitelist_subject(func):
def authenticate(optional=False):
def wrapper(func):
def kerberos_authenticate(resource, req, resp, *args, **kwargs):
# Try pre-emptive authentication
if not req.auth:
if optional:
def wrapped(resource, req, resp, *args, **kwargs):
kerberized = False
if "kerberos" in config.AUTHENTICATION_BACKENDS:
for subnet in config.KERBEROS_SUBNETS:
if req.context.get("remote_addr") in subnet:
kerberized = True
if not req.auth: # no credentials provided
if optional: # optional allowed
req.context["user"] = None
return func(resource, req, resp, *args, **kwargs)
if kerberized:
logger.debug("No Kerberos ticket offered while attempting to access %s from %s",
req.env["PATH_INFO"], req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Unauthorized",
"No Kerberos ticket offered, are you sure you've logged in with domain user account?",
["Negotiate"])
else:
logger.debug("No credentials offered while attempting to access %s from %s",
req.env["PATH_INFO"], req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Unauthorized", "Please authenticate", ("Basic",))
if kerberized:
if not req.auth.startswith("Negotiate "):
raise falcon.HTTPBadRequest("Bad request",
"Bad header, expected Negotiate: %s" % req.auth)
os.environ["KRB5_KTNAME"] = config.KERBEROS_KEYTAB
@ -107,9 +125,6 @@ def authenticate(optional=False):
context = gssapi.sec_contexts.SecurityContext(creds=server_creds)
if not req.auth.startswith("Negotiate "):
raise falcon.HTTPBadRequest("Bad request", "Bad header, expected Negotiate: %s" % req.auth)
token = ''.join(req.auth.split()[1:])
try:
@ -141,30 +156,21 @@ def authenticate(optional=False):
req.context["user"], req.env["PATH_INFO"], req.context["remote_addr"])
return func(resource, req, resp, *args, **kwargs)
def ldap_authenticate(resource, req, resp, *args, **kwargs):
"""
Authenticate against LDAP with WWW Basic Auth credentials
"""
if optional and not req.get_param_as_bool("authenticate"):
return func(resource, req, resp, *args, **kwargs)
import ldap
if not req.auth:
raise falcon.HTTPUnauthorized("Unauthorized",
"No authentication header provided",
("Basic",))
else:
if not req.auth.startswith("Basic "):
raise falcon.HTTPBadRequest("Bad request", "Bad header, expected Basic: %s" % req.auth)
from base64 import b64decode
basic, token = req.auth.split(" ", 1)
user, passwd = b64decode(token).decode("ascii").split(":", 1)
upn = "%s@%s" % (user, const.DOMAIN)
if config.AUTHENTICATION_BACKENDS == {"pam"}:
if not simplepam.authenticate(user, passwd, "sshd"):
logger.critical("Basic authentication failed for user %s from %s, "
"are you sure server process has read access to /etc/shadow?",
repr(user), req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Forbidden", "Invalid password", ("Basic",))
conn = None
elif "ldap" in config.AUTHENTICATION_BACKENDS:
upn = "%s@%s" % (user, config.KERBEROS_REALM)
click.echo("Connecting to %s as %s" % (config.LDAP_AUTHENTICATION_URI, upn))
conn = ldap.initialize(config.LDAP_AUTHENTICATION_URI, bytes_mode=False)
conn.set_option(ldap.OPT_REFERRALS, 0)
@ -186,53 +192,18 @@ def authenticate(optional=False):
("Basic",))
req.context["ldap_conn"] = conn
else:
raise NotImplementedError("No suitable authentication method configured")
try:
req.context["user"] = User.objects.get(user)
except User.DoesNotExist:
raise falcon.HTTPUnauthorized("Unauthorized", "Invalid credentials", ("Basic",))
retval = func(resource, req, resp, *args, **kwargs)
if conn:
conn.unbind_s()
return retval
def pam_authenticate(resource, req, resp, *args, **kwargs):
"""
Authenticate against PAM with WWW Basic Auth credentials
"""
if optional and not req.get_param_as_bool("authenticate"):
return func(resource, req, resp, *args, **kwargs)
if not req.auth:
raise falcon.HTTPUnauthorized("Forbidden", "Please authenticate", ("Basic",))
if not req.auth.startswith("Basic "):
raise falcon.HTTPBadRequest("Bad request", "Bad header: %s" % req.auth)
basic, token = req.auth.split(" ", 1)
user, passwd = b64decode(token).decode("ascii").split(":", 1)
import simplepam
if not simplepam.authenticate(user, passwd, "sshd"):
logger.critical("Basic authentication failed for user %s from %s, "
"are you sure server process has read access to /etc/shadow?",
repr(user), req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Forbidden", "Invalid password", ("Basic",))
req.context["user"] = User.objects.get(user)
return func(resource, req, resp, *args, **kwargs)
def wrapped(resource, req, resp, *args, **kwargs):
# If LDAP enabled and device is not Kerberos capable fall
# back to LDAP bind authentication
if "ldap" in config.AUTHENTICATION_BACKENDS:
if "Android" in req.user_agent or "iPhone" in req.user_agent:
return ldap_authenticate(resource, req, resp, *args, **kwargs)
if "kerberos" in config.AUTHENTICATION_BACKENDS:
return kerberos_authenticate(resource, req, resp, *args, **kwargs)
elif config.AUTHENTICATION_BACKENDS == {"pam"}:
return pam_authenticate(resource, req, resp, *args, **kwargs)
elif config.AUTHENTICATION_BACKENDS == {"ldap"}:
return ldap_authenticate(resource, req, resp, *args, **kwargs)
else:
raise NotImplementedError("Authentication backend %s not supported" % config.AUTHENTICATION_BACKENDS)
return wrapped
return wrapper
@ -247,7 +218,6 @@ def authorize_admin(func):
@whitelist_subnets(config.ADMIN_SUBNETS)
def wrapped(resource, req, resp, *args, **kwargs):
if req.context.get("user").is_admin():
req.context["admin_authorized"] = True
return func(resource, req, resp, *args, **kwargs)
logger.info("User '%s' not authorized to access administrative API", req.context.get("user").name)
raise falcon.HTTPForbidden("Forbidden", "User not authorized to perform administrative operations")

View File

@ -1,5 +1,6 @@
from __future__ import division, absolute_import, print_function
import click
import logging
import os
import re
import requests
@ -20,6 +21,7 @@ from jinja2 import Template
from random import SystemRandom
from xattr import getxattr, listxattr, setxattr
logger = logging.getLogger(__name__)
random = SystemRandom()
try:
@ -214,7 +216,7 @@ def store_request(buf, overwrite=False, address="", user=""):
return request_path, csr, common_name
def revoke(common_name, reason):
def revoke(common_name, reason, user="root"):
"""
Revoke valid certificate
"""
@ -228,18 +230,13 @@ def revoke(common_name, reason):
setxattr(signed_path, "user.revocation.reason", reason)
revoked_path = os.path.join(config.REVOKED_DIR, "%040x.pem" % cert.serial_number)
logger.info("Revoked certificate %s by %s", common_name, user)
os.unlink(os.path.join(config.SIGNED_BY_SERIAL_DIR, "%040x.pem" % cert.serial_number))
os.rename(signed_path, revoked_path)
push.publish("certificate-revoked", common_name)
# Publish CRL for long polls
url = config.LONG_POLL_PUBLISH % "crl"
click.echo("Publishing CRL at %s ..." % url)
requests.post(url, data=export_crl(),
headers={"User-Agent": "Certidude API", "Content-Type": "application/x-pem-file"})
attach_cert = buf, "application/x-pem-file", common_name + ".crt"
mailer.send("certificate-revoked.md",
attachments=(attach_cert,),
@ -334,7 +331,7 @@ def export_crl(pem=True):
return certificate_list.dump()
def delete_request(common_name):
def delete_request(common_name, user="root"):
# Validate CN
if not re.match(const.RE_COMMON_NAME, common_name):
raise ValueError("Invalid common name")
@ -342,6 +339,9 @@ def delete_request(common_name):
path, buf, csr, submitted = get_request(common_name)
os.unlink(path)
logger.info("Rejected signing request %s by %s" % (
common_name, user))
# Publish event at CA channel
push.publish("request-deleted", common_name)
@ -350,7 +350,7 @@ def delete_request(common_name):
config.LONG_POLL_PUBLISH % hashlib.sha256(buf).hexdigest(),
headers={"User-Agent": "Certidude API"})
def sign(common_name, profile, skip_notify=False, skip_push=False, overwrite=False, signer=None):
def sign(common_name, profile, skip_notify=False, skip_push=False, overwrite=False, signer="root"):
"""
Sign certificate signing request by it's common name
"""

View File

@ -1018,26 +1018,28 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat
click.echo("Not attempting to install packages from APT as requested...")
else:
click.echo("Installing packages...")
os.system("DEBIAN_FRONTEND=noninteractive apt-get install -qq -y \
cython3 python3-dev python3-mimeparse \
cmd = "DEBIAN_FRONTEND=noninteractive apt-get install -qq -y \
cython3 python3-dev \
python3-markdown python3-pyxattr python3-jinja2 python3-cffi \
software-properties-common libsasl2-modules-gssapi-mit npm nodejs \
libkrb5-dev libldap2-dev libsasl2-dev gawk libncurses5-dev \
rsync attr wget unzip")
os.system("pip3 install -q --upgrade gssapi falcon humanize ipaddress simplepam")
os.system("pip3 install -q --pre --upgrade python-ldap")
rsync attr wget unzip"
click.echo("Running: %s" % cmd)
if os.system(cmd): sys.exit(254)
if os.system("pip3 install -q --upgrade gssapi falcon humanize ipaddress simplepam user-agents"): sys.exit(253)
if os.system("pip3 install -q --pre --upgrade python-ldap"): exit(252)
if not os.path.exists("/usr/lib/nginx/modules/ngx_nchan_module.so"):
click.echo("Enabling nginx PPA")
os.system("add-apt-repository -y ppa:nginx/stable")
os.system("apt-get update -q")
os.system("apt-get install -y -q libnginx-mod-nchan")
if os.system("add-apt-repository -y ppa:nginx/stable"): sys.exit(251)
if os.system("apt-get update -q"): sys.exit(250)
if os.system("apt-get install -y -q libnginx-mod-nchan"): sys.exit(249)
else:
click.echo("PPA for nginx already enabled")
if not os.path.exists("/usr/sbin/nginx"):
click.echo("Installing nginx from PPA")
os.system("apt-get install -y -q nginx")
if os.system("apt-get install -y -q nginx"): sys.exit(248)
else:
click.echo("Web server nginx already installed")
@ -1160,16 +1162,16 @@ def certidude_setup_authority(username, kerberos_keytab, nginx_config, organizat
else:
cmd = "npm install --silent -g nunjucks@2.5.2 nunjucks-date@1.2.0 node-forge bootstrap@4.0.0-alpha.6 jquery timeago tether font-awesome qrcode-svg"
click.echo("Installing JavaScript packages: %s" % cmd)
assert os.system(cmd) == 0
if os.system(cmd): sys.exit(230)
# Copy fonts
click.echo("Copying fonts...")
os.system("rsync -avq /usr/local/lib/node_modules/font-awesome/fonts/ %s/fonts/" % assets_dir)
if os.system("rsync -avq /usr/local/lib/node_modules/font-awesome/fonts/ %s/fonts/" % assets_dir): sys.exit(229)
# Compile nunjucks templates
cmd = 'nunjucks-precompile --include ".html$" --include ".ps1$" --include ".sh$" --include ".svg" %s > %s.part' % (static_path, bundle_js)
click.echo("Compiling templates: %s" % cmd)
assert os.system(cmd) == 0
if os.system(cmd): sys.exit(228)
# Assemble bundle.js
click.echo("Assembling %s" % bundle_js)

View File

@ -44,6 +44,8 @@ OVERWRITE_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "overwrite subnets").split(" ") if j])
MACHINE_ENROLLMENT_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "machine enrollment subnets").split(" ") if j])
KERBEROS_SUBNETS = set([ipaddress.ip_network(j) for j in
cp.get("authorization", "kerberos subnets").split(" ") if j])
AUTHORITY_DIR = "/var/lib/certidude"
AUTHORITY_PRIVATE_KEY_PATH = cp.get("authority", "private key path")

View File

@ -1,4 +1,14 @@
@keyframes fresh {
from { background-color: #ffc107; }
to { background-color: white; }
}
.fresh {
animation-name: fresh;
animation-duration: 30s;
}
.loader-container {
margin: 20% auto 0 auto;
text-align: center;

View File

@ -15,15 +15,21 @@
<button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="#">Certidude</a>
<a class="navbar-brand" href="#columns=2">Certidude</a>
<div class="collapse navbar-collapse" id="navbarsExampleDefault">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link disabled dashboard" href="#">Dashboard</a>
<a class="nav-link disabled dashboard" href="#columns=2">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link disabled log" href="#">Log</a>
<a class="nav-link" href="#columns=3">Wider</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#columns=4">Widest</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Log</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0">

View File

@ -74,6 +74,7 @@ function onTagClicked(tag) {
}
});
}
return false;
}
function onNewTagClicked(menu) {
@ -110,6 +111,7 @@ function onTagFilterChanged() {
function onLogEntry (e) {
if (e.data) {
e = JSON.parse(e.data);
e.fresh = true;
}
if ($("#log-level-" + e.severity).prop("checked")) {
@ -117,7 +119,8 @@ function onLogEntry (e) {
entry: {
created: new Date(e.created).toLocaleString(),
message: e.message,
severity: e.severity
severity: e.severity,
fresh: e.fresh,
}
}));
}
@ -262,7 +265,7 @@ function onServerStarted() {
}
function onServerStopped() {
$("view").html('<div class="loader"></div><p>Server under maintenance</p>');
$("#view-dashboard").html('<div class="loader"></div><p>Server under maintenance</p>');
console.info("Server stopped");
}

View File

@ -0,0 +1,25 @@
pip3 install git+https://github.com/laurivosandi/certidude/
mkdir -p /etc/certidude/{client.conf.d,services.conf.d}
cat << EOF > /etc/certidude/client.conf.d/{{ authority_name }}.conf
[{{ authority_name }}]
trigger = interface up
common name = $HOSTNAME
system wide = true
EOF
cat << EOF > /etc/certidude/services.conf.d/{{ authority_name }}.conf
{% for router in session.service.routers %}{% if "ikev2" in session.service.protocols %}
[IPSec to {{ router }}]
authority = {{ authority_name }}
service = network-manager/strongswan
remote = {{ router }}
{% endif %}{% if "openvpn" in session.service.protocols %}
[OpenVPN to {{ router }}]
authority = {{ authority_name }}
service = network-manager/openvpn
remote = {{ router }}
{% endif %}{% endfor %}
EOF
certidude enroll

View File

@ -7,6 +7,12 @@
</div>
<form action="/api/request/" method="post">
<div class="modal-body">
<h5>Certidude client</h5>
<p>On Ubuntu or Fedora:</p>
<div class="highlight">
<pre class="code"><code>{% include "snippets/certidude-client.sh" %}</code></pre>
</div>
{% if "ikev2" in session.service.protocols %}
<h5>Windows {% if session.authority.certificate.algorithm == "ec" %}10{% else %}7 and up{% endif %}</h5>
<p>On Windows execute following PowerShell script</p>
@ -190,6 +196,8 @@ curl http://{{authority_name}}/api/revoked/?wait=yes -L -H "Accept: application/
{% endfor %}.
{% endif %}
See <a href="#request_submission_modal" data-toggle="modal">here</a> for more information on manual signing request upload.
{% if session.authority.autosign_subnets %}
{% if "0.0.0.0/0" in session.authority.autosign_subnets %}
All requests are automatically signed.
@ -202,17 +210,16 @@ curl http://{{authority_name}}/api/revoked/?wait=yes -L -H "Accept: application/
{% endif %}
{% endif %}
</p>
{% if columns >= 3 %}
</div>
<div class="col-sm-{{ column_width }}">
{% endif %}
<div id="pending_requests">
{% for request in session.authority.requests | sort(attribute="submitted", reverse=true) %}
{% include "views/request.html" %}
{% endfor %}
</div>
<p><h1>Revoked certificates</h1></p>
{% if columns >= 3 %}
</div>
<div class="col-sm-{{ column_width }}">
{% endif %}
<h1>Revoked certificates</h1>
<p>Following certificates have been revoked{% if session.features.crl %}, for more information click
<a href="#revocation_list_modal" data-toggle="modal">here</a>{% endif %}.</p>

View File

@ -1,4 +1,4 @@
<i class="fa fa-circle" style="color:{% if certificate.lease.age > 172800 %}#d9534f{% else %}{% if certificate.lease.age > 7200 %}#0275d8{% else %}#5cb85c{% endif %}{% endif %};"/>
<i class="fa fa-circle" style="color:{% if certificate.lease.age > 172800 %}#d9534f{% else %}{% if certificate.lease.age > 10800 %}#0275d8{% else %}#5cb85c{% endif %}{% endif %};"/>
Last seen
<time class="timeago" datetime="{{ certificate.lease.last_seen }}">{{ certificate.lease.last_seen }}</time>
at

View File

@ -1,4 +1,4 @@
<li id="log_entry_{{ entry.id }}" class="list-group-item justify-content-between filterable">
<li id="log_entry_{{ entry.id }}" class="list-group-item justify-content-between filterable{% if entry.fresh %} fresh{% endif %}">
<span>
<i class="fa fa-{{ entry.severity }}-circle"/>
{{ entry.message }}

View File

@ -4,14 +4,15 @@
# sshd PAM service. In case of 'kerberos' SPNEGO is used to authenticate
# user against eg. Active Directory or Samba4.
{% if realm %}
;backends = pam
backends = kerberos
{% else %}
backends = pam
;backends = kerberos
{% endif %}
;backends = ldap
;backends = kerberos
{% if realm %}
backends = kerberos ldap
;backends = pam
{% else %}
;backends = kerberos ldap
backends = pam
{% endif %}
kerberos keytab = FILE:{{ kerberos_keytab }}
{% if realm %}
@ -103,9 +104,6 @@ admin whitelist =
# Users are allowed to log in from user subnets
user subnets = 0.0.0.0/0
# Authority administrators are allowed to sign and revoke certificates from these subnets
admin subnets = 0.0.0.0/0
# Certificate signing requests are allowed to be submitted from these subnets
request subnets = 0.0.0.0/0
@ -135,6 +133,14 @@ renewal subnets =
overwrite subnets =
;overwrite subnets = 0.0.0.0/0
# Which subnets are offered Kerberos authentication, eg.
# subnet for Windows workstations or slice of VPN subnet where
# workstations are assigned to
kerberos subnets = 0.0.0.0
;kerberos subnets =
# Source subnets of Kerberos authenticated machines which are automatically
# allowed to enroll with CSR whose common name is set to machine's account name.
# Note that overwriting is not allowed by default, see 'overwrite subnets'
@ -142,6 +148,13 @@ overwrite subnets =
machine enrollment subnets =
;machine enrollment subnets = 0.0.0.0/0
# Authenticated users belonging to administrative LDAP or POSIX group
# are allowed to sign and revoke certificates from these subnets
admin subnets = 0.0.0.0/0
;admin subnets = 172.20.7.0/24 172.20.8.5
[logging]
# Disable logging
backend =

View File

@ -169,6 +169,12 @@ def test_cli_setup_authority():
if not os.path.exists("/etc/pki/ca-trust/source/anchors/"):
os.makedirs("/etc/pki/ca-trust/source/anchors/")
if not os.path.exists("/bin/systemctl"):
with open("/usr/bin/systemctl", "w") as fh:
fh.write("#!/bin/bash\n")
fh.write("service $2 $1\n")
os.chmod("/usr/bin/systemctl", 0o755)
# Back up original DNS server
if not os.path.exists("/etc/resolv.conf.orig"):
shutil.copyfile("/etc/resolv.conf", "/etc/resolv.conf.orig")
@ -205,7 +211,7 @@ def test_cli_setup_authority():
assert const.HOSTNAME == "ca"
assert const.DOMAIN == "example.lan"
os.system("certidude setup authority --elliptic-curve")
assert os.system("certidude setup authority --elliptic-curve") == 0
assert_cleanliness()
@ -289,13 +295,7 @@ def test_cli_setup_authority():
assert r.status_code == 400, r.text
r = client().simulate_get("/")
assert r.status_code == 200, r.text
r = client().simulate_get("/index.html")
assert r.status_code == 200, r.text
r = client().simulate_get("/nonexistant.html")
assert r.status_code == 404, r.text
r = client().simulate_get("/../nonexistant.html")
assert r.status_code == 400, r.text
assert r.status_code == 404, r.text # backend doesn't serve static
# Test request submission
buf = generate_csr(cn="test")
@ -440,11 +440,6 @@ def test_cli_setup_authority():
headers={"Accept":"text/plain"})
assert r.status_code == 415, r.text
r = client().simulate_get("/api/revoked/",
query_string="wait=true",
headers={"Accept":"application/x-pem-file"})
assert r.status_code == 303, r.text
# Test attribute fetching API call
r = client().simulate_get("/api/signed/test/attr/")
assert r.status_code == 401, r.text
@ -1114,22 +1109,23 @@ def test_cli_setup_authority():
# Bootstrap authority
assert not os.path.exists("/var/lib/certidude/ca.example.lan/ca_key.pem")
os.system("certidude setup authority --skip-packages")
assert os.system("certidude setup authority --skip-packages") == 0
# Make modifications to /etc/certidude/server.conf so
# Certidude would auth against domain controller
os.system("sed -e 's/ldap uri = ldaps:.*/ldap uri = ldaps:\\/\\/ca.example.lan/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/ldap uri = ldap:.*/ldap uri = ldap:\\/\\/ca.example.lan/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/autosign subnets =.*/autosign subnets =/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/machine enrollment subnets =.*/machine enrollment subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/scep subnets =.*/scep subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/ocsp subnets =.*/ocsp subnets =/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/crl subnets =.*/crl subnets =/g' -i /etc/certidude/server.conf")
os.system("sed -e 's/address = certificates@example.lan/address =/g' -i /etc/certidude/server.conf")
assert os.system("sed -e 's/ldap uri = ldaps:.*/ldap uri = ldaps:\\/\\/ca.example.lan/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/ldap uri = ldap:.*/ldap uri = ldap:\\/\\/ca.example.lan/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/autosign subnets =.*/autosign subnets =/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/machine enrollment subnets =.*/machine enrollment subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/scep subnets =.*/scep subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/ocsp subnets =.*/ocsp subnets =/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/crl subnets =.*/crl subnets =/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/address = certificates@example.lan/address =/g' -i /etc/certidude/server.conf") == 0
assert os.system("sed -e 's/kerberos subnets =.*/kerberos subnets = 0.0.0.0\\/0/g' -i /etc/certidude/server.conf") == 0
# Update server credential cache
os.system("sed -e 's/dc1/ca/g' -i /etc/cron.hourly/certidude")
assert os.system("sed -e 's/dc1/ca/g' -i /etc/cron.hourly/certidude") == 0
with open("/etc/cron.hourly/certidude") as fh:
cronjob = fh.read()
assert "ldap/ca.example.lan" in cronjob, cronjob