From 4ee31f4df99e4fc4e26606b828c4d0f7b3a76b49 Mon Sep 17 00:00:00 2001
From: Tanner Prestegard <tanner.prestegard@ligo.org>
Date: Mon, 10 Dec 2018 15:55:53 -0600
Subject: [PATCH] Add API backend for full cert verify and auth

New API backend which gets a full X509 certificate, verifies it,
and extracts the subject.  To be used in the cloud deployment
with Traefik.
---
 config/settings/base.py           |  1 +
 config/settings/container/base.py |  7 +++
 gracedb/api/backends.py           | 92 +++++++++++++++++++++++++++++++
 3 files changed, 100 insertions(+)

diff --git a/config/settings/base.py b/config/settings/base.py
index c5fd16f4e..674a006df 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -262,6 +262,7 @@ SHIB_ATTRIBUTE_MAP = {
 # Headers to use for X509 authentication
 X509_SUBJECT_DN_HEADER = 'HTTP_SSL_CLIENT_S_DN'
 X509_ISSUER_DN_HEADER = 'HTTP_SSL_CLIENT_I_DN'
+X509_CERT_HEADER = 'X_FORWARDED_TLS_CLIENT_CERT'
 
 # List of authentication backends to use when attempting to authenticate
 # a user.  Will be used in this order.  Authentication for the API is
diff --git a/config/settings/container/base.py b/config/settings/container/base.py
index e088fbb74..59aeb9580 100644
--- a/config/settings/container/base.py
+++ b/config/settings/container/base.py
@@ -49,6 +49,13 @@ DATABASES = {
 # Main server "hostname" - a little hacky but OK
 SERVER_HOSTNAME = SERVER_FQDN.split('.')[0]
 
+# Use full client certificate to authenticate
+REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = (
+    'api.backends.GraceDbAuthenticatedAuthentication',
+    'api.backends.GraceDbX509FullCertAuthentication',
+    'api.backends.GraceDbBasicAuthentication',
+)
+
 # Update allowed hosts from environment variables -----------------------------
 hosts_from_env = os.environ.get('DJANGO_ALLOWED_HOSTS', None)
 if hosts_from_env is not None:
diff --git a/gracedb/api/backends.py b/gracedb/api/backends.py
index 3f9b4c29f..b0321d846 100644
--- a/gracedb/api/backends.py
+++ b/gracedb/api/backends.py
@@ -1,5 +1,9 @@
+import base64
 import logging
+import OpenSSL.crypto
+import OpenSSL.SSL
 import re
+import urlparse
 
 from django.contrib.auth import get_user_model, authenticate
 from django.conf import settings
@@ -54,6 +58,10 @@ class GraceDbBasicAuthentication(authentication.BasicAuthentication):
 
 
 class GraceDbX509Authentication(authentication.BaseAuthentication):
+    """
+    Authentication based on X509 certificate subject.
+    Certificate should be verified by Apache already.
+    """
     api_only = True
     www_authenticate_realm = 'api'
     subject_dn_header = getattr(settings, 'X509_SUBJECT_DN_HEADER',
@@ -122,6 +130,90 @@ class GraceDbX509Authentication(authentication.BaseAuthentication):
         return (user, None)
 
 
+class GraceDbX509FullCertAuthentication(GraceDbX509Authentication):
+    """
+    Authentication based on a full X509 certificate. We verify the
+    certificate here.
+    """
+    api_only = True
+    www_authenticate_realm = 'api'
+    cert_header = getattr(settings, 'X509_CERT_HEADER',
+        'X_FORWARDED_TLS_CLIENT_CERT')
+
+    def authenticate(self, request):
+
+        # Make sure this request is directed to the API
+        if self.api_only and not is_api_request(request.path):
+            return None
+
+        # Try to get certificate from request headers
+        cert_data = self.get_certificate_data_from_request(request)
+
+        # If no certificate is found, abort
+        if not cert_data:
+            return None
+
+        # Verify certificate
+        try:
+            certificate = self.verify_certificate_chain(cert_data)
+        except exceptions.AuthenticationFailed as e:
+            raise
+        except Exception as e:
+            raise exceptions.AuthenticationFailed(_('Certificate could not be '
+                'verified'))
+
+        return self.authenticate_credentials(certificate)
+
+    @classmethod
+    def get_certificate_data_from_request(cls, request):
+        """Get certificate data from request"""
+        cert_quoted = request.META.get(cls.cert_header, None)
+        if cert_quoted is None:
+            return None
+
+        # Process the certificate a bit
+        cert_b64 = urlparse.unquote(cert_quoted)
+        cert_der = base64.b64decode(cert_b64)
+
+        return cert_der
+
+    def verify_certificate_chain(self, cert_data,
+        trusted_certs='/etc/grid-security/certificates'):
+
+        # Load certificate data
+        certificate = OpenSSL.crypto.load_certificate(
+            OpenSSL.crypto.FILETYPE_ASN1, cert_data)
+
+        # Set up context and get certificate store 
+        ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
+        ctx.load_verify_locations(None, capath=trusted_certs)
+        store = ctx.get_cert_store()
+
+        # Verify certificate
+        store_ctx = OpenSSL.crypto.X509StoreContext(store, certificate)
+        store_ctx.verify_certificate()
+
+        # Check if expired
+        if certificate.has_expired():
+            raise exceptions.AuthenticationFailed(_('Certificate has expired'))
+
+        return certificate
+
+    def authenticate_credentials(self, certificate):
+        # Convert certificate to subject
+        subject = self.get_certificate_subject_string(certificate)
+
+        return super(GraceDbX509FullCertAuthentication, self) \
+            .authenticate_credentials(subject)
+
+    @staticmethod
+    def get_certificate_subject_string(certificate):
+        subject = certificate.get_subject()
+        subject_string = '/' + "/".join(["=".join(c) for c in
+            subject.get_components()])
+        return subject_string
+
+
 class GraceDbAuthenticatedAuthentication(authentication.BaseAuthentication):
     """
     If user is already authenticated by the main Django middleware,
-- 
GitLab