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:
@@ -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):
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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/"},
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user