diff --git a/example/idp2/htdocs/login.mako b/example/idp2/htdocs/login.mako new file mode 100644 index 0000000..8695611 --- /dev/null +++ b/example/idp2/htdocs/login.mako @@ -0,0 +1,28 @@ +<%inherit file="root.mako"/> + +

Please log in

+

+ To register it's quite simple: enter a login and a password +

+ +
+ + + +
+ +
+
+
+
+ +
+ +
+
+ +
+ + +
diff --git a/example/idp2/idp.py b/example/idp2/idp.py new file mode 100755 index 0000000..e03a026 --- /dev/null +++ b/example/idp2/idp.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python + +import re +import logging +import urllib +import time + +from urlparse import parse_qs +from saml2 import server, BINDING_SOAP +from saml2 import BINDING_HTTP_REDIRECT, BINDING_HTTP_POST +from saml2 import time_util +from Cookie import SimpleCookie +from saml2.httputil import Response, Redirect, Unauthorized +from saml2.pack import http_form_post_message +from saml2.pack import http_soap_message +from saml2.s_utils import rndstr +from saml2.saml import AUTHN_PASSWORD + +logger = logging.getLogger("saml2.idp") + +def _expiration(timeout, format="%a, %d-%b-%Y %H:%M:%S GMT"): + if timeout == "now": + return time_util.instant(format) + elif timeout == "dawn": + return time.strftime(format, time.gmtime(0)) + else: + # validity time should match lifetime of assertions + return time_util.in_a_while(minutes=timeout, format=format) + +# ----------------------------------------------------------------------------- +def dict_to_table(ava, lev=0, width=1): + txt = ['\n' % width] + for prop, valarr in ava.items(): + txt.append("\n") + if isinstance(valarr, basestring): + txt.append("\n" % str(prop)) + try: + txt.append("\n" % valarr.encode("utf8")) + except AttributeError: + txt.append("\n" % valarr) + elif isinstance(valarr, list): + index = 0 + num = len(valarr) + for val in valarr: + if not index: + txt.append("\n") + if isinstance(val, dict): + txt.append("\n") + else: + try: + txt.append("\n" % val.encode("utf8")) + except AttributeError: + txt.append("\n" % val) + if num > 1: + txt.append("\n") + num -= 1 + index += 1 + elif isinstance(valarr, dict): + txt.append("\n" % prop) + txt.append("\n") + txt.append("\n") + txt.append('
%s%s%s%s\n" % (len(valarr), prop)) + else: + txt.append("
\n") + txt.extend(dict_to_table(val, lev+1, width-1)) + txt.append("%s%s
%s\n") + txt.extend(dict_to_table(valarr, lev+1, width-1)) + txt.append("
\n') + return txt + +def get_post(environ): + # the environment variable CONTENT_LENGTH may be empty or missing + try: + request_body_size = int(environ.get('CONTENT_LENGTH', 0)) + except ValueError: + request_body_size = 0 + + # When the method is POST the query string will be sent + # in the HTTP request body which is passed by the WSGI server + # in the file like wsgi.input environment variable. + return environ['wsgi.input'].read(request_body_size) + +# ----------------------------------------------------------------------------- +AUTHN = (AUTHN_PASSWORD, "http://lingon.catalogix.se/login") + +REPOZE_ID_EQUIVALENT = "uid" +FORM_SPEC = """
+ + +
""" + +def _sso(environ, start_response, query, binding, user): + if not query: + logger.info("Missing QUERY") + start_response('401 Unauthorized', [('Content-Type', 'text/plain')]) + return ['Unknown user'] + + # base 64 encoded request + req_info = IDP.parse_authn_request(query["SAMLRequest"][0], binding=binding) + resp_args = IDP.response_args(req_info.message, [BINDING_HTTP_POST], + descr_type="spsso") + logger.info("parsed OK") + logger.info("%s" % req_info) + + identity = USERS[user] + logger.info("Identity: %s" % (identity,)) + + if REPOZE_ID_EQUIVALENT: + identity[REPOZE_ID_EQUIVALENT] = user + try: + authn_resp = IDP.create_authn_response(identity, userid=user, + authn=AUTHN, **resp_args) + except Exception, excp: + if logger: logger.error("Exception: %s" % (excp,)) + raise + + if logger: logger.info("AuthNResponse: %s" % authn_resp) + + http_args = http_form_post_message(authn_resp, resp_args["destination"], + relay_state=query["RelayState"][0], + typ="SAMLResponse") + + resp = Response(http_args["data"], headers=http_args["headers"]) + return resp(environ, start_response) + +def sso(environ, start_response, user): + """ Supposted to return a POST """ + + logger.info("--- In SSO ---") + logger.debug("user: %s" % user) + logger.info("Query string: %s" % environ["QUERY_STRING"]) + extra = parse_qs(environ["QUERY_STRING"]) + logger.info("EXTRA: %s" % extra) + logger.debug("keys: %s" % IDP.ticket.keys()) + query = parse_qs(IDP.ticket[extra["key"][0]]) + del IDP.ticket[extra["key"][0]] + + return _sso(environ, start_response, query, BINDING_HTTP_REDIRECT, user) + +def sso_post(environ, start_response, user): + logger.info("--- In SSO POST ---") + logger.debug("user: %s" % user) + logger.info("Query string: %s" % environ["QUERY_STRING"]) + extra = parse_qs(environ["QUERY_STRING"]) + logger.info("EXTRA: %s" % extra) + logger.debug("keys: %s" % IDP.ticket.keys()) + query = parse_qs(IDP.ticket[extra["key"][0]]) + del IDP.ticket[extra["key"][0]] + + return _sso(environ, start_response, query, BINDING_HTTP_POST, user) + +def whoami(environ, start_response, user): + start_response('200 OK', [('Content-Type', 'text/html')]) + identity = USERS[user].copy() + for prop in ["login", "password"]: + try: + del identity[prop] + except KeyError: + continue + response = dict_to_table(identity) + return response[:] + +def not_found(environ, start_response): + """Called if no URL matches.""" + start_response('404 NOT FOUND', [('Content-Type', 'text/plain')]) + return ['Not Found'] + +def not_authn(environ, start_response): + # redirect to login page + logger.info("not_authn ENV: %s" % environ) + + loc = "http://%s/login" % (environ["HTTP_HOST"]) + + headers = [('Content-Type', 'text/plain')] + if environ["REQUEST_METHOD"] == "GET": + if "QUERY_STRING" in environ: + query = environ["QUERY_STRING"] + logger.info("query: %s" % query) + key = hash(query) + IDP.ticket[str(key)] = query + loc += "?%s" % urllib.urlencode({"came_from": environ["PATH_INFO"], + "key": key}) + elif environ["REQUEST_METHOD"] == "POST": + query = get_post(environ) + logger.info("query: %s" % query) + key = hash(query) + IDP.ticket[str(key)] = query + loc += "?%s" % urllib.urlencode({"came_from": environ["PATH_INFO"], + "key": key}) + + logger.debug("location: %s" % loc) + logger.debug("headers: %s" % headers) + resp = Redirect(loc, headers=headers) + return resp(environ, start_response) + +def do_authentication(environ, start_response, sid, cookie=None): + """ + Put up the login form + """ + query = parse_qs(environ["QUERY_STRING"]) + + logger.info("The login page") + if cookie: + headers = [cookie] + else: + headers = [] + + resp = Response(mako_template="login.mako", template_lookup=LOOKUP, + headers=headers) + + argv = { + "action": "/verify", + "came_from": query["came_from"][0], + "login": "", + "password": "", + "key": query["key"][0] + } + logger.info("do_authentication argv: %s" % argv) + return resp(environ, start_response, **argv) + +# ---------------------------------------------------------------------------- + +PASSWD = [("roland", "dianakra"), + ("babs", "howes"), + ("upper", "crust")] + + +def verify_username_and_password(dic): + global PASSWD + # verify username and password + for user, pwd in PASSWD: + if user == dic["login"][0]: + if pwd == dic["password"][0]: + return True, user + + return False, "" + + +def do_verify(environ, start_response, _user): + query = parse_qs(get_post(environ)) + + logger.debug("do_verify: %s" % query) + + _ok, user = verify_username_and_password(query) + if not _ok: + resp = Unauthorized("Unknown user or wrong password") + else: + id = rndstr() + IDP.authn[id] = user + logger.debug("Register %s under '%s'" % (user, id)) + kaka = set_cookie("idpauthn", "/", id) + lox = "http://%s%s?id=%s&key=%s" % (environ["HTTP_HOST"], + query["came_from"][0], id, + query["key"][0]) + logger.debug("Redirect => %s" % lox) + resp = Redirect(lox, headers=[kaka], content="text/html") + + return resp(environ, start_response) + +def kaka2user(kaka): + logger.debug("KAKA: %s" % kaka) + if kaka: + cookie_obj = SimpleCookie(kaka) + morsel = cookie_obj.get("idpauthn", None) + if morsel: + return IDP.authn[morsel.value] + else: + logger.debug() + return None + +# =========================================================================== + +def _subject_sp_info(req_info): + # look for the subject + subject = req_info.subject_id() + subject = subject.text.strip() + sp_entity_id = req_info.message.issuer.text.strip() + return subject, sp_entity_id + +def _slo(environ, start_response, query, user): + try: + req_info = IDP.parse_logout_request(query["SAMLRequest"][0], + BINDING_HTTP_REDIRECT) + relay_state = query["SAMLRequest"][0] + logger.info("LOGOUT request parsed OK") + logger.info("REQ_INFO: %s" % req_info.message) + except KeyError, exc: + if logger: logger.info("logout request error: %s" % (exc,)) + start_response('400 Bad request', [('Content-Type', 'text/plain')]) + return ['Request parse error'] + + subject, sp_entity_id = _subject_sp_info(req_info) + logger.info("Logout subject: %s" % (subject,)) + logger.info("local identifier: %s" % IDP.ident.local_name(sp_entity_id, + subject)) + # remove the authentication + + status = None + + # Either HTTP-Post or HTTP-redirect is possible + bindings = [BINDING_HTTP_POST, BINDING_HTTP_REDIRECT] + logger.debug("logout response to %s" % sp_entity_id) + logger.debug("entity info: %s" % IDP.metadata.entity[sp_entity_id]["spsso"][0]) + (resp, headers, message) = IDP.create_logout_response(req_info.message, + bindings) + #headers.append(session.cookie(expire="now")) + logger.info("Response code: %s" % (resp,)) + logger.info("Header: %s" % (headers,)) + delco = delete_cookie(environ, "idpauthn") + if delco: + headers.append(delco) + start_response(resp, headers) + return message + +def slo(environ, start_response, user): + """ Expects a HTTP-redirect logout request """ + + query = None + if "QUERY_STRING" in environ: + logger.info("Query string: %s" % environ["QUERY_STRING"]) + query = parse_qs(environ["QUERY_STRING"]) + + if not query: + start_response('401 Unauthorized', [('Content-Type', 'text/plain')]) + return ['Unknown user'] + else: + return _slo(environ, start_response, query, user) + +def slo_post(environ, start_response, user): + """ Expects a HTTP-POST logout request """ + + query = parse_qs(get_post(environ)) + return _slo(environ, start_response, query, user) + +def slo_soap(environ, start_response, user): + soap_message = get_post(environ) + #logger.debug("info type: %s" % type(soap_message)) + #logger.debug("SLO_SOAP: %s" % soap_message) + req_info = IDP.parse_logout_request("%s" % soap_message, BINDING_SOAP) + + subject, sp_entity_id = _subject_sp_info(req_info) + logger.info("Logout subject: %s" % (subject,)) + logger.info("local identifier: %s" % IDP.ident.local_name(sp_entity_id, + subject)) + + response = IDP.create_logout_response(req_info.message, [BINDING_SOAP]) + args = http_soap_message(response) + + delco = delete_cookie(environ, "idpauthn") + if delco: + args["headers"].append(delco) + + resp = Response(args["data"], headers=args["headers"]) + return resp(environ, start_response) + + +def delete_cookie(environ, name): + kaka = environ.get("HTTP_COOKIE", '') + if kaka: + cookie_obj = SimpleCookie(kaka) + morsel = cookie_obj.get(name, None) + cookie = SimpleCookie() + cookie[name] = "" + cookie[name]['path'] = "/" + logger.debug("Expire: %s" % morsel) + cookie[name]["expires"] = _expiration("dawn") + return tuple(cookie.output().split(": ", 1)) + return None + +def set_cookie(name, path, value): + cookie = SimpleCookie() + cookie[name] = value + cookie[name]['path'] = "/" + cookie[name]["expires"] = _expiration(5) # 5 minutes from now + logger.debug("Cookie expires: %s" % cookie[name]["expires"]) + return tuple(cookie.output().split(": ", 1)) + +# ---------------------------------------------------------------------------- + +# map urls to functions +AUTHN_URLS = [ + (r'whoami$', whoami), + (r'whoami/(.*)$', whoami), + (r'post_sso$', sso_post), + (r'post_sso/(.*)$', sso_post), + (r'sso$', sso), + (r'sso/(.*)$', sso), + (r'logout$', slo), + (r'logout/(.*)$', slo), + (r'logout_post$', slo_post), + (r'logout_post/(.*)$', slo_post), + (r'logout_soap$', slo_soap), + (r'logout_soap/(.*)$', slo_soap), +] + +NON_AUTHN_URLS = [ + (r'login?(.*)$', do_authentication), + (r'verify?(.*)$', do_verify), + ] + +# ---------------------------------------------------------------------------- + +def application(environ, start_response): + """ + The main WSGI application. Dispatch the current request to + the functions from above and store the regular expression + captures in the WSGI environment as `myapp.url_args` so that + the functions from above can access the url placeholders. + + If nothing matches call the `not_found` function. + + :param environ: The HTTP application environment + :param start_response: The application to run when the handling of the + request is done + :return: The response as a list of lines + """ + + path = environ.get('PATH_INFO', '').lstrip('/') + kaka = environ.get("HTTP_COOKIE", None) + logger.info(" PATH: %s" % path) + + if kaka: + logger.info("= KAKA =") + user = kaka2user(kaka) + else: + try: + query = parse_qs(environ["QUERY_STRING"]) + logger.debug("QUERY: %s" % query) + user = IDP.authn[query["id"][0]] + except KeyError: + user = None + + if not user: + logger.info("-- No USER --") + for regex, callback in NON_AUTHN_URLS: + match = re.search(regex, path) + if match is not None: + try: + environ['myapp.url_args'] = match.groups()[0] + except IndexError: + environ['myapp.url_args'] = path + logger.info("callback: %s" % (callback,)) + return callback(environ, start_response, user) + for regex, callback in AUTHN_URLS: + match = re.search(regex, path) + if match is not None: + return not_authn(environ, start_response) + else: + for regex, callback in AUTHN_URLS: + match = re.search(regex, path) + if match is not None: + try: + environ['myapp.url_args'] = match.groups()[0] + except IndexError: + environ['myapp.url_args'] = path + logger.info("callback: %s" % (callback,)) + return callback(environ, start_response, user) + return not_found(environ, start_response) + +# ---------------------------------------------------------------------------- +from mako.lookup import TemplateLookup +ROOT = './' +LOOKUP = TemplateLookup(directories=[ROOT + 'templates', ROOT + 'htdocs'], + module_directory=ROOT + 'modules', + input_encoding='utf-8', output_encoding='utf-8') +# ---------------------------------------------------------------------------- + +if __name__ == '__main__': + import sys + from idp_user import USERS + from wsgiref.simple_server import make_server + + PORT = 8088 + + IDP = server.Server(sys.argv[1]) + IDP.ticket = {} + SRV = make_server('', PORT, application) + print "IdP listening on port: %s" % PORT + SRV.serve_forever() \ No newline at end of file diff --git a/example/idp2/idp_conf.py b/example/idp2/idp_conf.py new file mode 100644 index 0000000..f21d7ec --- /dev/null +++ b/example/idp2/idp_conf.py @@ -0,0 +1,74 @@ +from saml2 import BINDING_HTTP_REDIRECT +from saml2 import BINDING_HTTP_POST +from saml2 import BINDING_SOAP +from saml2.saml import NAME_FORMAT_URI +from saml2.saml import NAMEID_FORMAT_TRANSIENT +from saml2.saml import NAMEID_FORMAT_PERSISTENT + +#BASE = "http://lingon.ladok.umu.se:8088" +#BASE = "http://lingon.catalogix.se:8088" +BASE = "http://localhost:8088" + +CONFIG={ + "entityid" : "%s/idp.xml" % BASE, + "description": "My IDP", + "service": { + "idp": { + "name" : "Rolands IdP", + "endpoints" : { + "single_sign_on_service":[(BASE+"/sso",BINDING_HTTP_REDIRECT), + (BASE+"/post_sso", BINDING_HTTP_POST)], + "single_logout_service":[(BASE+"/logout", + BINDING_HTTP_REDIRECT), + (BASE+"/logout_post", + BINDING_HTTP_POST), + (BASE+"/logout_soap", + BINDING_SOAP)], + }, + "policy": { + "default": { + "lifetime": {"minutes":15}, + "attribute_restrictions": None, # means all I have + "name_form": NAME_FORMAT_URI + }, + }, + "subject_data": "./idp.subject.db", + "name_id_format": [NAMEID_FORMAT_TRANSIENT, + NAMEID_FORMAT_PERSISTENT] + } + }, + "debug" : 1, + "key_file" : "pki/mykey.pem", + "cert_file" : "pki/mycert.pem", + "metadata" : { + "local": ["../sp.xml"], + }, + "organization": { + "display_name": "Rolands Identiteter", + "name": "Rolands Identiteter", + "url": "http://www.example.com", + }, + "contact_person": [{ + "contact_type": "technical", + "given_name": "Roland", + "sur_name": "Hedberg", + "email_address": "technical@example.com" + },{ + "contact_type": "support", + "given_name": "Support", + "email_address": "support@example.com" + }, + ], + # This database holds the map between a subjects local identifier and + # the identifier returned to a SP + #"xmlsec_binary": "/usr/local/bin/xmlsec1", + "attribute_map_dir" : "../attributemaps", + "logger": { + "rotating": { + "filename": "idp.log", + "maxBytes": 500000, + "backupCount": 5, + }, + "loglevel": "debug", + } +} diff --git a/example/idp2/idp_user.py b/example/idp2/idp_user.py new file mode 100644 index 0000000..17fef55 --- /dev/null +++ b/example/idp2/idp_user.py @@ -0,0 +1,28 @@ +USERS = { + "roland": { + "surname": "Hedberg", + "givenName": "Roland", + "eduPersonAffiliation": "staff", + "uid": "rohe0002" + }, + "ozzie": { + "surname": "Guillen", + "givenName": "Ozzie", + "eduPersonAffiliation": "affiliate" + }, + "derek": { + "surname": "Jeter", + "givenName": "Derek", + "eduPersonAffiliation": "affiliate" + }, + "ichiro": { + "surname": "Suzuki", + "givenName": "Ischiro", + "eduPersonAffiliation": "affiliate" + }, + "ryan": { + "surname": "Howard", + "givenName": "Ryan", + "eduPersonAffiliation": "affiliate" + } +} \ No newline at end of file diff --git a/example/idp2/pki/mycert.pem b/example/idp2/pki/mycert.pem new file mode 100644 index 0000000..d4a0873 --- /dev/null +++ b/example/idp2/pki/mycert.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC8jCCAlugAwIBAgIJAJHg2V5J31I8MA0GCSqGSIb3DQEBBQUAMFoxCzAJBgNV +BAYTAlNFMQ0wCwYDVQQHEwRVbWVhMRgwFgYDVQQKEw9VbWVhIFVuaXZlcnNpdHkx +EDAOBgNVBAsTB0lUIFVuaXQxEDAOBgNVBAMTB1Rlc3QgU1AwHhcNMDkxMDI2MTMz +MTE1WhcNMTAxMDI2MTMzMTE1WjBaMQswCQYDVQQGEwJTRTENMAsGA1UEBxMEVW1l +YTEYMBYGA1UEChMPVW1lYSBVbml2ZXJzaXR5MRAwDgYDVQQLEwdJVCBVbml0MRAw +DgYDVQQDEwdUZXN0IFNQMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkJWP7 +bwOxtH+E15VTaulNzVQ/0cSbM5G7abqeqSNSs0l0veHr6/ROgW96ZeQ57fzVy2MC +FiQRw2fzBs0n7leEmDJyVVtBTavYlhAVXDNa3stgvh43qCfLx+clUlOvtnsoMiiR +mo7qf0BoPKTj7c0uLKpDpEbAHQT4OF1HRYVxMwIDAQABo4G/MIG8MB0GA1UdDgQW +BBQ7RgbMJFDGRBu9o3tDQDuSoBy7JjCBjAYDVR0jBIGEMIGBgBQ7RgbMJFDGRBu9 +o3tDQDuSoBy7JqFepFwwWjELMAkGA1UEBhMCU0UxDTALBgNVBAcTBFVtZWExGDAW +BgNVBAoTD1VtZWEgVW5pdmVyc2l0eTEQMA4GA1UECxMHSVQgVW5pdDEQMA4GA1UE +AxMHVGVzdCBTUIIJAJHg2V5J31I8MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF +BQADgYEAMuRwwXRnsiyWzmRikpwinnhTmbooKm5TINPE7A7gSQ710RxioQePPhZO +zkM27NnHTrCe2rBVg0EGz7QTd1JIwLPvgoj4VTi/fSha/tXrYUaqc9AqU1kWI4WN ++vffBGQ09mo+6CffuFTZYeOhzP/2stAPwCTU4kxEoiy0KpZMANI= +-----END CERTIFICATE----- diff --git a/example/idp2/pki/mykey.pem b/example/idp2/pki/mykey.pem new file mode 100644 index 0000000..d9ec5f8 --- /dev/null +++ b/example/idp2/pki/mykey.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDkJWP7bwOxtH+E15VTaulNzVQ/0cSbM5G7abqeqSNSs0l0veHr +6/ROgW96ZeQ57fzVy2MCFiQRw2fzBs0n7leEmDJyVVtBTavYlhAVXDNa3stgvh43 +qCfLx+clUlOvtnsoMiiRmo7qf0BoPKTj7c0uLKpDpEbAHQT4OF1HRYVxMwIDAQAB +AoGAbx9rKH91DCw/ZEPhHsVXJ6cYHxGcMoAWvnMMC9WUN+bNo4gNL205DLfsxXA1 +jqXFXZj3+38vSFumGPA6IvXrN+Wyp3+Lz3QGc4K5OdHeBtYlxa6EsrxPgvuxYDUB +vx3xdWPMjy06G/ML+pR9XHnRaPNubXQX3UxGBuLjwNXVmyECQQD2/D84tYoCGWoq +5FhUBxFUy2nnOLKYC/GGxBTX62iLfMQ3fbQcdg2pJsB5rrniyZf7UL+9FOsAO9k1 +8DO7G12DAkEA7Hkdg1KEw4ZfjnnjEa+KqpyLTLRQ91uTVW6kzR+4zY719iUJ/PXE +PxJqm1ot7mJd1LW+bWtjLpxs7jYH19V+kQJBAIEpn2JnxdmdMuFlcy/WVmDy09pg +0z0imdexeXkFmjHAONkQOv3bWv+HzYaVMo8AgCOksfEPHGqN4eUMTfFeuUMCQF+5 +E1JSd/2yCkJhYqKJHae8oMLXByNqRXTCyiFioutK4JPYIHfugJdLfC4QziD+Xp85 +RrGCU+7NUWcIJhqfiJECQAIgUAzfzhdj5AyICaFPaOQ+N8FVMLcTyqeTXP0sIlFk +JStVibemTRCbxdXXM7OVipz1oW3PBVEO3t/VyjiaGGg= +-----END RSA PRIVATE KEY----- diff --git a/example/idp2/templates/root.mako b/example/idp2/templates/root.mako new file mode 100644 index 0000000..3d6e9a0 --- /dev/null +++ b/example/idp2/templates/root.mako @@ -0,0 +1,37 @@ +<% self.seen_css = set() %> +<%def name="css_link(path, media='')" filter="trim"> + % if path not in self.seen_css: + + % endif + <% self.seen_css.add(path) %> + +<%def name="css()" filter="trim"> + ${css_link('/css/main.css', 'screen')} + +<%def name="pre()" filter="trim"> +
+

Login

+
+ +<%def name="post()" filter="trim"> +
+ +
+ + ## + +IDP test login + ${self.css()} + + + + ${pre()} +## ${comps.dict_to_table(pageargs)} +##

+${next.body()} +${post()} + +