pvl.login: a pubtkt-based sso login server..
authorTero Marttila <terom@paivola.fi>
Mon, 13 Jan 2014 01:49:34 +0200
changeset 348 089ec3eddc92
parent 347 d368f3b8a117
child 349 3c20473d0bdc
pvl.login: a pubtkt-based sso login server..
bin/pvl.login-server
pvl/login/__init__.py
pvl/login/pubtkt.py
pvl/login/server.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/pvl.login-server	Mon Jan 13 01:49:34 2014 +0200
@@ -0,0 +1,42 @@
+#!/usr/bin/python
+
+"""
+    pvl.verkko.rrd wsgi development server
+"""
+
+
+import pvl.args
+import pvl.login.server
+import pvl.web.args
+
+
+import optparse
+import logging; log = logging.getLogger('pvl.login-server')
+
+       
+def main (argv) :
+    """
+        pvl.login server
+    """
+
+    parser = optparse.OptionParser(main.__doc__)
+    parser.add_option_group(pvl.args.parser(parser))
+    parser.add_option_group(pvl.web.args.parser(parser))
+
+    options, args = parser.parse_args(argv[1:])
+    pvl.args.apply(options)
+
+    # app
+    application = pvl.web.args.apply(options,
+            pvl.login.server.LoginApplication,
+    )
+
+    # behind a reverse-proxy
+    import werkzeug.contrib.fixers
+
+    application = werkzeug.contrib.fixers.ProxyFix(application)
+
+    pvl.web.args.main(options, application)
+
+if __name__ == '__main__':
+    pvl.args.main(main)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/login/pubtkt.py	Mon Jan 13 01:49:34 2014 +0200
@@ -0,0 +1,163 @@
+import base64
+import calendar
+import datetime
+import ipaddr
+import hashlib
+import M2Crypto
+
+import logging; log = logging.getLogger('pvl.login.pubtkt')
+
+def datetime2unix (dt) :
+    """
+        datetime.datetime -> float
+    """
+
+    return calendar.timegm(dt.utctimetuple())
+
+class Error (Exception) :
+    pass
+
+class ParseError (Error) :
+    pass
+
+class VerifyError (Error) :
+    def __init__ (self, pubtkt, error) :
+        self.pubtkt = pubtkt
+        self.error = error
+
+class ExpiredError (VerifyError) :
+    def __init__ (self, pubtkt, now) :
+        self.pubtkt = pubtkt
+        self.now = now
+
+class ServerKeys (object) :
+    @classmethod
+    def config (cls, public_key, private_key) :
+        return cls(
+                public  = M2Crypto.RSA.load_pub_key(public_key),
+                private = M2Crypto.RSA.load_key(private_key),
+        )
+
+    def __init__ (self, public, private) :
+        self.public = public
+        self.private = private
+
+class PubTkt (object) :
+    @classmethod
+    def load (cls, cookie, public_key) :
+        """
+            Load and verify a pubtkt from a cookie.
+
+            Raise ParseError, VerifyError.
+        """
+        
+        pubtkt, hash, sig = cls.parse(cookie)
+
+        log.debug("parsed %s hash=%s sig=%s", pubtkt, hash.encode('hex'), sig.encode('hex'))
+        
+        try :
+            if not public_key.verify(hash, sig, 'sha1') :
+                raise VerifyError(pubtkt, "Unable to verify signature")
+        except M2Crypto.RSA.RSAError as ex :
+            raise VerifyError(pubtkt, str(ex))
+        
+        now = datetime.datetime.now()
+
+        log.debug("validating %s < %s", pubtkt.validuntil, now)
+
+        if pubtkt.validuntil < now :
+            raise ExpiredError(pubtkt, now)
+
+        return pubtkt
+
+    @classmethod
+    def parse (cls, cookie) :
+        """
+            Load a pubtkt from a cookie
+
+            Raises ParseError.
+        """
+        
+        if ';sig=' in cookie :
+            data, sig = cookie.rsplit(';sig=', 1)
+        else :
+            raise ParseError("Missing signature")
+        
+        sig = base64.b64decode(sig)
+        hash = hashlib.sha1(data).digest()
+
+        try :
+            attrs = dict(field.split('=', 1) for field in data.split(';'))
+        except ValueError as ex :
+            raise ParseError(str(ex))
+        
+        try :
+            return cls.build(**attrs), hash, sig
+        except (TypeError, ValueError) as ex :
+            raise ParseError(str(ex))
+    
+    @classmethod
+    def build (cls, uid, validuntil, cip=None, tokens=None, udata=None, graceperiod=None, bauth=None) :
+        """
+            Build a pubtkt from items.
+
+            Raises TypeError or ValueError..
+        """
+        
+        return cls(uid,
+                validuntil  = datetime.datetime.fromtimestamp(int(validuntil)),
+                cip         = ipaddr.IPAddress(cip) if cip else None,
+                tokens      = tokens.split(',') if tokens else (),
+                udata       = udata,
+                graceperiod = datetime.datetime.fromtimestamp(int(graceperiod)) if graceperiod else None,
+                bauth       = bauth,
+        )
+
+    @classmethod
+    def new (cls, uid, expiry, **opts) :
+        now = datetime.datetime.now()
+
+        return cls(uid, now + expiry, **opts)
+
+    def __init__ (self, uid, validuntil, cip=None, tokens=(), udata=None, graceperiod=None, bauth=None) :
+        self.uid = uid
+        self.validuntil = validuntil
+        self.cip = cip
+        self.tokens = tokens
+        self.udata = udata
+        self.graceperiod = graceperiod
+        self.bauth = bauth
+
+    def iteritems (self) :
+        yield 'uid', self.uid
+        yield 'validuntil', int(datetime2unix(self.validuntil))
+
+        if self.cip :
+            yield 'cip', self.cip
+        
+        if self.tokens :
+            yield 'tokens', ','.join(self.tokens)
+        
+        if self.udata :
+            yield 'udata', self.udata
+        
+        if self.graceperiod :
+            yield 'graceperiod', int(datetime2unix(self.graceperiod))
+        
+        if self.bauth :
+            yield 'bauth', self.bauth
+
+    def __str__ (self) :
+        """
+            The (unsigned) pubtkt
+        """
+
+        return ';'.join('%s=%s' % (key, value) for key, value in self.iteritems())
+
+    def sign (self, private_key) :
+        data = str(self)
+        hash = hashlib.sha1(data).digest()
+        sign = private_key.sign(hash, 'sha1')
+
+        return '%s;sig=%s' % (self, base64.b64encode(sign))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/login/server.py	Mon Jan 13 01:49:34 2014 +0200
@@ -0,0 +1,193 @@
+# encoding: utf-8
+
+import datetime
+import werkzeug
+import werkzeug.urls
+
+import pvl.web
+import pvl.web.response
+
+from pvl.login import pubtkt
+from pvl.web import urls, html
+
+import logging; log = logging.getLogger('pvl.login.server')
+
+class Handler (pvl.web.Handler) :
+    # Bootstrap
+    DOCTYPE = 'html'
+    HTML_XMLNS = None
+    HTML_LANG = 'en'
+    CSS = (
+            '//netdna.bootstrapcdn.com/bootstrap/3.0.3/css/bootstrap.min.css',
+    )
+    JS = (
+            '//code.jquery.com/jquery.js',
+            '//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js',
+    )
+
+    def process_cookie (self) :
+        """
+            Reverse the urlencoding used for the cookie...
+        """
+        
+        log.debug("cookies: %s", self.request.cookies)
+
+        cookie = self.request.cookies.get(self.app.cookie_name)
+        
+        log.debug("cookie %s=%s", self.app.cookie_name, cookie)
+
+        if cookie :
+            cookie = werkzeug.urls.url_unquote(cookie)
+        
+        log.debug("cookie decoded: %s", cookie)
+        
+        if cookie :
+            return self.app.load(cookie)
+
+class Index (Handler) :
+    TITLE = u"Päivölä Network Login"
+    
+    pubtkt = None
+    cookie_error = None
+
+    def process (self) :
+        try :
+            self.pubtkt = self.process_cookie()
+        except pubtkt.Error as ex :
+            self.cookie_error = ex
+
+    def render_info (self) :
+        if self.cookie_error :
+            return (
+                    html.h2("Invalid cookie"),
+                    html.p(self.cookie_error),
+            )
+        elif self.pubtkt :
+            return (
+                    html.h2("Login: {pubtkt.uid}".format(pubtkt=self.pubtkt)),
+            )
+        else :
+            return (
+                    html.a(href=self.url(Login), title="Login")(html.h2("No login")),
+            )
+   
+    def render (self) :
+
+        return html.div(class_='container')(
+                self.render_info(),
+        )
+
+class Login (Handler) :
+    TITLE = "Login"
+    
+    STYLE = """
+form#login {
+    max-width:  50%;
+    padding:    1em;
+    margin:     0 auto;
+}
+
+    """
+
+
+    auth_error = None
+
+    def process (self) :
+        if self.request.method == 'POST' :
+            back = self.app.login_server
+            username = self.request.form.get('username')
+            password = self.request.form.get('username')
+
+            if username and password :
+                # preprocess
+                username = username.strip().lower()
+                
+                try :
+                    pt = self.app.auth(username, password)
+
+                except pubtkt.Error as ex :
+                    self.auth_errors = ex
+
+                else :
+                    # browsers seem to be very particular about quoting ;'s in cookie values...
+                    # this follows PHP's setcookie() encoding...
+                    cookie = werkzeug.urls.url_quote(self.app.sign(pt))
+
+                    # redirect with cookie
+                    response = pvl.web.response.redirect(back)
+
+                    response.set_cookie(self.app.cookie_name, cookie,
+                        domain      = self.app.cookie_domain,
+                        secure      = self.app.cookie_secure,
+                        httponly    = self.app.cookie_httponly,
+                    )
+
+                    return response
+
+    def render (self) :
+        return html.div(class_='container')(
+            html.form(action=self.request.path, method='POST', id='login')(
+                html.fieldset(
+                    html.legend("Log in"),
+                
+                    html.div(class_='form-group')(
+                        html.label(for_='username', class_='sr-only')("Username"),
+                        html.input(name='username', type='text', class_='form-control', placeholder="username", required=True, autofocus=True),
+
+                        html.label(for_='password', class_='sr-only')("Password"),
+                        html.input(name='password', type='password', class_='form-control', placeholder="Password", required=True),
+                    ),
+
+                    html.button(type='submit', class_='btn btn-primary')("Login"),
+                )
+            )
+        )
+
+class LoginApplication (pvl.web.Application) :
+    URLS = urls.Map((
+        urls.rule('/',              Index),
+        urls.rule('/login',         Login),
+    ))
+
+    PUBLIC_KEY = 'etc/login/public.pem'
+    PRIVATE_KEY = 'etc/login/private.pem'
+
+    login_server = 'https://login.test.paivola.fi/'
+    login_expire = datetime.timedelta(hours=1)
+
+    cookie_name = 'auth_pubtkt'
+    cookie_domain = 'test.paivola.fi'
+    cookie_secure = True
+    cookie_httponly = True
+
+    def __init__ (self, public_key=PUBLIC_KEY, private_key=PRIVATE_KEY, **opts) :
+        super(LoginApplication, self).__init__(**opts)
+        
+        self.server_keys = pubtkt.ServerKeys.config(
+                public_key  = public_key,
+                private_key = private_key,
+        )
+
+    def load (self, cookie) :
+        """
+            Load a pubtkt from a cookie, and verify it.
+        """
+
+        return pubtkt.PubTkt.load(cookie, self.server_keys.public)
+
+    def auth (self, username, password) :
+        """
+            Perform authentication, returning a PubTkt, signed
+        """
+        
+        return pubtkt.PubTkt.new(username,
+                expiry  = self.login_expire,
+        )
+
+    def sign (self, pubtkt) :
+        """
+            Create a cookie by signing the given pubtkt.
+        """
+        
+        return pubtkt.sign(self.server_keys.private)
+