pvl.login: ui tweaks, alerts, back support
authorTero Marttila <terom@paivola.fi>
Mon, 13 Jan 2014 17:11:09 +0200
changeset 354 d46c8d3e3140
parent 353 51022350c645
child 355 2daf32a118ff
pvl.login: ui tweaks, alerts, back support
pvl/login/pubtkt.py
pvl/login/server.py
--- a/pvl/login/pubtkt.py	Mon Jan 13 03:23:33 2014 +0200
+++ b/pvl/login/pubtkt.py	Mon Jan 13 17:11:09 2014 +0200
@@ -18,21 +18,49 @@
     return datetime.datetime.utcfromtimestamp(unix)
 
 class Error (Exception) :
-    pass
+    def __init__ (self, error) :
+        self.error = error
 
 class ParseError (Error) :
-    pass
+    """
+        Unable to load PubTkt from cookie.
+    """
+
+    def __unicode__ (self) :
+        return u"Invalid login token: {self.error}".format(self=self)
 
 class VerifyError (Error) :
+    """
+        Unable to verify PubTkt.
+    """
+
     def __init__ (self, pubtkt, error) :
         self.pubtkt = pubtkt
         self.error = error
 
+    def __unicode__ (self) :
+        return u"Invalid login token signature: {self.error}".format(self=self)
+
 class ExpiredError (VerifyError) :
+    """
+        Verified PubTkt, but expired.
+    """
+
     def __init__ (self, pubtkt, now) :
         self.pubtkt = pubtkt
         self.now = now
 
+    def __unicode__ (self) :
+        return u"Login token has expired"
+
+class ServerError (Error) :
+    """
+        Invalid server request.
+    """
+    
+    def __unicode__ (self) :
+        return u"Login request is not valid: {self.error}".format(self=self)
+
 class ServerKeys (object) :
     @classmethod
     def config (cls, public_key, private_key) :
@@ -90,7 +118,11 @@
         else :
             raise ParseError("Missing signature")
         
-        sig = base64.b64decode(sig)
+        try :
+            sig = base64.b64decode(sig)
+        except (ValueError, TypeError) as ex :
+            raise ParseError("Invalid signature")
+
         hash = hashlib.sha1(data).digest()
 
         try :
@@ -98,9 +130,14 @@
         except ValueError as ex :
             raise ParseError(str(ex))
         
+        if 'uid' not in attrs or 'validuntil' not in attrs :
+            raise ParseError("Missing parameters in cookie (uid, validuntil)")
+
         try :
             return cls.build(**attrs), hash, sig
-        except (TypeError, ValueError) as ex :
+        except TypeError as ex :
+            raise ParseError("Invalid or missing parameters in cookie")
+        except ValueError as ex :
             raise ParseError(str(ex))
     
     @classmethod
@@ -121,10 +158,13 @@
         )
 
     @classmethod
-    def new (cls, uid, expiry, **opts) :
+    def new (cls, uid, valid, grace=None, **opts) :
         now = cls.now()
 
-        return cls(uid, now + expiry, **opts)
+        return cls(uid, now + valid,
+            graceperiod = now + grace if grace else None,
+            **opts
+        )
 
     def __init__ (self, uid, validuntil, cip=None, tokens=(), udata=None, graceperiod=None, bauth=None) :
         self.uid = uid
@@ -195,9 +235,13 @@
         else :
             return False
 
-    def renew (self, expiry) :
+    def renew (self, valid, grace=None) :
         if not self.valid() :
             raise ExpiredError(self, "Unable to renew expired pubtkt")
 
-        self.validuntil = self.now() + expiry
+        now = self.now()
 
+        self.validuntil = now + valid
+        self.graceperiod = now + grace if grace else None
+
+
--- a/pvl/login/server.py	Mon Jan 13 03:23:33 2014 +0200
+++ b/pvl/login/server.py	Mon Jan 13 17:11:09 2014 +0200
@@ -1,6 +1,7 @@
 # encoding: utf-8
 
 import datetime
+import urlparse
 import werkzeug
 import werkzeug.urls
 
@@ -25,12 +26,30 @@
             '//netdna.bootstrapcdn.com/bootstrap/3.0.3/js/bootstrap.min.js',
     )
 
+    STYLE = """
+body {
+    padding-top: 2em;
+    text-align: center;
+}
+
+.container {
+    padding: 2em 1em;
+    text-align: left;
+}
+    """
+
     def redirect (self, *url, **params) :
         return pvl.web.response.redirect(self.url(*url, **params))
