1
0
mirror of https://github.com/laurivosandi/certidude synced 2025-10-30 08:59:13 +00:00

Several updates #2

* Reverse RDN components for all certs
* Less side effects in unittests
* Split help dialog shell snippets into separate files
* Restore 'admin subnets' config option
* Embedded subnets, IKE and ESP proposals now configurable in builder.conf
* Use expr instead of bc for math operations in shell
* Better frontend support for Let's Encrypt certificates
This commit is contained in:
2018-05-02 08:11:01 +00:00
parent 5e9251f365
commit 4e4b551cc2
49 changed files with 959 additions and 1051 deletions

View File

@@ -7,12 +7,12 @@ import os
import hashlib
from datetime import datetime
from xattr import listxattr, getxattr
from certidude.auth import login_required
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__)
@@ -30,6 +30,7 @@ class SessionResource(AuthorityHandler):
@csrf_protection
@serialize
@login_required
@authorize_admin
def on_get(self, req, resp):
def serialize_requests(g):
@@ -43,7 +44,6 @@ class SessionResource(AuthorityHandler):
except IOError:
submission_hostname = None
yield dict(
server = self.authority.server_flags(common_name),
submitted = submitted,
common_name = common_name,
address = submission_address,
@@ -55,7 +55,7 @@ class SessionResource(AuthorityHandler):
)
def serialize_revoked(g):
for common_name, path, buf, cert, signed, expired, revoked, reason in 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,
@@ -121,10 +121,7 @@ class SessionResource(AuthorityHandler):
if e["extn_id"].native in ("extended_key_usage",)])
)
if req.context.get("user").is_admin():
logger.info("Logged in authority administrator %s from %s" % (req.context.get("user"), req.context.get("remote_addr")))
else:
logger.info("Logged in authority user %s from %s" % (req.context.get("user"), req.context.get("remote_addr")))
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,
@@ -175,14 +172,15 @@ class SessionResource(AuthorityHandler):
profiles = sorted([p.serialize() for p in config.PROFILES.values()], key=lambda p:p.get("slug")),
)
) if req.context.get("user").is_admin() else None,
),
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))
logging=config.LOGGING_BACKEND)
)
class StaticResource(object):

View File

@@ -4,9 +4,7 @@ import re
from xattr import setxattr, listxattr, removexattr
from certidude import push
from certidude.decorators import serialize, csrf_protection
from certidude.auth import login_required, authorize_admin
from .utils.firewall import whitelist_subject
from .utils.firewall import login_required, authorize_admin, whitelist_subject
logger = logging.getLogger(__name__)

View File

@@ -5,9 +5,10 @@ import logging
import os
import subprocess
from certidude import config, const, authority
from certidude.auth import login_required, authorize_admin
from certidude.common import cert_to_dn
from ipaddress import ip_network
from jinja2 import Template
from .utils.firewall import login_required, authorize_admin
logger = logging.getLogger(__name__)
@@ -17,6 +18,7 @@ class ImageBuilderResource(object):
def on_get(self, req, resp, profile, suggested_filename):
router = [j[0] for j in authority.list_signed(
common_name=config.cp2.get(profile, "router"))][0]
subnets = set([ip_network(j) for j in config.cp2.get(profile, "subnets").split(" ")])
model = config.cp2.get(profile, "model")
build_script_path = config.cp2.get(profile, "command")
overlay_path = config.cp2.get(profile, "overlay")
@@ -40,6 +42,9 @@ class ImageBuilderResource(object):
cwd=os.path.dirname(os.path.realpath(build_script_path)),
env={"PROFILE": model, "PATH":"/usr/sbin:/usr/bin:/sbin:/bin",
"ROUTER": router,
"IKE": config.cp2.get(profile, "ike"),
"ESP": config.cp2.get(profile, "esp"),
"SUBNETS": ",".join(str(j) for j in subnets),
"AUTHORITY_CERTIFICATE_ALGORITHM": authority.public_key.algorithm,
"AUTHORITY_CERTIFICATE_DISTINGUISHED_NAME": cert_to_dn(authority.certificate),
"BUILD":build, "OVERLAY":build + "/overlay/"},

View File

@@ -5,9 +5,9 @@ import re
import xattr
from datetime import datetime
from certidude import config, push
from certidude.auth import login_required, authorize_admin, authorize_server
from certidude.decorators import serialize
from .utils import AuthorityHandler
from .utils.firewall import login_required, authorize_admin, authorize_server
logger = logging.getLogger(__name__)

View File

@@ -1,7 +1,7 @@
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize
from certidude.relational import RelationalMixin
from .utils.firewall import login_required, authorize_admin
class LogResource(RelationalMixin):
SQL_CREATE_TABLES = "log_tables.sql"

View File

