From e2f27078d11222b4a23d6ffb478d70626ed61584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lauri=20V=C3=B5sandi?= Date: Sun, 16 Aug 2015 17:21:42 +0300 Subject: [PATCH] Added preliminary Kerberos authentication support --- README.rst | 80 ++++++++++++++++++- certidude/api.py | 56 +++++++++---- certidude/auth.py | 60 ++++++++++++++ certidude/cli.py | 12 ++- certidude/templates/index.html | 26 +++--- certidude/templates/openssl.cnf | 8 +- .../templates/strongswan-client-to-site.conf | 4 +- .../templates/strongswan-site-to-client.conf | 1 + certidude/templates/uwsgi.ini | 11 +-- certidude/wrappers.py | 24 ++++-- certidude/wsgi.py | 4 +- setup.py | 3 +- 12 files changed, 236 insertions(+), 53 deletions(-) create mode 100644 certidude/auth.py diff --git a/README.rst b/README.rst index 203146e..9043bf5 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,7 @@ Features * Certificate numbering obfuscation, certificate serial numbers are intentionally randomized to avoid leaking information about business practices. * Server-side events support via for example nginx-push-stream-module. +* Kerberos based authentication TODO @@ -42,9 +43,12 @@ To install Certidude: .. code:: bash - apt-get install -y python3 python3-netifaces python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev + apt-get install -y python3 python3-pip python3-dev cython3 build-essential libffi-dev libssl-dev pip3 install certidude +Make sure you're running PyOpenSSL 0.15+ and netifaces 0.10.4+ from PyPI, +not the outdated ones provided by APT. + Create a system user for ``certidude``: .. code:: bash @@ -106,7 +110,7 @@ Install uWSGI: .. code:: bash - apt-get install uwsgi uwsgi-plugin-python3 + apt-get install nginx uwsgi uwsgi-plugin-python3 To set up ``nginx`` and ``uwsgi`` is suggested: @@ -132,8 +136,8 @@ Otherwise manually configure uUWSGI application in ``/etc/uwsgi/apps-available/c callable = app chmod-socket = 660 chown-socket = certidude:www-data - env = CERTIDUDE_EVENT_PUBLISH=http://localhost/event/publish/%(channel)s - env = CERTIDUDE_EVENT_SUBSCRIBE=http://localhost/event/subscribe/%(channel)s + env = PUSH_PUBLISH=http://localhost/event/publish/%(channel)s + env = PUSH_SUBSCRIBE=http://localhost/event/subscribe/%(channel)s Also enable the application: @@ -213,3 +217,71 @@ Restart the services: service uwsgi restart service nginx restart + + +Setting up Kerberos authentication +---------------------------------- + +Following assumes you have already set up Kerberos infrastructure and +Certidude is simply one of the servers making use of that infrastructure. + +Install dependencies: + +.. code:: bash + + apt-get install samba-common-bin krb5-user ldap-utils + +Set up Samba client configuration in ``/etc/samba/smb.conf``: + +.. code:: ini + + [global] + security = ads + netbios name = CERTIDUDE + workgroup = WORKGROUP + realm = EXAMPLE.LAN + kerberos method = system keytab + +Set up Kerberos keytab for the web service: + +.. code:: bash + + KRB5_KTNAME=FILE:/etc/certidude.keytab net ads keytab add HTTP -U Administrator + + +Setting up authorization +------------------------ + +Obviously arbitrary Kerberos authenticated user should not have access to +the CA web interface. +You could either specify user name list +in ``/etc/ssl/openssl.cnf``: + +.. code:: bash + + admin_users=alice bob john kate + +Or alternatively specify file path: + +.. code:: bash + + admin_users=/run/certidude/user.whitelist + +Use following shell snippets eg in ``/etc/cron.hourly/update-certidude-user-whitelist`` +to generate user whitelist via LDAP: + +.. code:: bash + + ldapsearch -H ldap://dc1.id.stipit.com -s sub -x -LLL \ + -D 'cn=certidude,cn=Users,dc=id,dc=stipit,dc=com' \ + -w 'certidudepass' \ + -b 'ou=sso,dc=id,dc=stipit,dc=com' \ + '(objectClass=user)' sAMAccountName userPrincipalName givenName sn \ + | python3 -c "import ldif3; import sys; [sys.stdout.write('%s:%s:%s:%s\n' % (a.pop('sAMAccountName')[0], a.pop('userPrincipalName')[0], a.pop('givenName')[0], a.pop('sn')[0])) for _, a in ldif3.LDIFParser(sys.stdin.buffer).parse()]" \ + > /run/certidude/user.whitelist + +Set permissions: + +.. code:: bash + + chmod 700 /etc/cron.hourly/update-certidude-user-whitelist diff --git a/certidude/api.py b/certidude/api.py index 27d9a21..62843c9 100644 --- a/certidude/api.py +++ b/certidude/api.py @@ -7,6 +7,7 @@ import urllib.request import click from time import sleep from certidude.wrappers import Request, Certificate +from certidude.auth import login_required from certidude.mailer import Mailer from pyasn1.codec.der import decoder from datetime import datetime, date @@ -20,9 +21,20 @@ RE_HOSTNAME = "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0 def omit(**kwargs): return dict([(key,value) for (key, value) in kwargs.items() if value]) +def authorize_admin(func): + def wrapped(self, req, resp, *args, **kwargs): + kerberos_username, kerberos_realm = kwargs.get("user") + if kerberos_username not in kwargs.get("ca").admin_users: + raise falcon.HTTPForbidden("User %s not whitelisted" % kerberos_username) + kwargs["user"] = kerberos_username + return func(self, req, resp, *args, **kwargs) + return wrapped + + def pop_certificate_authority(func): def wrapped(self, req, resp, *args, **kwargs): kwargs["ca"] = self.config.instantiate_authority(kwargs["ca"]) + print(func) return func(self, req, resp, *args, **kwargs) return wrapped @@ -72,9 +84,11 @@ def serialize(func): def templatize(path): template = env.get_template(path) def wrapper(func): - def wrapped(instance, req, resp, **kwargs): + def wrapped(instance, req, resp, *args, **kwargs): assert not req.get_param("unicode") or req.get_param("unicode") == u"✓", "Unicode sanity check failed" - r = func(instance, req, resp, **kwargs) + print("templatize would call", func, "with", args, kwargs) + r = func(instance, req, resp, *args, **kwargs) + r.pop("self") if not resp.body: if req.get_header("Accept") == "application/json": resp.set_header("Cache-Control", "no-cache, no-store, must-revalidate"); @@ -114,9 +128,11 @@ class SignedCertificateDetailResource(CertificateAuthorityBase): resp.stream = open(path, "rb") resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % cn) + @login_required @pop_certificate_authority + @authorize_admin @validate_common_name - def on_delete(self, req, resp, ca, cn): + def on_delete(self, req, resp, ca, cn, user): ca.revoke(cn) class SignedCertificateListResource(CertificateAuthorityBase): @@ -152,9 +168,11 @@ class RequestDetailResource(CertificateAuthorityBase): resp.append_header("Content-Type", "application/x-x509-user-cert") resp.append_header("Content-Disposition", "attachment; filename=%s.csr" % cn) + @login_required @pop_certificate_authority + @authorize_admin @validate_common_name - def on_patch(self, req, resp, ca, cn): + def on_patch(self, req, resp, ca, cn, user): """ Sign a certificate signing request """ @@ -165,8 +183,10 @@ class RequestDetailResource(CertificateAuthorityBase): resp.status = falcon.HTTP_201 resp.location = os.path.join(req.relative_uri, "..", "..", "signed", cn) + @login_required @pop_certificate_authority - def on_delete(self, req, resp, ca, cn): + @authorize_admin + def on_delete(self, req, resp, ca, cn, user): ca.delete_request(cn) class RequestListResource(CertificateAuthorityBase): @@ -195,8 +215,8 @@ class RequestListResource(CertificateAuthorityBase): remote_addr = ipaddress.ip_address(req.env["REMOTE_ADDR"]) # Check for CSR submission whitelist - if ca.request_whitelist: - for subnet in ca.request_whitelist: + if ca.request_subnets: + for subnet in ca.request_subnets: if subnet.overlaps(remote_addr): break else: @@ -225,11 +245,11 @@ class RequestListResource(CertificateAuthorityBase): # Process automatic signing if the IP address is whitelisted and autosigning was requested if req.get_param("autosign").lower() in ("yes", "1", "true"): - for subnet in ca.autosign_whitelist: + for subnet in ca.autosign_subnets: if subnet.overlaps(remote_addr): try: resp.append_header("Content-Type", "application/x-x509-user-cert") - resp.body = ca.sign(req).dump() + resp.body = ca.sign(csr).dump() return except FileExistsError: # Certificate already exists, try to save the request pass @@ -245,7 +265,7 @@ class RequestListResource(CertificateAuthorityBase): # Wait the certificate to be signed if waiting is requested if req.get_param("wait"): - url_template = os.getenv("CERTIDUDE_EVENT_SUBSCRIBE") + url_template = os.getenv("PUSH_SUBSCRIBE") if url_template: # Redirect to nginx pub/sub url = url_template % dict(channel=request.fingerprint()) @@ -286,15 +306,15 @@ class CertificateAuthorityResource(CertificateAuthorityBase): resp.append_header("Content-Disposition", "attachment; filename=%s.crt" % ca.slug) class IndexResource(CertificateAuthorityBase): - @templatize("index.html") + @login_required @pop_certificate_authority - def on_get(self, req, resp, ca): - return { - "authority": ca } + @templatize("index.html") + def on_get(self, req, resp, ca, user): + return locals() class ApplicationConfigurationResource(CertificateAuthorityBase): - @validate_common_name @pop_certificate_authority + @validate_common_name def on_get(self, req, resp, ca, cn): ctx = dict( cn = cn, @@ -304,9 +324,11 @@ class ApplicationConfigurationResource(CertificateAuthorityBase): resp.append_header("Content-Disposition", "attachment; filename=%s.ovpn" % cn) resp.body = Template(open("/etc/openvpn/%s.template" % ca.slug).read()).render(ctx) - @validate_common_name + @login_required @pop_certificate_authority - def on_put(self, req, resp, ca, cn=None): + @authorize_admin + @validate_common_name + def on_put(self, req, resp, user, ca, cn=None): pkey_buf, req_buf, cert_buf = ca.create_bundle(cn) ctx = dict( diff --git a/certidude/auth.py b/certidude/auth.py new file mode 100644 index 0000000..95d4748 --- /dev/null +++ b/certidude/auth.py @@ -0,0 +1,60 @@ + +import click +import falcon +import kerberos +import os +import re +import socket + +# Vanilla Kerberos provides only username. +# AD also embeds PAC (Privilege Attribute Certificate), which +# is supposed to be sent via HTTP headers and it contains +# the groups user is part of. +# Even then we would have to manually look up the e-mail +# address eg via LDAP, hence to keep things simple +# we simply use Kerberos to authenticate. + +FQDN = socket.getaddrinfo(socket.gethostname(), 0, flags=socket.AI_CANONNAME)[0][3] + +if not os.getenv("KRB5_KTNAME"): + click.echo("Kerberos keytab not specified, set environment variable 'KRB5_KTNAME'", err=True) + exit(250) + +try: + principal = kerberos.getServerPrincipalDetails("HTTP", FQDN) +except kerberos.KrbError as exc: + click.echo("Failed to initialize Kerberos, reason: %s" % exc, err=True) + exit(249) +else: + click.echo("Kerberos enabled, service principal is HTTP/%s" % FQDN) + +def login_required(func): + def wrapped(resource, req, resp, *args, **kwargs): + authorization = req.get_header("Authorization") + + if not authorization: + resp.append_header("WWW-Authenticate", "Negotiate") + raise falcon.HTTPUnauthorized("Unauthorized", "No Kerberos ticket offered?") + + token = ''.join(authorization.split()[1:]) + + rc, context = kerberos.authGSSServerInit("HTTP@" + FQDN) + if rc != kerberos.AUTH_GSS_COMPLETE: + raise falcon.HTTPForbidden("Forbidden", "Kerberos ticket expired?") + + rc = kerberos.authGSSServerStep(context, token) + kerberos_user = kerberos.authGSSServerUserName(context).split("@") + + # References lost beyond this point! Results in + # ValueError: PyCapsule_SetPointer called with null pointer + kerberos.authGSSServerClean(context) + + if rc == kerberos.AUTH_GSS_COMPLETE: + kwargs["user"] = kerberos_user + return func(resource, req, resp, *args, **kwargs) + elif rc == kerberos.AUTH_GSS_CONTINUE: + raise falcon.HTTPUnauthorized("Unauthorized", "Tried GSSAPI") + else: + raise falcon.HTTPForbidden("Forbidden", "Tried GSSAPI") + + return wrapped diff --git a/certidude/cli.py b/certidude/cli.py index fef12d8..e5369f5 100755 --- a/certidude/cli.py +++ b/certidude/cli.py @@ -402,6 +402,7 @@ def certidude_setup_strongswan_client(url, config, secrets, email_address, commo @click.option("--username", default="certidude", help="Service user account, created if necessary, 'certidude' by default") @click.option("--hostname", default=HOSTNAME, help="nginx hostname, '%s' by default" % HOSTNAME) @click.option("--static-path", default=os.path.join(os.path.dirname(__file__), "static"), help="Static files") +@click.option("--kerberos-keytab", default="/etc/certidude.keytab", help="Specify Kerberos keytab") @click.option("--nginx-config", "-n", default="/etc/nginx/nginx.conf", type=click.File(mode="w", atomic=True, lazy=True), @@ -411,7 +412,7 @@ def certidude_setup_strongswan_client(url, config, secrets, email_address, commo type=click.File(mode="w", atomic=True, lazy=True), help="uwsgi configuration, /etc/uwsgi/ by default") @click.option("--push-server", help="Push server URL, in case of different nginx instance") -def certidude_setup_production(username, hostname, push_server, nginx_config, uwsgi_config, static_path): +def certidude_setup_production(username, hostname, push_server, nginx_config, uwsgi_config, static_path, kerberos_keytab): try: pwd.getpwnam(username) click.echo("Username '%s' already exists, excellent!" % username) @@ -419,8 +420,13 @@ def certidude_setup_production(username, hostname, push_server, nginx_config, uw cmd = "adduser", "--system", "--no-create-home", "--group", username subprocess.check_call(cmd) -# cmd = "gpasswd", "-a", username, "www-data" -# subprocess.check_call(cmd) + if subprocess.call("net ads testjoin", shell=True): + click.echo("Domain membership check failed, 'net ads testjoin' returned non-zero value", stderr=True) + exit(255) + + if not os.path.exists(kerberos_keytab): + subprocess.call("KRB5_KTNAME=FILE:" + kerberos_keytab + " net ads keytab add HTTP -P") + click.echo("Created Kerberos keytab in '%s'" % kerberos_keytab) if not static_path.endswith("/"): static_path += "/" diff --git a/certidude/templates/index.html b/certidude/templates/index.html index 7446e1b..5dbd024 100644 --- a/certidude/templates/index.html +++ b/certidude/templates/index.html @@ -15,12 +15,16 @@

Submit signing request

-

Request submission is allowed from: {% for i in authority.request_whitelist %}{{ i }} {% endfor %}

-

Autosign is allowed from: {% for i in authority.autosign_whitelist %}{{ i }} {% endfor %}

+

Hi, {{user}}

+ +

Request submission is allowed from: {% if ca.request_subnets %}{% for i in ca.request_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %}

+

Autosign is allowed from: {% if ca.autosign_subnets %}{% for i in ca.autosign_subnets %}{{ i }} {% endfor %}{% else %}nowhere{% endif %}

+

Authority administration is allowed from: {% if ca.admin_subnets %}{% for i in ca.admin_subnets %}{{ i }} {% endfor %}{% else %}anywhere{% endif %} +

Authority administration allowed for: {% for i in ca.admin_users %}{{ i }} {% endfor %}

IPsec gateway on OpenWrt

-{% set s = authority.certificate.subject %} +{% set s = ca.certificate.subject %}
 opkg update
@@ -70,15 +74,15 @@ certidude setup openvpn client {{request.url}}
 

Pending requests