-
+    
     pubtkt = None
-    cookie_error = None
-    verify_error = None
+
+    def init (self) :
+        self.alerts = []
+
+    def alert (self, type, alert) :
+        log.info(u"%s: %s", type, alert)
+
+        self.alerts.append((type, unicode(alert)))
 
     def process_cookie (self) :
         """
@@ -57,15 +76,51 @@
             self.pubtkt = self.app.load(cookie)
 
         except pubtkt.ParseError as ex :
-            self.cookie_error = ex
+            self.alert('danger', ex)
+
+        except pubtkt.ExpiredError as ex :
+            self.pubtkt = ex.pubtkt
+            self.alert('warning', ex)
 
         except pubtkt.VerifyError as ex :
             self.pubtkt = ex.pubtkt
-            self.verify_error = ex
- 
+            self.alert('danger', ex)
+
+    def process_back (self) :
+        self.server = None
+        self.back = urlparse.urlunparse((self.app.login_scheme, self.app.login_server, '/', '', '', ''))
+
+        back = self.request.args.get('back')
+
+        if back :
+            url = urlparse.urlparse(back, self.app.login_scheme)
+            
+            if not self.app.login_scheme :
+                scheme = url.scheme
+
+            elif url.scheme == self.app.login_scheme :
+                scheme = url.scheme
+
+            else :
+                self.alert('info', "Using SSL for application URL")
+                scheme = self.app.login_scheme
+                
+            self.server = self.app.check_server(url.hostname)
+            self.back = urlparse.urlunparse((scheme, self.server, url.path, url.params, url.query, url.fragment))
 
 class Index (Handler) :
     TITLE = u"Päivölä Network Login"
+
+    STYLE = Handler.STYLE + """
+.pubtkt {
+    width: 30em;
+    margin: 1em auto;
+}
+
+.pubtkt form {
+    display: inline;
+}
+    """
     
     def process (self) :
         self.process_cookie()
@@ -105,10 +160,10 @@
         grace = pubtkt.grace()
 
         if valid and grace :
-            valid = "{valid} ({grace})".format(valid=self.render_valid(valid), grace=self.render_valid(grace))
+            valid = "{grace} ({valid})".format(valid=self.render_valid(valid), grace=self.render_valid(grace))
             valid_status = 'success'
         elif valid and grace is False :
-            valid = "Renewable ({grace})".format(valid=self.render_valid(valid), grace=self.render_valid(grace))
+            valid = "Renewable ({valid})".format(valid=self.render_valid(valid))
             valid_status = 'warning'
         elif valid :
             valid = "{valid}".format(valid=self.render_valid(valid))
@@ -134,7 +189,7 @@
     def render_pubtkt (self, pubtkt) :
         status = self.render_status(pubtkt)
 
-        return html.div(class_='panel panel-{status}'.format(status=status))(
+        return html.div(class_='pubtkt panel panel-{status}'.format(status=status))(
             html.div(class_='panel-heading')("Login: {pubtkt.uid}".format(pubtkt=self.pubtkt)),
             html.ul(class_='list-group')(
                 html.li(class_='list-group-item {status}'.format(status=('alert-'+status if status else '')), title=title)(
@@ -143,30 +198,30 @@
                 ) for icon, status, title, info in self.render_pubtkt_fields(pubtkt)
             ),
             html.div(class_='panel-footer')(
-                (
-                    html.form(action=self.url(Login), method='post')(
-                        html.button(type='submit', class_='btn btn-success')("Renew"),
-                    )
-                ) if pubtkt.valid() else (
-                    html.form(action=self.url(Login), method='get')(
-                        html.button(type='submit', class_='btn btn-info')("Login"),
+                #html.div(class_='btn-toolbar', role='toolbar')(
+                    (
+                        html.form(action=self.url(Login), method='post', class_='form-inline')(
+                            html.button(type='submit', class_='btn btn-success')("Renew"),
+                        )
+                    ) if pubtkt.valid() else (
+                        html.form(action=self.url(Login), method='get', class_='form-inline')(
+                            html.button(type='submit', class_='btn btn-info')("Login"),
+                        ),
                     ),
-                ),
 
-                html.form(action=self.url(Logout), method='post')(
-                    html.button(type='submit', class_='btn btn-warning')("Logout"),
-                ),
+                    html.form(action=self.url(Logout), method='post', class_='form-inline pull-right')(
+                        html.button(type='submit', class_='btn btn-warning')("Logout"),
+                    ),
+                #),
             ),
         )
 
     def render_info (self) :
-        if self.cookie_error :
-            return (
-                    html.h2("Invalid cookie"),
-                    html.p(self.cookie_error),
-            )
+        for type, alert in self.alerts :
+            yield html.div(class_='alert alert-{type}'.format(type=type))(alert)
 
-        return self.render_pubtkt(self.pubtkt)
+        if self.pubtkt :
+            yield self.render_pubtkt(self.pubtkt)
    
     def render (self) :
 
@@ -177,7 +232,7 @@
 class Login (Handler) :
     TITLE = "Login"
     
-    STYLE = """
+    STYLE = Handler.STYLE + """
 form#login {
     max-width:  50%;
     padding:    1em;
@@ -185,12 +240,15 @@
 }
 
     """
