# encoding: utf-8
import datetime
import urlparse
import werkzeug
import werkzeug.urls
import pvl.login.auth
import pvl.web
import pvl.web.response
from pvl.login import pubtkt
from pvl.web import urls
from pvl.web import html5 as 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',
)
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
def init (self) :
self.alerts = []
def alert (self, type, alert, icon=None) :
log.info(u"%s: %s", type, alert)
self.alerts.append((type, icon, unicode(alert)))
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)
if not cookie :
return
log.debug("cookie %s=%s", self.app.cookie_name, cookie)
cookie = werkzeug.urls.url_unquote(cookie)
log.debug("cookie decoded: %s", cookie)
if not cookie :
return
try :
self.pubtkt = self.app.load(cookie)
except pubtkt.ParseError as ex :
self.alert('danger', ex, icon='compare')
except pubtkt.ExpiredError as ex :
self.pubtkt = ex.pubtkt
self.alert('warning', ex, icon='clock')
except pubtkt.VerifyError as ex :
self.pubtkt = ex.pubtkt
self.alert('danger', ex, icon='warning-sign')
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", icon='lock')
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))
def render_alerts (self) :
for type, icon, alert in self.alerts :
yield html.div(class_='alert alert-{type}'.format(type=type))(
html.span(class_='glyphicon glyphicon-{glyphicon}'.format(glyphicon=icon)) if icon else None,
alert
)
class Index (Handler) :
TITLE = u"Päivölä Network Login"
STYLE = Handler.STYLE + """
.pubtkt {
width: 30em;
margin: 1em auto;
}
.pubtkt form {
display: inline;
}
.pubtkt .panel-heading {
line-height: 20px;
}
.pubtkt .panel-body .glyphicon {
width: 1em;
float: left;
line-height: 20px;
}
.pubtkt .panel-body .progress {
margin-bottom: 0;
margin-left: 2em;
}
"""
JS = Handler.JS + (
'/static/pubtkt-expire.js',
)
def process (self) :
self.process_cookie()
if not self.pubtkt :
return self.redirect(Login)
def render_valid (self, valid) :
seconds = valid.seconds + valid.days * (24 * 60 * 60)
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return "%2d:%02d:%02d" % (hours, minutes, seconds)
def render_status (self, pubtkt) :
valid = pubtkt.valid()
grace = pubtkt.grace()
if grace :
return 'warning'
elif valid :
return 'success'
else :
return 'danger'
def render_pubtkt_valid (self, pubtkt) :
"""
Yield HTML for ticket validity.
"""
lifetime = self.app.login_valid
valid = pubtkt.valid()
grace = pubtkt.grace()
grace_period = pubtkt.grace_period()
remaining = pubtkt.remaining()
if valid :
progress = float(valid.seconds) / float(lifetime.seconds)
else :
progress = None
if grace :
title = "Remaining renewal period"
label = "{grace} (Renew)".format(grace=self.render_valid(grace))
status = 'warning'
elif valid :
title = "Remaining validity period"
label = "{valid}".format(valid=self.render_valid(valid))
status = 'success'
else :
title = "Expired"
label = "Expired"
status = 'danger'
if progress :
return html.div(class_='panel-body', title=title)(
html.span(class_='glyphicon glyphicon-time'),
html.div(class_='progress pubtkt-progress',
data_start=valid.seconds,
data_refresh=grace_period.seconds if remaining else None,
data_end=lifetime.seconds,
)(
html.div(class_='progress-bar progress-bar-{status}'.format(status=status),
role='progressbar',
style='width: {pp:.0f}%'.format(pp=progress*100),
)(
html.span(class_='pubtkt-progress-label')(label)
)
)
)
else :
return None # html.p(label)
def render_pubtkt_fields (self, pubtkt) :
"""
Yield (glyphicon, text) to render as fields for ticket.
"""
if pubtkt.cip :
yield 'cloud', None, "Network address", pubtkt.cip
if pubtkt.udata :
yield 'comment', None, "Associated data", pubtkt.udata
for token in pubtkt.tokens :
yield 'flag', None, "Access token", token
if pubtkt.bauth :
yield 'keys', None, "Authentication token", pubtkt.bauth
def render_pubtkt (self, pubtkt) :
status = self.render_status(pubtkt)
domain = self.app.login_domain
return html.div(class_='pubtkt panel panel-{status}'.format(status=status))(
html.div(class_='panel-heading')(
html.span(class_='glyphicon glyphicon-user'),
html.strong(pubtkt.uid),
html.span("@", domain),
),
self.render_pubtkt_valid(pubtkt),
html.ul(class_='list-group')(
html.li(class_='list-group-item {status}'.format(status=('alert-'+status if status else '')), title=title)(
html.span(class_='glyphicon glyphicon-{glyphicon}'.format(glyphicon=icon)) if icon else None,
data,
) for icon, status, title, data in self.render_pubtkt_fields(pubtkt)
),
html.div(class_='panel-footer')(
#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')(
html.span(class_='glyphicon glyphicon-time'), "Renew"
)
)
) if pubtkt.valid() else (
html.form(action=self.url(Login), method='get', class_='form-inline')(
html.button(type='submit', class_='btn btn-info')(
html.span(class_='glyphicon glyphicon-log-in'), "Login"
)
),
),
html.form(action=self.url(Logout), method='post', class_='form-inline pull-right')(
html.button(type='submit', class_='btn btn-warning')(
html.span(class_='glyphicon glyphicon-log-out'), "Logout"
)
),
#),
),
)
def render (self) :
return html.div(class_='container')(
self.render_alerts(),
self.render_pubtkt(self.pubtkt) if self.pubtkt else None,
)
class Login (Handler) :
TITLE = "Login"
STYLE = Handler.STYLE + """
form#login {
max-width: 50%;
padding: 1em;
margin: 0 auto;
}
"""
login_failure = None
def process (self) :
self.process_cookie()
try :
self.process_back()
except pubtkt.Error as ex :
self.alert('danger', ex)
if self.pubtkt :
self.username = self.pubtkt.uid
else :
self.username = None
# update cookie?
set_pubtkt = None
if self.request.method == 'POST' :
username = self.request.form.get('username')
password = self.request.form.get('password')
if username :
# preprocess
username = username.strip().lower()
if username and password :
self.username = username
try :
set_pubtkt = self.app.auth(username, password)
except pvl.login.auth.AuthError as ex :
self.alert('danger', "Internal authentication error, try again later?")
else :
if not set_pubtkt :
self.alert('danger', "Invalid authentication credentials, try again.")
elif self.pubtkt and self.pubtkt.valid() :
# renew manually if valid
set_pubtkt = self.app.renew(self.pubtkt)
# a POST request that does not modify state is a failure
if not set_pubtkt :
self.login_failure = True
elif 'renew' in self.request.args :
# renew automatically if in grace period
if self.pubtkt and self.pubtkt.grace() :
set_pubtkt = self.app.renew(self.pubtkt)
if set_pubtkt :
# 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(set_pubtkt))
self.pubtkt = set_pubtkt
# redirect with cookie
response = pvl.web.response.redirect(self.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 status (self) :
if self.login_failure :
return 400
else :
return 200
def render (self) :
domain = self.app.login_domain
if 'logout' in self.request.args :
self.alert('info', "You have been logged out.", icon='log-out')
if self.pubtkt and self.pubtkt.valid() :
renew = True
# within validity period...
self.alert('info', "Login or renew ticket.", icon='log-in')
else :
renew = False
return html.div(class_='container')(
html.form(action=self.url(back=self.back), method='POST', id='login')(
self.render_alerts(),
html.fieldset(
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')(
html.label(for_='username', class_='sr-only')("Username"),
html.input(name='username', type='text', class_='form-control', placeholder="username", required=True, autofocus=(not self.username), value=self.username),
html.span(class_='input-group-addon')("@{domain}".format(domain=domain)),
),
html.label(for_='password', class_='sr-only')("Password"),
html.input(name='password', type='password', class_='form-control', placeholder="Password", required=(not renew), autofocus=bool(self.username)),
),
html.button(type='submit', class_='btn btn-primary')(
html.span(class_='glyphicon glyphicon-log-in'), "Login"
),
html.button(type='submit', class_='btn btn-success')(
html.span(class_='glyphicon glyphicon-time'), "Renew"
) if renew else None,
)
)
)
class Logout (Handler) :
TITLE = "Logout"
def process (self) :
self.process_cookie()
if not self.pubtkt :
return self.redirect(Login)
if self.request.method == 'POST' :
response = pvl.web.response.redirect(self.url(Login, logout=1))
response.set_cookie(self.app.cookie_name, '',
expires = 0,
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.url(), method='post')(
html.fieldset(
html.legend("Logout {pubtkt.uid}".format(pubtkt=self.pubtkt)),
html.button(type='submit', class_='btn btn-warning')(
html.span(class_='glyphicon glyphicon-log-out'), "Logout"
),
)
)
)
class LoginApplication (pvl.web.Application) :
URLS = urls.Map((
urls.rule('/', Index),
urls.rule('/login', Login),
urls.rule('/logout', Logout),
))
PUBLIC_KEY = 'etc/login/public.pem'
PRIVATE_KEY = 'etc/login/private.pem'
login_domain = 'test.paivola.fi'
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'
cookie_secure = True
cookie_httponly = True
def __init__ (self, auth, public_key=PUBLIC_KEY, private_key=PRIVATE_KEY, **opts) :
super(LoginApplication, self).__init__(**opts)
self._auth = auth
self.server_keys = pubtkt.ServerKeys.config(
public_key = public_key,
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.
"""
return pubtkt.PubTkt.load(cookie, self.server_keys.public)
def auth (self, username, password) :
"""
Perform authentication, returning a PubTkt, signed, or None.
Raises auth.AuthError.
"""
auth = self._auth.auth(username, password)
if not auth :
return None
tokens = list(self._auth.access(auth))
udata = self._auth.userdata(auth)
return pubtkt.PubTkt.new(username,
valid = self.login_valid,
grace = self.login_grace,
tokens = tokens,
udata = udata,
)
def sign (self, pubtkt) :
"""
Create a cookie by signing the given pubtkt.
"""
return pubtkt.sign(self.server_keys.private)
def renew (self, pubtkt) :
"""
Renew and re-sign the given pubtkt.
"""
# XXX: inplace
pubtkt.renew(self.login_valid, self.login_grace)
return pubtkt