pvl.login: do not store invalid pubtkt's in self.pubtkt; implement a ssl client cert ca
authorTero Marttila <terom@paivola.fi>
Tue, 14 Jan 2014 21:03:30 +0200
changeset 373 6beb06b59ee6
parent 372 24e6c32c488f
child 374 d2426cebb46a
pvl.login: do not store invalid pubtkt's in self.pubtkt; implement a ssl client cert ca
.hgignore
bin/pvl.login-server
pvl/login/server.py
pvl/login/ssl.py
--- a/.hgignore	Tue Jan 14 21:02:54 2014 +0200
+++ b/.hgignore	Tue Jan 14 21:03:30 2014 +0200
@@ -8,6 +8,7 @@
 ^dist/
 ^MANIFEST$
 ^log
+^ssl/
 
 # testing
 ^(tmp|test|var|opt)/
--- a/bin/pvl.login-server	Tue Jan 14 21:02:54 2014 +0200
+++ b/bin/pvl.login-server	Tue Jan 14 21:03:30 2014 +0200
@@ -9,6 +9,7 @@
 import pvl.ldap.args
 import pvl.login.auth
 import pvl.login.server
+import pvl.login.ssl
 import pvl.web.args
 
 
@@ -35,7 +36,8 @@
     # app
     application = pvl.web.args.apply(options,
             pvl.login.server.LoginApplication,
-            auth=pvl.login.auth.LDAPAuth(ldap),
+            auth    = pvl.login.auth.LDAPAuth(ldap),
+            ssl     = pvl.login.ssl.UsersCA('ssl/userca', 'ssl/users'),
     )
 
     # behind a reverse-proxy
--- a/pvl/login/server.py	Tue Jan 14 21:02:54 2014 +0200
+++ b/pvl/login/server.py	Tue Jan 14 21:03:30 2014 +0200
@@ -44,6 +44,8 @@
         return pvl.web.response.redirect(self.url(*url, **params))
     
     pubtkt = None
+    invalid_pubtkt = None
+    valid_pubtkt = None
 
     def init (self) :
         self.alerts = []
@@ -80,13 +82,22 @@
         except pubtkt.ParseError as ex :
             self.alert('danger', ex, icon='compare')
 
+        except pubtkt.VerifyError as ex :
+            self.alert('danger', ex, icon='warning-sign')
+            
+            self.invalid_pubtkt = ex.pubtkt
+
         except pubtkt.ExpiredError as ex :
-            self.pubtkt = ex.pubtkt
             self.alert('warning', ex, icon='clock')
+            
+            # store it anyways, but not as valid
+            self.pubtkt = ex.pubtkt
 
-        except pubtkt.VerifyError as ex :
-            self.pubtkt = ex.pubtkt
-            self.alert('danger', ex, icon='warning-sign')
+        else :
+            # it's a parsed, verified and valid pubtkt
+            self.valid_pubtkt = self.pubtkt
+
+        return self.pubtkt
 
     def process_back (self) :
         self.server = None
@@ -106,8 +117,12 @@
             else :
                 self.alert('info', "Using SSL for application URL", icon='lock')
                 scheme = self.app.login_scheme
-                
-            self.server = self.app.check_server(url.hostname)
+
+            if url.hostname :
+                self.server = self.app.check_server(url.hostname)
+            else :
+                self.server = self.app.login_server
+
             self.back = urlparse.urlunparse((scheme, self.server, url.path, url.params, url.query, url.fragment))
 
     def render_alerts (self) :
