pvl.login: a pubtkt-based sso login server..
--- /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)
+