# HG changeset patch # User Tero Marttila # Date 1389570574 -7200 # Node ID 089ec3eddc926a27115b9eb89ebbba5bd48a7e81 # Parent d368f3b8a1172debaacad3628ee3ae5d96f4ba17 pvl.login: a pubtkt-based sso login server.. diff -r d368f3b8a117 -r 089ec3eddc92 bin/pvl.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) diff -r d368f3b8a117 -r 089ec3eddc92 pvl/login/pubtkt.py --- /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)) + diff -r d368f3b8a117 -r 089ec3eddc92 pvl/login/server.py --- /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) +