From f461a713ce28e434a34dca4e4d1abbfe255ef1ff Mon Sep 17 00:00:00 2001 From: Patrick Uiterwijk Date: Mon, 6 Oct 2014 19:12:13 +0200 Subject: [PATCH] Add OpenIDP Provider This commit implements all the core functionality needed to expose an OpenID Identity Provider including a framework to dynamycally add extensions. Signed-off-by: Patrick Uiterwijk Signed-off-by: Simo Sorce Reviewed-by: Patrick Uiterwijk --- ipsilon/providers/openid/__init__.py | 0 ipsilon/providers/openid/auth.py | 261 ++++++++++++++++++ .../providers/openid/extensions/__init__.py | 0 ipsilon/providers/openid/extensions/common.py | 67 +++++ ipsilon/providers/openid/meta.py | 102 +++++++ ipsilon/providers/openidp.py | 150 ++++++++++ ipsilon/root.py | 5 +- templates/master.html | 7 + templates/openid/consent_form.html | 44 +++ templates/openid/userpage.html | 10 + templates/openid/xrds.xml | 16 ++ 11 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 ipsilon/providers/openid/__init__.py create mode 100755 ipsilon/providers/openid/auth.py create mode 100644 ipsilon/providers/openid/extensions/__init__.py create mode 100755 ipsilon/providers/openid/extensions/common.py create mode 100755 ipsilon/providers/openid/meta.py create mode 100755 ipsilon/providers/openidp.py create mode 100644 templates/openid/consent_form.html create mode 100644 templates/openid/userpage.html create mode 100644 templates/openid/xrds.xml diff --git a/ipsilon/providers/openid/__init__.py b/ipsilon/providers/openid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ipsilon/providers/openid/auth.py b/ipsilon/providers/openid/auth.py new file mode 100755 index 0000000..abf19ae --- /dev/null +++ b/ipsilon/providers/openid/auth.py @@ -0,0 +1,261 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Ipsilon project Contributors, for licensee see COPYING + +from ipsilon.providers.common import ProviderPageBase +from ipsilon.providers.common import AuthenticationError, InvalidRequest +from ipsilon.providers.openid.meta import XRDSHandler, UserXRDSHandler +from ipsilon.providers.openid.meta import IDHandler +from ipsilon.util.trans import Transaction +from ipsilon.util.user import UserSession + +from openid.server.server import ProtocolError, EncodingError + +import cherrypy +import time +import json + + +class AuthenticateRequest(ProviderPageBase): + + def __init__(self, *args, **kwargs): + super(AuthenticateRequest, self).__init__(*args, **kwargs) + self.stage = 'init' + self.trans = None + + def _preop(self, *args, **kwargs): + try: + # generate a new id or get current one + self.trans = Transaction('openid', **kwargs) + if self.trans.cookie.value != self.trans.provider: + self.debug('Invalid transaction, %s != %s' % ( + self.trans.cookie.value, self.trans.provider)) + except Exception, e: # pylint: disable=broad-except + self.debug('Transaction initialization failed: %s' % repr(e)) + raise cherrypy.HTTPError(400, 'Invalid transaction id') + + def pre_GET(self, *args, **kwargs): + self._preop(*args, **kwargs) + + def pre_POST(self, *args, **kwargs): + self._preop(*args, **kwargs) + + def _get_form(self, *args): + form = None + if args is not None: + first = args[0] if len(args) > 0 else None + second = first[0] if len(first) > 0 else None + if type(second) is dict: + form = second.get('form', None) + return form + + def auth(self, *args, **kwargs): + request = None + form = self._get_form(args) + try: + request = self._parse_request(**kwargs) + return self._openid_checks(request, form, **kwargs) + except InvalidRequest, e: + raise cherrypy.HTTPError(e.code, e.msg) + except AuthenticationError, e: + if request is None: + raise cherrypy.HTTPError(e.code, e.msg) + return self._respond(request.answer(False)) + + def _parse_request(self, **kwargs): + request = None + try: + request = self.cfg.server.decodeRequest(kwargs) + except ProtocolError, openid_error: + self.debug('ProtocolError: %s' % openid_error) + raise InvalidRequest('Invalid OpenID request', 400) + + if request is None: + self.debug('No request') + raise cherrypy.HTTPRedirect(self.basepath) + + return request + + def _openid_checks(self, request, form, **kwargs): + us = UserSession() + user = us.get_user() + immediate = False + + self.debug('Mode: %s Stage: %s User: %s' % ( + kwargs['openid.mode'], self.stage, user.name)) + if kwargs.get('openid.mode', None) == 'checkid_setup': + if user.is_anonymous: + if self.stage == 'init': + returl = '%s/openid/Continue?%s' % ( + self.basepath, self.trans.get_GET_arg()) + data = {'openid_stage': 'auth', + 'openid_request': json.dumps(kwargs), + 'login_return': returl} + self.trans.store(data) + redirect = '%s/login?%s' % (self.basepath, + self.trans.get_GET_arg()) + self.debug('Redirecting: %s' % redirect) + raise cherrypy.HTTPRedirect(redirect) + else: + raise AuthenticationError("unknown user", 401) + + elif kwargs.get('openid.mode', None) == 'checkid_immediate': + # This is immediate, so we need to assert or fail + if user.is_anonymous: + return self._respond(request.answer(False)) + + immediate = True + + else: + return self._respond(self.cfg.server.handleRequest(request)) + + # check if this is discovery or ned identity matching checks + if not request.idSelect(): + idurl = self.cfg.identity_url_template % {'username': user.name} + if request.identity != idurl: + raise AuthenticationError("User ID mismatch!", 401) + + # check if the ralying party is trusted + if request.trust_root in self.cfg.untrusted_roots: + raise AuthenticationError("Untrusted Relying party", 401) + + # if the party is explicitly whitelisted just respond + if request.trust_root in self.cfg.trusted_roots: + return self._respond(self._response(request, us)) + + allowroot = 'allow-%s' % request.trust_root + + try: + userdata = user.load_plugin_data(self.cfg.name) + expiry = int(userdata[allowroot]) + except Exception, e: # pylint: disable=broad-except + self.debug(e) + expiry = 0 + if expiry > int(time.time()): + self.debug("User has unexpired previous authorization") + return self._respond(self._response(request, us)) + + if immediate: + raise AuthenticationError("No consent for immediate", 401) + + if self.stage == 'consent': + if form is None: + raise AuthenticationError("Unintelligible consent", 401) + allow = form.get('decided_allow', False) + if not allow: + raise AuthenticationError("User declined", 401) + try: + days = int(form.get('remember_for_days', '0')) + if days < 0 or days > 7: + raise + userdata = {allowroot: str(int(time.time()) + (days*86400))} + user.save_plugin_data(self.cfg.name, userdata) + except Exception, e: # pylint: disable=broad-except + self.debug(e) + days = 0 + + # all done we consent! + return self._respond(self._response(request, us)) + + else: + data = {'openid_stage': 'consent', + 'openid_request': json.dumps(kwargs)} + self.trans.store(data) + + # Add extension data to this list of dictionaries + ad = [ + { + "Trust Root": request.trust_root, + }, + ] + userattrs = us.get_user_attrs() + for n, e in self.cfg.extensions.items(): + data = e.get_display_data(request, userattrs) + self.debug('%s returned %s' % (n, repr(data))) + ad.append(data) + + context = { + "title": 'Consent', + "action": '%s/openid/Consent' % (self.basepath), + "trustroot": request.trust_root, + "username": user.name, + "authz_details": ad, + } + context.update(dict((self.trans.get_POST_tuple(),))) + # pylint: disable=star-args + return self._template('openid/consent_form.html', **context) + + def _response(self, request, session): + user = session.get_user() + identity_url = self.cfg.identity_url_template % {'username': user.name} + response = request.answer( + True, + identity=identity_url, + claimed_id=identity_url + ) + userattrs = session.get_user_attrs() + for _, e in self.cfg.extensions.items(): + resp = e.get_response(request, userattrs) + if resp is not None: + response.addExtension(resp) + return response + + def _respond(self, response): + try: + self.debug('Response: %s' % response) + webresponse = self.cfg.server.encodeResponse(response) + cherrypy.response.headers.update(webresponse.headers) + cherrypy.response.status = webresponse.code + return webresponse.body + except EncodingError, encoding_error: + self.debug('Unable to respond because: %s' % encoding_error) + cherrypy.response.headers = { + 'Content-Type': 'text/plain; charset=UTF-8' + } + cherrypy.response.status = 400 + return encoding_error.response.encodeToKVForm() + + +class Continue(AuthenticateRequest): + + def GET(self, *args, **kwargs): + transdata = self.trans.retrieve() + self.stage = transdata.get('openid_stage', None) + openid_request = transdata.get('openid_request', None) + if self.stage is None or openid_request is None: + raise AuthenticationError("unknown state", 400) + + kwargs = json.loads(openid_request) + return self.auth(**kwargs) + + +class Consent(AuthenticateRequest): + + def POST(self, *args, **kwargs): + transdata = self.trans.retrieve() + self.stage = transdata.get('openid_stage', None) + openid_request = transdata.get('openid_request', None) + if self.stage is None or openid_request is None: + raise AuthenticationError("unknown state", 400) + + args = ({'form': kwargs},) + kwargs = json.loads(openid_request) + return self.auth(*args, **kwargs) + + +class OpenID(AuthenticateRequest): + + def __init__(self, *args, **kwargs): + super(OpenID, self).__init__(*args, **kwargs) + self.XRDS = XRDSHandler(*args, **kwargs) + self.yadis = UserXRDSHandler(*args, **kwargs) + self.id = IDHandler(*args, **kwargs) + self.Continue = Continue(*args, **kwargs) + self.Consent = Consent(*args, **kwargs) + self.trans = None + + def GET(self, *args, **kwargs): + return self.auth(**kwargs) + + def POST(self, *args, **kwargs): + return self.auth(**kwargs) diff --git a/ipsilon/providers/openid/extensions/__init__.py b/ipsilon/providers/openid/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ipsilon/providers/openid/extensions/common.py b/ipsilon/providers/openid/extensions/common.py new file mode 100755 index 0000000..b75d394 --- /dev/null +++ b/ipsilon/providers/openid/extensions/common.py @@ -0,0 +1,67 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Ipsilon project Contributors, for licensee see COPYING + +from __future__ import absolute_import + +from ipsilon.providers.common import FACILITY +from ipsilon.util.plugin import PluginLoader +from ipsilon.util.log import Log + + +class OpenidExtensionBase(Log): + + def __init__(self, name=None): + self.name = name + self.enabled = False + self.type_uris = [] + + def _display(self, request, userdata): + raise NotImplementedError + + def _response(self, request, userdata): + raise NotImplementedError + + def get_type_uris(self): + if self.enabled: + return self.type_uris + return [] + + def get_display_data(self, request, userdata): + if self.enabled: + return self._display(request, userdata) + return {} + + def get_response(self, request, userdata): + if self.enabled: + return self._response(request, userdata) + return None + + def enable(self): + self.enabled = True + + def disable(self): + self.enabled = False + + +FACILITY = 'openid_extensions' + + +class LoadExtensions(Log): + + def __init__(self, enabled): + loader = PluginLoader(LoadExtensions, FACILITY, 'OpenidExtension') + self.plugins = loader.get_plugin_data() + + available = self.plugins['available'].keys() + self._debug('Available Extensions: %s' % str(available)) + + for item in enabled: + if item not in self.plugins['available']: + self.debug('<%s> not available' % item) + continue + self.debug('Enable OpenId extension: %s' % item) + self.plugins['available'][item].enable() + + def get_extensions(self): + return self.plugins['available'] diff --git a/ipsilon/providers/openid/meta.py b/ipsilon/providers/openid/meta.py new file mode 100755 index 0000000..a04a78c --- /dev/null +++ b/ipsilon/providers/openid/meta.py @@ -0,0 +1,102 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Ipsilon project Contributors, for licensee see COPYING + +from ipsilon.providers.common import ProviderPageBase + +import cherrypy + + +class MetaHandler(ProviderPageBase): + + def __init__(self, *args, **kwargs): + super(MetaHandler, self).__init__(*args, **kwargs) + self.default_headers.update({ + 'Cache-Control': 'no-cache, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': 'Thu, 01 Dec 1994 16:00:00 GMT', + }) + self._template_name = None + self._take_args = False + + def reply(self, **kwargs): + if self._template_name is None: + raise ValueError('Template not set') + return self._template(self._template_name, **kwargs) + + def default(self, *args, **kwargs): + if self._take_args: + return self.root(*args, **kwargs) + raise cherrypy.NotFound() + + +class XRDSHandler(MetaHandler): + + def __init__(self, *args, **kwargs): + super(XRDSHandler, self).__init__(*args, **kwargs) + self.default_headers['Content-Type'] = 'application/xrds+xml' + self._template_name = 'openid/xrds.xml' + + def GET(self, *args, **kwargs): + types = [ + 'http://specs.openid.net/auth/2.0/server', + 'http://openid.net/server/1.0', + ] + for _, e in self.cfg.extensions.items(): + types.extend(e.get_type_uris()) + + return self.reply(types=types, + uri=self.cfg.endpoint_url) + + +class UserXRDSHandler(XRDSHandler): + + def __init__(self, *args, **kwargs): + super(UserXRDSHandler, self).__init__(*args, **kwargs) + self._take_args = True + + def GET(self, *args, **kwargs): + if len(args) != 1: + raise cherrypy.NotFound() + if args[0].endswith('.xrds'): + name = args[0][:-5] + identity_url = self.cfg.identity_url_template % {'username': name} + types = [ + 'http://specs.openid.net/auth/2.0/signon', + 'http://openid.net/signon/1.0', + ] + for _, e in self.cfg.extensions.items(): + types.extend(e.get_type_uris()) + + return self.reply(types=types, + uri=self.cfg.endpoint_url, + localid=identity_url) + + raise cherrypy.NotFound() + + +class IDHandler(MetaHandler): + + def __init__(self, *args, **kwargs): + super(IDHandler, self).__init__(*args, **kwargs) + self._template_name = 'openid/userpage.html' + self._take_args = True + + def GET(self, *args, **kwargs): + if len(args) != 1: + raise cherrypy.NotFound() + name = args[0] + yadis = '%syadis/%s.xrds' % (self.cfg.endpoint_url, name) + cherrypy.response.headers['X-XRDS-Location'] = yadis + + endpoint_url = self.cfg.endpoint_url + identity_url = self.cfg.identity_url_template % {'username': name} + + HEAD_LINK = '' + provider_heads = [HEAD_LINK % ('openid2.provider', endpoint_url), + HEAD_LINK % ('openid.server', endpoint_url)] + user_heads = [HEAD_LINK % ('openid2.delegate', identity_url), + HEAD_LINK % ('openid.local_id', identity_url)] + heads = {'provider': provider_heads, 'user': user_heads} + + return self.reply(title='Userpage', username=name, heads=heads) diff --git a/ipsilon/providers/openidp.py b/ipsilon/providers/openidp.py new file mode 100755 index 0000000..2e41050 --- /dev/null +++ b/ipsilon/providers/openidp.py @@ -0,0 +1,150 @@ +#!/usr/bin/python +# +# Copyright (C) 2014 Ipsilon project Contributors, for licensee see COPYING + +from __future__ import absolute_import + +from ipsilon.providers.common import ProviderBase +from ipsilon.providers.common import FACILITY +from ipsilon.providers.openid.auth import OpenID +from ipsilon.providers.openid.extensions.common import LoadExtensions +from ipsilon.util.plugin import PluginObject + +from openid.server.server import Server +# TODO: Move this to the database +from openid.store.memstore import MemoryStore + + +class IdpProvider(ProviderBase): + + def __init__(self): + super(IdpProvider, self).__init__('openid', 'openid') + self.page = None + self.server = None + self.basepath = None + self.extensions = None + self.description = """ +Provides OpenID 2.0 authentication infrastructure. """ + + self._options = { + 'default email domain': [ + """Default email domain, for users missing email property.""", + 'string', + 'example.com' + ], + 'endpoint url': [ + """The Absolute URL of the OpenID provider""", + 'string', + 'http://localhost:8080/idp/openid/' + ], + 'identity url template': [ + """The templated URL where identities are exposed.""", + 'string', + 'http://localhost:8080/idp/openid/id/%(username)s' + ], + 'trusted roots': [ + """List of trusted relying parties.""", + 'list', + [] + ], + 'untrusted roots': [ + """List of untrusted relying parties.""", + 'list', + [] + ], + 'enabled extensions': [ + """List of enabled extensions""", + 'list', + [] + ], + } + + @property + def endpoint_url(self): + url = self.get_config_value('endpoint url') + if url.endswith('/'): + return url + else: + return url+'/' + + @property + def default_email_domain(self): + return self.get_config_value('default email domain') + + @property + def identity_url_template(self): + url = self.get_config_value('identity url template') + if url.endswith('/'): + return url + else: + return url+'/' + + @property + def trusted_roots(self): + return self.get_config_value('trusted roots') + + @property + def untrusted_roots(self): + return self.get_config_value('untrusted roots') + + @property + def enabled_extensions(self): + return self.get_config_value('enabled extensions') + + def get_tree(self, site): + self.init_idp() + self.page = OpenID(site, self) + # self.admin = AdminPage(site, self) + + # Expose OpenID presence in the root + headers = site[FACILITY]['root'].default_headers + headers['X-XRDS-Location'] = self.endpoint_url+'XRDS' + + html_heads = site[FACILITY]['root'].html_heads + HEAD_LINK = '' + openid_heads = [HEAD_LINK % ('openid2.provider', self.endpoint_url), + HEAD_LINK % ('openid.server', self.endpoint_url)] + html_heads['openid'] = openid_heads + + return self.page + + def init_idp(self): + self.server = Server(MemoryStore(), op_endpoint=self.endpoint_url) + loader = LoadExtensions(self.enabled_extensions) + self.extensions = loader.get_extensions() + + def on_enable(self): + self.init_idp() + + +class Installer(object): + + def __init__(self): + self.name = 'openid' + self.ptype = 'provider' + + def install_args(self, group): + group.add_argument('--openid', choices=['yes', 'no'], default='yes', + help='Configure OpenID Provider') + + def configure(self, opts): + if opts['openid'] != 'yes': + return + + proto = 'https' + if opts['secure'].lower() == 'no': + proto = 'http' + url = '%s://%s/%s/openid/' % ( + proto, opts['hostname'], opts['instance']) + + # Add configuration data to database + po = PluginObject() + po.name = 'openid' + po.wipe_data() + + po.wipe_config_values(FACILITY) + config = {'endpoint url': url, + 'identity_url_template': '%sid/%%(username)s' % url, + 'enabled': '1'} + po.set_config(config) + po.save_plugin_config(FACILITY) diff --git a/ipsilon/root.py b/ipsilon/root.py index b2654ac..e214115 100755 --- a/ipsilon/root.py +++ b/ipsilon/root.py @@ -39,6 +39,7 @@ class Root(Page): if template_env: sites[site]['template_env'] = template_env super(Root, self).__init__(sites[site]) + self.html_heads = dict() # set up error pages cherrypy.config['error_page.400'] = errors.Error_400(self._site) @@ -60,4 +61,6 @@ class Root(Page): ProviderPlugins(self._site, self.admin) def root(self): - return self._template('index.html', title='Ipsilon') + self.debug(self.html_heads) + return self._template('index.html', title='Ipsilon', + heads=self.html_heads) diff --git a/templates/master.html b/templates/master.html index 21178fc..cf1275a 100644 --- a/templates/master.html +++ b/templates/master.html @@ -8,6 +8,13 @@ + {%- if heads %} + {%- for group, value in heads.items() %} + {%- for head in value %} + {{ head }} + {%- endfor %} + {%- endfor %} + {%- endif %} diff --git a/templates/openid/consent_form.html b/templates/openid/consent_form.html new file mode 100644 index 0000000..8c3813e --- /dev/null +++ b/templates/openid/consent_form.html @@ -0,0 +1,44 @@ +{% extends "master.html" %} +{% block main %} + +
+

The OpenID realying party {{ trustroot }} is asking + to authorize access for {{ username }}.

+

Please review the authorization details

+
+ + + +{% endblock %} diff --git a/templates/openid/userpage.html b/templates/openid/userpage.html new file mode 100644 index 0000000..db87974 --- /dev/null +++ b/templates/openid/userpage.html @@ -0,0 +1,10 @@ +{% extends "master.html" %} +{% block main %} +
+{% endblock %} diff --git a/templates/openid/xrds.xml b/templates/openid/xrds.xml new file mode 100644 index 0000000..86b3e0f --- /dev/null +++ b/templates/openid/xrds.xml @@ -0,0 +1,16 @@ + + + + +{%- for t in types %} + {{ t }} +{%- endfor %} +{%- if uri %} + {{ uri }} +{%- endif %} +{%- if localid %} + {{ localid }} +{%- endif %} + + + -- 2.20.1