@@ -454,6 +469,8 @@
     def render (self) :
         return html.div(class_='container')(
             html.form(action=self.url(), method='post')(
+                self.render_alerts(),
+
                 html.fieldset(
                     html.legend("Logout {pubtkt.uid}".format(pubtkt=self.pubtkt)),
             
@@ -464,11 +481,61 @@
             )
         )
 
+class SSL (Handler) :
+    TITLE = "SSL"
+
+    OUT = 'tmp/spkac'
+
+    def process_spkac (self, spkac) :
+        log.info("SPKAC: %s", spkac)
+
+        cert = self.app.sign_ssl(self.pubtkt, spkac)
+
+        file = self.response_file(open(cert))
+
+        log.info("Returning client cert: %s", cert)
+        
+        return pvl.web.Response(file, mimetype='application/x-x509-user-cert')
+
+    def process (self) :
+        if not self.process_cookie() :
+            return self.redirect(Login, back=self.url())
+
+        self.sslcert_dn = self.request.headers.get('X-Forwarded-SSL-DN')
+
+        if self.request.method == 'POST' :
+            spkac = self.request.form.get('spkac')
+
+            if spkac:
+                return self.process_spkac(spkac)
+    
+    def render (self) :
+        if self.sslcert_dn :
+            self.alert('info', "You are currently using a client SSL cert: {self.sslcert_dn}".format(self=self))
+
+        return html.div(class_='container')(
+            html.form(action=self.url(), method='post')(
+                self.render_alerts(),
+                html.fieldset(
+                    html.legend("SSL Login"),
+
+                    html.keygen(name='spkac', challenge='foo', keytype='RSA'),
+
+                    html.button(type='submit', class_='btn')(
+                        "Generate Certificate"
+                    ),
+                )
+            )
+        )
+
 class LoginApplication (pvl.web.Application) :
     URLS = urls.Map((
         urls.rule('/',              Index),
         urls.rule('/login',         Login),
         urls.rule('/logout',        Logout),
+
+        # proto
+        urls.rule('/ssl',           SSL),
     ))
 
     PUBLIC_KEY = 'etc/login/public.pem'
@@ -485,10 +552,11 @@
     cookie_secure = True
     cookie_httponly = True
 
-    def __init__ (self, auth, public_key=PUBLIC_KEY, private_key=PRIVATE_KEY, **opts) :
+    def __init__ (self, auth, ssl=None, public_key=PUBLIC_KEY, private_key=PRIVATE_KEY, **opts) :
         super(LoginApplication, self).__init__(**opts)
         
         self._auth = auth
+        self._ssl = ssl
         self.server_keys = pubtkt.ServerKeys.config(
                 public_key  = public_key,
                 private_key = private_key,
@@ -555,3 +623,15 @@
                 tokens  = list(self._auth.access(auth)),
                 udata   = self._auth.userdata(auth),
         )
+
+    def sign_ssl (self, pubtkt, spkac) :
+        """
+            Generate a SSL client cert for the given user.
+        """
+
+        if not self._ssl :
+            raise pvl.login.ssl.Error("No ssl CA available for signing")
+
+        return self._ssl.sign_user(pubtkt.uid, spkac,
+                userinfo    = pubtkt.udata,
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/login/ssl.py	Tue Jan 14 21:03:30 2014 +0200
@@ -0,0 +1,134 @@
+# encoding: utf-8
+
+import base64
+import datetime
+import hashlib
+import os
+import os.path
+import string
+
+import pvl.invoke
+
+import logging; log = logging.getLogger('pvl.login.ssl')
+
+class Error (Exception) :
+    pass
+
+class UsersCA (object) :
+    OPENSSL = '/usr/bin/openssl'
+
+    SIGN_DAYS = 1
+
+    VALID_USER = set(string.letters + string.digits + '-.')
+    
+    O = u"Päivölän Kansanopisto"
+    OU = u"People"
+    DC = ('paivola', 'fi')
+
+    def __init__ (self, ca, users) :
+        self.ca = ca
+        self.users = users
+
+        self.ca_config = os.path.join(ca, 'openssl.cnf')
+
+    def sign_spkac (self, out, spkac, days=SIGN_DAYS) :
+        """
+            Sign given request file (path).
+
+            Creates the given output file (path). Empty file on errors..
+        """
+
+        pvl.invoke.invoke(self.OPENSSL, ('ca',
+                '-config', self.ca_config,
+                '-spkac', spkac,
+                '-out', out,
+                '-policy', 'policy_user',
+                '-days', str(days),
+                '-utf8',
+            ),
+            setenv={
+                'CA':   self.ca,
+            },
+        )
+
+    def generate_dn (self, uid, cn=None) :
+        """
+            Generate OpenSSL (rdn, value) pairs for given user.
+        """
+
+        if self.O :
+            yield 'O', self.O
+
+        elif self.DC :
+            for index, dc in enumerate(self.DC, 1) :
+                yield '{index}.DC'.format(index=index), dc
+        
+        yield 'OU', self.OU
+         
+        yield 'UID', uid 
+        
+        if cn :
+            yield 'CN', cn
+
+    def write_spkac (self, path, spkac, dn) :
+        """
+            Write out a spkac file to the given path, containing the given base64-encoded spkac and DN.
+        """
+
+        # roundtrip the spkac for consistent formatting
+        spkac = base64.b64encode(base64.b64decode(spkac))
+
+        file = open(path, 'w')
+
+        file.write('SPKAC=')
+        file.write(spkac)
+        file.write('\n')
+
+        for rdn, value in dn :
+            file.write(u'{rdn}={value}\n'.format(rdn=rdn, value=value).encode('utf-8'))
+        
+        file.close()
+
+    def sign_user (self, user, spkac, userinfo=None) :
+        """
+            Sign given spkac string (base64-encoded) for given user.
+
+            Returns path to the signed cert.
+        """
+
+        if not set(user).issubset(self.VALID_USER) :
+            raise Error("Invalid username: {user}".format(user=user))
+
+        dir = os.path.join(self.users, user)
+
+        if not os.path.exists(dir) :
+            os.mkdir(dir)
+
+        name = hashlib.sha1(user + spkac).hexdigest()
+        spkac_file = os.path.join(dir, name) + '.spkac'
+        cert_file = os.path.join(dir, name)
+        tmp_file = os.path.join(dir, name) + '.tmp'
+        
+        # the req to sign
+        if os.path.exists(spkac_file) :
+            log.warning("spkac already exists: %s", spkac_file)
+        else :
+            log.info("%s: write spkac: %s", user, spkac_file)
+            self.write_spkac(os.path.join(dir, name) + '.spkac', spkac, self.generate_dn(user, userinfo))
+        
+        # sign it
+        if os.path.exists(cert_file) :
+            log.warning("cert already exists: %s", cert_file)
+            return cert_file
+        
+        if os.path.exists(tmp_file) :
+            log.warning("cleaning out previous tmp file: %s", tmp_file)
+            os.unlink(tmp_file)
+
+        log.info("%s: sign cert: %s", user, cert_file)
+        self.sign_spkac(tmp_file, spkac_file)
+
+        log.debug("%s: rename %s -> %s", user, tmp_file, cert_file)
+        os.rename(tmp_file, cert_file)
+
+        return cert_file