@@ -8,7 +8,6 @@ from asn1crypto import pem, x509
from asn1crypto.csr import CertificationRequest
from base64 import b64decode
from certidude import config, push, errors
from certidude.auth import login_required, login_optional, authorize_admin
from certidude.decorators import csrf_protection, MyEncoder
from certidude.profile import SignatureProfile
from datetime import datetime
@@ -16,7 +15,8 @@ from oscrypto import asymmetric
from oscrypto.errors import SignatureError
from xattr import getxattr, setxattr
from .utils import AuthorityHandler
from .utils.firewall import whitelist_subnets, whitelist_content_types
from .utils.firewall import whitelist_subnets, whitelist_content_types, \
login_required, login_optional, authorize_admin
logger = logging.getLogger(__name__)
@@ -219,7 +219,6 @@ class RequestDetailResource(AuthorityHandler):
resp.body = json.dumps(dict(
submitted = submitted,
common_name = cn,
server = self.authority.server_flags(cn),
address = getxattr(path, "user.request.address").decode("ascii"), # TODO: move to authority.py
md5sum = hashlib.md5(buf).hexdigest(),
sha1sum = hashlib.sha1(buf).hexdigest(),

View File

@@ -3,10 +3,10 @@ import falcon
import logging
import json
import hashlib
from certidude.auth import login_required, authorize_admin
from certidude.decorators import csrf_protection
from xattr import listxattr, getxattr
from .utils import AuthorityHandler
from .utils.firewall import login_required, authorize_admin
logger = logging.getLogger(__name__)

View File

@@ -1,9 +1,9 @@
import logging
from xattr import getxattr, removexattr, setxattr
from certidude import push
from certidude.auth import login_required, authorize_admin
from certidude.decorators import serialize, csrf_protection
from .utils import AuthorityHandler
from .utils.firewall import login_required, authorize_admin
logger = logging.getLogger(__name__)

View File

@@ -9,8 +9,8 @@ from certidude import mailer
from certidude.decorators import serialize
from certidude.user import User
from certidude import config
from certidude.auth import login_required, authorize_admin
from .utils import AuthorityHandler
from .utils.firewall import login_required, authorize_admin
logger = logging.getLogger(__name__)

View File

@@ -1,7 +1,16 @@
import falcon
import logging
import binascii
import click
import gssapi
import os
import re
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")
@@ -68,3 +77,200 @@ def whitelist_subject(func):
else:
return func(self, req, resp, cn, *args, **kwargs)
return wrapped
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:
req.context["user"] = None
return func(resource, req, resp, *args, **kwargs)
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"])
os.environ["KRB5_KTNAME"] = config.KERBEROS_KEYTAB
try:
server_creds = gssapi.creds.Credentials(
usage='accept',
name=gssapi.names.Name('HTTP/%s'% const.FQDN))
except gssapi.raw.exceptions.BadNameError:
logger.error("Failed initialize HTTP service principal, possibly bad permissions for %s or /etc/krb5.conf" %
config.KERBEROS_KEYTAB)
raise
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:
context.step(b64decode(token))
except binascii.Error: # base64 errors
raise falcon.HTTPBadRequest("Bad request", "Malformed token")
except gssapi.raw.exceptions.BadMechanismError:
raise falcon.HTTPBadRequest("Bad request", "Unsupported authentication mechanism (NTLM?) was offered. Please make sure you've logged into the computer with domain user account. The web interface should not prompt for username or password.")
try:
username, realm = str(context.initiator_name).split("@")
except AttributeError: # TODO: Better exception
raise falcon.HTTPForbidden("Failed to determine username, are you trying to log in with correct domain account?")
if realm != config.KERBEROS_REALM:
raise falcon.HTTPForbidden("Forbidden",
"Cross-realm trust not supported")
if username.endswith("$") and optional:
# Extract machine hostname
# TODO: Assert LDAP group membership
req.context["machine"] = username[:-1].lower()
req.context["user"] = None
else:
# Attempt to look up real user
req.context["user"] = User.objects.get(username)
logger.debug("Succesfully authenticated user %s for %s from %s",
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",))
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)
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)
try:
conn.simple_bind_s(upn, passwd)
except ldap.STRONG_AUTH_REQUIRED:
logger.critical("LDAP server demands encryption, use ldaps:// instead of ldaps://")
raise
except ldap.SERVER_DOWN:
logger.critical("Failed to connect LDAP server at %s, are you sure LDAP server's CA certificate has been copied to this machine?",
config.LDAP_AUTHENTICATION_URI)
raise
except ldap.INVALID_CREDENTIALS:
logger.critical("LDAP bind authentication failed for user %s from %s",
repr(user), req.context.get("remote_addr"))
raise falcon.HTTPUnauthorized("Forbidden",
"Please authenticate with %s domain account username" % const.DOMAIN,
("Basic",))
req.context["ldap_conn"] = conn
req.context["user"] = User.objects.get(user)
retval = func(resource, req, resp, *args, **kwargs)
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
def login_required(func):
return authenticate()(func)
def login_optional(func):
return authenticate(optional=True)(func)
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")
return wrapped
def authorize_server(func):
"""
Make sure the request originator has a certificate with server flags
"""
from asn1crypto import pem, x509
def wrapped(resource, req, resp, *args, **kwargs):
buf = req.get_header("X-SSL-CERT")
if not buf:
logger.info("No TLS certificate presented to access administrative API call")
raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation")
header, _, der_bytes = pem.unarmor(buf.replace("\t", "").encode("ascii"))
cert = x509.Certificate.load(der_bytes) # TODO: validate serial
for extension in cert["tbs_certificate"]["extensions"]:
if extension["extn_id"].native == "extended_key_usage":
if "server_auth" in extension["extn_value"].native:
req.context["machine"] = cert.subject.native["common_name"]
return func(resource, req, resp, *args, **kwargs)
logger.info("TLS authenticated machine '%s' not authorized to access administrative API", cert.subject.native["common_name"])
raise falcon.HTTPForbidden("Forbidden", "Machine not authorized to perform the operation")
return wrapped