-
     def process (self) :
         self.process_cookie()
+        
+        try :
+            self.process_back()
+        except pubtkt.Error as ex :
+            self.alert('danger', ex)
 
         if self.request.method == 'POST' :
-            back = self.app.login_server
             username = self.request.form.get('username')
             password = self.request.form.get('username')
 
@@ -216,7 +274,7 @@
             cookie = werkzeug.urls.url_quote(self.app.sign(self.pubtkt))
 
             # redirect with cookie
-            response = pvl.web.response.redirect(back)
+            response = pvl.web.response.redirect(self.back)
 
             response.set_cookie(self.app.cookie_name, cookie,
                 domain      = self.app.cookie_domain,
@@ -235,9 +293,21 @@
         domain = self.app.login_domain
 
         return html.div(class_='container')(
-            html.form(action=self.url(), method='POST', id='login')(
+            html.form(action=self.url(back=self.back), method='POST', id='login')(
+                (
+                    html.div(class_='alert alert-{alert}'.format(alert=type))(alert)
+                        for type, alert in self.alerts
+                ),
+
                 html.fieldset(
-                    html.legend("Log in"),
+                    html.legend(
+                        (
+                            "Login @ ",
+                            html.a(href=self.back)(self.server),
+                        ) if self.server else (
+                            "Login"
+                        )
+                   ),
                 
                     html.div(class_='form-group')(
                         html.div(class_='input-group')(
@@ -260,14 +330,19 @@
 
     def process (self) :
         self.process_cookie()
+ 
+        try :
+            self.process_back()
+        except pubtkt.Error as ex :
+            self.alert('danger', ex)
 
         if not self.pubtkt :
-            return self.redirect(Index)
+            return self.redirect(self.back)
 
         if self.request.method == 'POST' :
             back = self.app.login_server
 
-            response = pvl.web.response.redirect(back)
+            response = pvl.web.response.redirect(self.back)
 
             response.set_cookie(self.app.cookie_name, '',
                     expires = 0,
@@ -300,8 +375,10 @@
     PRIVATE_KEY = 'etc/login/private.pem'
     
     login_domain = 'test.paivola.fi'
-    login_server = 'https://login.test.paivola.fi/'
-    login_expire = datetime.timedelta(seconds=60)
+    login_server = 'login.test.paivola.fi'
+    login_valid = datetime.timedelta(seconds=60)
+    login_grace = datetime.timedelta(seconds=30)
+    login_scheme = 'https'
 
     cookie_name = 'auth_pubtkt'
     cookie_domain = 'test.paivola.fi'
@@ -316,6 +393,18 @@
                 private_key = private_key,
         )
 
+    def check_server (self, server) :
+        """
+            Check that the given target server is valid.
+        """
+
+        server = server.lower()
+
+        if server == self.login_domain or server.endswith('.' + self.login_domain) :
+            return server
+        else :
+            raise pubtkt.ServerError("Target server is not covered by our authentication domain: {domain}".format(domain=self.login_domain))
+
     def load (self, cookie) :
         """
             Load a pubtkt from a cookie, and verify it.
@@ -329,7 +418,8 @@
         """
         
         return pubtkt.PubTkt.new(username,
-                expiry  = self.login_expire,
+                valid   = self.login_valid,
+                grace   = self.login_grace,
         )
 
     def sign (self, pubtkt) :
@@ -344,4 +434,4 @@
             Renew and re-sign the given pubtkt.
         """
 
-        pubtkt.renew(self.login_expire)
+        pubtkt.renew(self.login_valid, self.login_grace)